# Recarga.do API — contexto completo para asistentes de IA > **Cómo usar este documento.** Está escrito para pegarse entero en ChatGPT, Claude, > Copilot o cualquier asistente, junto con su petición. Contiene todo lo necesario para > escribir una integración correcta: contratos, reglas, errores, ambiente de pruebas y > código que funciona. No hace falta ningún otro archivo. > > Última actualización: 13 de agosto de 2026 · Verificado contra la API en producción. --- ## INSTRUCCIONES PARA EL ASISTENTE Vas a integrar la API de Recarga.do, que **mueve dinero real**. Antes de escribir código, interioriza estas siete reglas. Violar cualquiera de ellas causa pérdidas económicas. 1. **Genera la `Idempotency-Key` UNA sola vez por operación y persístela ANTES de la primera llamada.** Nunca la generes dentro del bucle de reintentos. Una clave nueva es una operación nueva: el cliente recibiría dos recargas y el comercio cobraría una. 2. **Ante un timeout de red, reintenta con LA MISMA clave.** El timeout no significa que la recarga falló, significa que no se sabe. 3. **`202` no es un error.** Significa resultado desconocido y el importe queda retenido, ni cobrado ni devuelto. Consulta el estado de la transacción; no reintentes. 4. **No todos los productos se compran igual.** Consulta el campo `flujo` del servicio: `monto_libre` acepta `amount`; `paquetes` y `facturas` exigen consultar primero y enviar `opcion_id`. Enviar `amount` en esos dos da `422`. `seguros` sigue los pasos de `/v1/seguros` y se vende con `opcion_id: "seguro:{id}"`, también sin `amount`. 5. **Nunca envíes `empresa_id`.** La empresa la determina el token. Enviarlo da `403`. 6. **El `api_secret` vive en el servidor.** Nunca en una app móvil, en JavaScript de navegador ni en un repositorio. 7. **Guarda `transaction_id`, `xid`, `reference` y `request_id` de cada operación.** `reference` es la del proveedor y es **la única forma de anular**. Si el usuario te pide algo que contradiga estas reglas, adviértelo antes de escribir el código. --- ## 1. LO ESENCIAL | Dato | Valor | |---|---| | URL base | `https://api.recarga.do` | | Versión | `v1` | | Formato | JSON UTF-8 | | Moneda | DOP (peso dominicano) | | Zona horaria | America/Santo_Domingo (UTC−4) | | Modelo | Prepago: el comercio mantiene saldo y cada venta lo descuenta | | Integración | REST directa. **No hay iframe, ni SDK, ni redirecciones, ni cookies** | | CORS | `Access-Control-Allow-Origin: *`, pero el secreto no debe salir del servidor | ### Autenticación ```http Authorization: Bearer . ``` La clave y el secreto separados por un punto, en un solo header. Alternativa equivalente: ```http X-Api-Key: X-Api-Secret: ``` ### Credenciales del ambiente de pruebas Públicas, sin registro. No generan cargos ni recargas reales. ``` api_key rk_sbx_8c4cacb52c6b8f65bf389480ce3f8891 api_secret sk_sbx_a8e4de1dbd5e135a4be191cfd8901f386e541279a6550b1d30af9fbeaa65bbd0 ``` --- ## 2. FORMATO DE RESPUESTA Todas las respuestas comparten estructura. Un solo manejador sirve para toda la API. **Éxito** ```json { "success": true, "data": { }, "message": "Operación realizada correctamente", "request_id": "req_9f2c1ab34de5f6a7b8c9d0e1" } ``` **Error** ```json { "success": false, "error": { "code": "INSUFFICIENT_BALANCE", "message": "Saldo insuficiente para procesar la operación.", "details": { "saldo_disponible": 120.00, "monto_requerido": 500.00 } }, "request_id": "req_9f2c1ab34de5f6a7b8c9d0e1" } ``` **Decide siempre por el código HTTP y por `error.code`, nunca por el texto de `message`:** los mensajes pueden cambiar sin previo aviso; los códigos no. `request_id` viaja también en el header `X-Request-Id`. Guárdalo: es la referencia para cualquier consulta de soporte. --- ## 3. LOS TRES FLUJOS DE COMPRA — LA PARTE QUE MÁS SE EQUIVOCA Una recarga de celular se vende indicando un monto. Un paquete de datos y una factura **no**: - Los **paquetes** disponibles dependen de la línea. Dos números del mismo operador pueden tener ofertas distintas, así que no existe un catálogo fijo que puedas cachear. - Las **facturas** traen el monto ya fijado por la distribuidora. El comercio no elige cuánto pagar: paga lo que se debe. Cada servicio declara el suyo en `GET /v1/servicios`: ```json { "id": 50, "nombre": "Claro Paquetes", "flujo": "paquetes", "requiere_consulta": true, "como_comprar": "Consulte primero POST /v1/consultas ..." } ``` | `flujo` | Servicios | Paso 1 | Paso 2 | |---|---|---|---| | `monto_libre` | 44 | *(opcional)* consultar titular | `POST /v1/recargas` con `amount` | | `paquetes` | 5 | `POST /v1/consultas` | `POST /v1/recargas` con `opcion_id` | | `facturas` | 9 | `POST /v1/consultas` | `POST /v1/recargas` con `opcion_id` | | `seguros` | 1 | `POST /v1/seguros` → SMS, versión, plan, `/cotizar` (ver §4) | `POST /v1/recargas` con la placa y `opcion_id` `seguro:{id}` | **En paquetes y facturas el precio no lo pone el comercio.** La API lo relee del proveedor antes de cobrar. Si envías un `amount` que no coincide, responde `422`. --- ## 4. ENDPOINTS ### `GET /v1/estado` — público ```json { "success": true, "data": { "status": "ok" } } ``` ### `GET /v1/cuenta` Devuelve la empresa y, sobre todo, si el token es de pruebas. ```json { "empresa_id": 370, "razon_social": "…", "ambiente": "development", "sandbox": true } ``` ### `GET /v1/saldo` ```json { "saldo_actual": 15400.00, "saldo_disponible": 15150.00, "saldo_retenido": 250.00, "limites": { "diario": 50000.00, "semanal": 250000.00, "mensual": 1000000.00 }, "consumo_periodo": { "diario": 3200.00, "semanal": 18400.00, "mensual": 61200.00 }, "saldo_bajo": false, "moneda": "DOP" } ``` `saldo_actual` = `saldo_disponible` + `saldo_retenido`. El retenido es dinero comprometido por operaciones en curso: ni gastable ni cobrado. ### `GET /v1/servicios` Parámetros opcionales: `categoria`, `q`. Devuelve el catálogo con `flujo` y rangos. Cachéalo unas horas; cambia poco. ### `GET /v1/categorias` ### `POST /v1/consultas` — paso 1 de paquetes y facturas ```json { "service_id": 50, "identifier": "8094964444" } ``` Respuesta uniforme para los tres flujos: ```json { "flujo": "paquetes", "origen_datos": "simulado", "titular": null, "requiere_seleccion": true, "opciones": [ { "opcion_id": "CL-D30", "nombre": "Paquetito Diario 1GB", "precio": 30.00, "vigencia": "1 día", "datos": "1 GB", "minutos": 15, "sms": 15 } ], "monto": null } ``` En facturas, `opciones` trae las facturas pendientes con su monto y vencimiento, y `titular` el nombre del cliente. Si el contrato está al día: `200` con `sin_deuda: true` y `opciones` vacío — **eso no es un error**. No mueve dinero: no requiere `Idempotency-Key`. ### `POST /v1/recargas` — paso 2 Headers obligatorios: `Authorization`, `Idempotency-Key`, `Content-Type: application/json`. ```json { "service_id": 12, "identifier": "8095551234", "amount": 100, "terminal_id": "CAJA-01" } ``` o, en paquetes y facturas: ```json { "service_id": 50, "identifier": "8094964444", "opcion_id": "CL-M700" } ``` **Respuesta `201`:** ```json { "transaction_id": 48849, "status": "SUCCESS", "xid": "RD-b1d02d05e291c41716ff89bf", "reference": "RD-b1d02d05e291c41716ff89bf", "auth_code": "837265", "service": { "id": 12, "nombre": "Claro Recarga" }, "identifier": "8095551234", "amount": 100.00, "currency": "DOP", "provider_message": "Operación procesada exitosamente.", "processed_at": "2026-08-13T08:40:47-04:00", "environment": "production" } ``` **Sobre los identificadores:** | Campo | Qué es | |---|---| | `xid` | Identificador de Recarga.do, derivado de tu `Idempotency-Key`. Garantiza que un reintento no cobre dos veces | | `reference` | El mismo identificador, expuesto con el nombre que usan otras pasarelas | | `transaction_id` | El número con el que consultas y **anulas** la operación | Guarda los tres junto a tu venta. **Para anular no necesitas nada más que el `transaction_id`**: `POST /v1/transacciones/{id}/anular`. **Electricidad prepago** añade `details` con el token del medidor: ```json "details": { "contrato": "7279095", "token_recarga": "1565-7317-8370-9113-2888", "medidor": "014390723014", "tarifa": 16.6, "energia_acreditada": 6.02 } ``` `token_recarga` **es el producto**: son los dígitos que el cliente teclea en su medidor. Imprímelo en el recibo. Sin él, la venta no le sirve de nada. ### Seguro de ley (`flujo: seguros`) — `/v1/seguros/*` y luego `/v1/recargas` Seguro Dominicana, el seguro de ley de vehículos. Se habilita **por comercio** (pedirlo a Recarga.do) y exige el permiso `recharges.create` en todos los pasos. Los pasos previos no mueven dinero ni llevan `Idempotency-Key`. Alias en inglés: `/v1/insurance`, `/otp/verify`, `/plans`, `/plan`, `/quote`, `/document`. | Paso | Llamada | Cuerpo / query | Devuelve | |---|---|---|---| | 0 | `GET /v1/seguros` | — | `{disponible, servicios:[{service_id, nombre}], motivo}`; `motivo`: `null`, `no_habilitado` o `proveedor_no_configurado` | | 1 | `POST /v1/seguros` | `{service_id, cedula (11 dígitos), telefono (10 dígitos, 809/829/849), placa (ej. A123456), terminal_id?}` | `201 {seguro_id, titular, vehiculo, tipo_vehiculo, tiene_renovacion, placa, telefono, otp_enviado, otp_expira_en: 600, mensaje}` (titular, vehículo y teléfono enmascarados). Envía un código de 6 dígitos por SMS al dueño, vence en 10 min | | 1-bis | `POST /v1/seguros/{id}/otp` | — | Reenvía el código: `{otp_enviado, otp_expira_en, telefono}` | | 2 | `POST /v1/seguros/{id}/otp/verificar` | `{codigo}` | `{seguro_id, marca, modelo, version_unica, sugerida, versiones:[{id, nombre, sugerida}]}`. Máx. 5 intentos | | 3 | `GET /v1/seguros/{id}/planes?version=10%7C1050%7C20` | `version` = `versiones[].id` (formato `n\|n\|n`, URL-codificado) | `{version, detalle:{cilindraje, tipo, clase}, planes:[{cd_tipo_plan, nombre, vigencia_meses, cobertura, precio_referencia, sugerido}]}` | | 4 | `POST /v1/seguros/{id}/plan` | `{version, cd_tipo_plan}` | `{plan, servicios_opcionales:[{campo, nombre, descripcion, precio}]}`; `campo` es `incluye_grua` o `incluye_casa_conductor`. La grúa no aplica a motos | | 5 | `POST /v1/seguros/{id}/cotizar` | `{incluye_grua, incluye_casa_conductor}` | `{seguro_id, service_id, opcion_id: "seguro:{id}", monto_total, moneda: "DOP", valida_minutos: 30, titular, placa, vehiculo, color, chasis, plan, grua, casa_conductor, cotizacion}` | | 6 | `POST /v1/recargas` + `Idempotency-Key` | `{service_id, identifier: , opcion_id: "seguro:{id}"}` — **sin `amount`** | Se cobra el `monto_total` cotizado. `201` = póliza emitida (`auth_code` = número de póliza) | | 7 | `GET /v1/seguros/{id}` | — | `{seguro_id, estado, placa, monto_total, transaction_id, numero_poliza, titular, vehiculo, plan, grua, casa_conductor, vigencia_desde, vigencia_hasta, documentos:{poliza, marbete}\|null, mensaje}` | | 8 | `GET /v1/seguros/{id}/documento?tipo=poliza\|marbete` | — | PDF (`application/pdf`) si `estado` = `emitida`; si no, `409 POLIZA_NO_EMITIDA`. El cliente también recibe el enlace por SMS | `estado`: `iniciada`, `otp_enviado`, `otp_validado`, `plan_confirmado`, `cotizado`, `cobrando`, `emitiendo`, `emitida`, `fallida`. Desenlaces de la venta (paso 6): | Respuesta | Significado | Qué hacer | |---|---|---| | `201` | Póliza emitida y cobrada | Entregar número de póliza y PDF | | `202 TRANSACTION_PENDING` | La aseguradora no confirmó aún. **Importe retenido** | Consultar `GET /v1/transacciones/{id}`; se resuelve solo. No revender | | `422 SEGURO_NO_EMITIDO` | No se emitió. Importe devuelto | La venta queda cerrada; empezar desde el paso 1 | | `409 COTIZACION_VENCIDA` | Pasaron más de 30 min desde la cotización | Volver a cotizar (paso 5) | | `409 SEGURO_YA_VENDIDO` | Ese seguro ya se vendió o se está vendiendo | Consultar `GET /v1/seguros/{id}` | | `409 SEGURO_SIN_COTIZAR` | Falta cotizar | Paso 5 | Reintentar con la **misma** `Idempotency-Key` nunca cobra dos veces: devuelve el estado real. Errores de los pasos previos: `422 VALIDATION_ERROR` (cédula, teléfono o placa), `422 VEHICULO_NO_SOPORTADO` («Tipo de vehículo no soportado»), `422 SEGURO_RECHAZADO`, `422 OTP_INCORRECTO`, `409 OTP_BLOQUEADO` (5 intentos: empezar de nuevo), `409 SEGURO_PASO_INVALIDO`, `422 POLIZA_ACTIVA` (al cotizar: ya tiene póliza vigente; el mensaje trae la fecha de renovación), `403` no habilitado, `503 SEGUROS_NO_DISPONIBLE`. Los seguros **no se anulan** por la API (`REVERSAL_NOT_SUPPORTED`) y `POST /v1/consultas` con un servicio de seguro responde `501`. ### `POST /v1/transacciones/{id}/anular` Requiere `Idempotency-Key`. Devuelve el importe al saldo. Solo funciona si la transacción está `SUCCESS` y el servicio tiene `acepta_anulacion: true`. ### `GET /v1/transacciones` Filtros: `page`, `per_page` (máx. 200), `status`, `date_from`, `date_to`, `identifier`. **Filtrar por `identifier` es la mejor forma de localizar una operación tras un timeout.** ### `GET /v1/transacciones/{id}` y `GET /v1/transacciones/{id}/recibo` El recibo solo está disponible para transacciones `SUCCESS`. --- ## 5. ESTADOS | `status` | Significado | El saldo | |---|---|---| | `SUCCESS` | Entregada y cobrada | Cobrado | | `FAILED` | Rechazada por el proveedor | Devuelto | | `PENDING` | **Resultado desconocido** | **Retenido** | | `PROCESSING` | En curso | Retenido | | `REVERSED` | Anulada tras completarse | Devuelto | --- ## 6. CÓDIGOS DE ERROR Y QUÉ HACER | Código | HTTP | Qué hacer | |---|---|---| | `UNAUTHENTICATED` | 401 | Revisa el header. No reintentes en bucle | | `FORBIDDEN` | 403 | Token sin permiso, empresa suspendida, o enviaste `empresa_id` | | `NOT_FOUND` | 404 | Recurso inexistente o de otra empresa | | `VALIDATION_ERROR` | 422 | Revisa `details`. No reintentes sin corregir | | `INSUFFICIENT_BALANCE` | 422 | Deposita saldo | | `LIMIT_EXCEEDED` | 422 | Límite del período superado | | `IDEMPOTENT_REQUEST_IN_PROGRESS` | 409 | Espera 2 s y reintenta **con la misma clave** | | `IDEMPOTENCY_CONFLICT` | 409 | Consulta el historial antes de usar otra clave | | `RATE_LIMIT_EXCEEDED` | 429 | Espera exponencial, conservando la clave | | `TRANSACTION_PENDING` | 202 | **Consulta el estado. No reintentes** | | `INVALID_PHONE` | 422 | Número inválido. Saldo devuelto | | `INVALID_PRODUCT` | 422 | Refresca el catálogo | | `INVALID_AMOUNT` | 422 | Respeta el rango del servicio | | `DUPLICATE_TRANSACTION` | **409** | Consulta el historial antes de reintentar | | `TRANSACTION_REJECTED` | 422 | Rechazo genérico. Saldo devuelto | | `SERVICE_TIMEOUT` | **202** | Resultado desconocido. Consulta el estado; NO reintentes | | `SERVICE_UNAVAILABLE` | **503** | Reintenta con espera. Misma clave | | `SERVICE_TEMPORARILY_UNAVAILABLE` | 503 | El proveedor no puede atender ahora. Misma clave | | `REVERSAL_NOT_SUPPORTED` | 422 | Ese servicio no admite anulación (los seguros nunca) | | `SEGURO_NO_EMITIDO` | 422 | Póliza no emitida. Saldo devuelto. Empezar el seguro desde `POST /v1/seguros` | | `COTIZACION_VENCIDA` | 409 | Cotización de seguro con más de 30 min. Volver a cotizar | | `SEGURO_YA_VENDIDO` | 409 | Ese seguro ya se vendió. Consultar `GET /v1/seguros/{id}` | | `POLIZA_ACTIVA` | 422 | El vehículo ya tiene póliza vigente | | `OTP_INCORRECTO` / `OTP_BLOQUEADO` | 422 / 409 | Código SMS equivocado / 5 intentos: empezar de nuevo | | `SEGUROS_NO_DISPONIBLE` | 503 | Venta de seguros no disponible por el momento | | `REVERSAL_REJECTED` | 422 | El proveedor rechazó la anulación | | `ALREADY_REVERSED` | 409 | Ya estaba anulada | | `NOT_REVERSIBLE` | 409 | No está completada: no se puede anular | | `INTERNAL_ERROR` | 500 | Consulta el estado y reporta el `request_id` | --- ## 7. AMBIENTE DE PRUEBAS Las cuentas con `ambiente: development` no llegan al proveedor real. **El último dígito del `identifier` decide el escenario**, de forma determinista: | Termina en | Consulta | Compra | |---|---|---| | `0` | contrato/número no existe | `422 INVALID_PHONE`, saldo devuelto | | `1` | sin opciones (o contrato al día) | `422 TRANSACTION_REJECTED`, saldo devuelto | | `2` | el proveedor no responde | `202 TRANSACTION_PENDING`, **saldo retenido** | | otro | resultado normal | `201 SUCCESS`, saldo cobrado | **Prueba el escenario `2` antes de salir a producción.** Es el que rompe la mayoría de las integraciones. Las respuestas del sandbox tienen **exactamente el mismo formato** que las de producción: `auth_code` numérico, `reference` con la misma forma, token de electricidad en cinco grupos de cuatro dígitos. Lo único que cambia es el campo `environment`. El sandbox además **tarda lo que tarda producción** (mediana ~1.2 s, cola hasta 12 s), para que dimensiones bien tus timeouts. El header `X-Sandbox-Fast: true` omite la espera en tus pruebas automatizadas. --- ## 8. LÍMITES - **Rate limit:** 120 peticiones/minuto por token. Headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. - **Límites de consumo:** diario, semanal y mensual por empresa, visibles en `/v1/saldo`. El valor `0` significa sin límite. - **Timeout recomendado en tu cliente:** 40 s de lectura, 5 s de conexión. --- ## 9. CATÁLOGO COMPLETO DE SERVICIOS 59 servicios activos. El `id` de esta tabla es el `service_id` que debes enviar. ### flujo `monto_libre` — 44 servicios | id | nombre | categoría | mínimo | máximo | anulable | |---|---|---|---|---|---| | 23 | ASDN | Agua | — | — | no | | 33 | ACTIVA PREPAGO | Electricidad | RD$50 | RD$1500 | no | | 29 | CEB | Electricidad | — | — | sí | | 31 | CEB PREPAGO | Electricidad | RD$50 | RD$1500 | no | | 27 | CEPM | Electricidad | — | — | sí | | 30 | CEPM PREPAGO | Electricidad | RD$50 | RD$1500 | no | | 54 | EDENORTE PREPAGO | Electricidad | RD$50 | RD$5000 | no | | 22 | EDESUR PREPAGO | Electricidad | RD$50 | RD$1500 | no | | 32 | BETCRIS | Entretenimiento | RD$100 | RD$5000 | no | | 56 | BETCRIS RETIRO | Entretenimiento | — | — | no | | 20 | BILLETES LOTERIA NAC | Entretenimiento | RD$50 | RD$2000 | no | | 21 | JUANCITO SPORT | Entretenimiento | RD$100 | RD$5000 | sí | | 26 | PASO RAPIDO | Entretenimiento | RD$100 | RD$1500 | sí | | 16 | STARCABLE | Internet & TV | — | — | no | | 18 | WIND | Internet & TV | — | — | no | | 46 | ADN | Otros Servicios | — | — | sí | | 25 | ASTER | Otros Servicios | — | — | no | | 40 | AVON | Otros Servicios | — | — | sí | | 41 | BELCORP | Otros Servicios | — | — | sí | | 58 | BILLETES RETIRO | Otros Servicios | — | — | no | | 24 | COAAROM | Otros Servicios | — | — | no | | 47 | DGII | Otros Servicios | — | — | sí | | 38 | ECOLLECT | Otros Servicios | — | — | sí | | 49 | INFOTEP | Otros Servicios | — | — | sí | | 59 | KRECE | Otros Servicios | — | — | sí | | 28 | LUZ Y FUERZA | Otros Servicios | — | — | sí | | 57 | PAYCASH | Otros Servicios | — | — | no | | 17 | RECARGAS INTERNACIONALES | Otros Servicios | — | — | no | | 55 | REMESAS ENVIO | Otros Servicios | — | — | no | | 48 | TSS | Otros Servicios | — | — | sí | | 42 | UAPA | Otros Servicios | — | — | sí | | 44 | UNAPEC | Otros Servicios | — | — | sí | | 45 | UNIBE | Otros Servicios | — | — | sí | | 43 | UTEL | Otros Servicios | — | — | sí | | 37 | CREDIGO | Servicios Financieros | — | — | sí | | 36 | LAPRIMERAAPP | Servicios Financieros | RD$30 | RD$1500 | no | | 35 | MONEYSHOP | Servicios Financieros | — | — | no | | 34 | SOLUCREDITO | Servicios Financieros | — | — | no | | 39 | PGR MULTAS | Servicios Gubernamentales | — | — | sí | | 2 | ALTICE | Telefonía | RD$30 | RD$2000 | sí | | 1 | CLARO | Telefonía | RD$30 | RD$2000 | sí | | 4 | DIGICEL | Telefonía | RD$50 | RD$2000 | no | | 5 | NATCOM | Telefonía | RD$50 | RD$2000 | no | | 3 | VIVA | Telefonía | RD$25 | RD$2000 | no | ### flujo `paquetes` — 5 servicios | id | nombre | categoría | mínimo | máximo | anulable | |---|---|---|---|---|---| | 19 | WIND PAQUETES | Internet & TV | — | — | no | | 51 | ALTICE Paquetes | Paquetes de Datos | — | — | no | | 50 | Claro Paquetes | Paquetes de Datos | — | — | no | | 53 | Digicel Paquetes | Paquetes de Datos | — | — | no | | 52 | Viva Paquetes | Paquetes de Datos | — | — | no | ### flujo `facturas` — 9 servicios | id | nombre | categoría | mínimo | máximo | anulable | |---|---|---|---|---|---| | 13 | CAASD | Agua | — | — | sí | | 14 | CORAAPLATA | Agua | — | — | sí | | 11 | CORAASAN | Agua | — | — | no | | 15 | CORAAVEGA | Agua | — | — | sí | | 10 | EDEESTE | Electricidad | — | — | no | | 8 | EDENORTE | Electricidad | — | — | sí | | 9 | EDESUR | Electricidad | — | — | sí | | 7 | ALTICE POSTPAGO | Telefonía | — | — | no | | 6 | CLARO POSTPAGO | Telefonía | — | — | sí | ### flujo `seguros` — 1 servicios | id | nombre | categoría | mínimo | máximo | anulable | |---|---|---|---|---|---| | 12 | SEGURO | Seguros | — | — | no | Se vende como «Seguro Dominicana» con los pasos de `/v1/seguros` (§4). Confirma el `service_id` con `GET /v1/seguros`: solo aparece si el comercio lo tiene habilitado. --- ## 10. IMPLEMENTACIÓN COMPLETA Las tres implementaciones hacen lo mismo: recorren el flujo de dos pasos, manejan los cuatro desenlaces y persisten la clave de idempotencia antes de llamar. ### PHP ```php int, 'body' => array]. Nunca lanza por respuesta del servidor. */ private function llamar(string $metodo, string $ruta, ?array $cuerpo = null, ?string $idem = null): array { $h = ['Authorization: Bearer ' . $this->key . '.' . $this->secret]; if ($cuerpo !== null) { $h[] = 'Content-Type: application/json'; } if ($idem !== null) { $h[] = 'Idempotency-Key: ' . $idem; } $ch = curl_init($this->base . $ruta); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $metodo, CURLOPT_HTTPHEADER => $h, CURLOPT_POSTFIELDS => $cuerpo !== null ? json_encode($cuerpo) : null, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 40, ]); $raw = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); $errno = curl_errno($ch); curl_close($ch); // Timeout: NO es un fallo, es un resultado desconocido. if ($errno === CURLE_OPERATION_TIMEDOUT) { return ['status' => 0, 'body' => ['error' => ['code' => 'CLIENT_TIMEOUT']]]; } return ['status' => $status, 'body' => json_decode((string) $raw, true) ?? []]; } public function servicios(): array { return $this->llamar('GET', '/v1/servicios'); } public function saldo(): array { return $this->llamar('GET', '/v1/saldo'); } public function consultar(int $servicioId, string $identificador): array { return $this->llamar('POST', '/v1/consultas', ['service_id' => $servicioId, 'identifier' => $identificador]); } public function comprar(array $cuerpo, string $idem): array { return $this->llamar('POST', '/v1/recargas', $cuerpo, $idem); } public function transaccion(int $id): array { return $this->llamar('GET', '/v1/transacciones/' . $id); } } // ─────────────────────────── uso ─────────────────────────── $api = new RecargaDo(getenv('RECARGA_KEY'), getenv('RECARGA_SECRET')); // 1. Averiguar cómo se compra este servicio $servicios = $api->servicios()['body']['data']; $servicio = null; foreach ($servicios as $s) { if ($s['id'] === $servicioId) { $servicio = $s; break; } } $cuerpo = ['service_id' => $servicio['id'], 'identifier' => $numero]; if ($servicio['requiere_consulta']) { // 2a. Paquetes o facturas: consultar y dejar que el cajero elija $c = $api->consultar($servicio['id'], $numero); if (($c['body']['success'] ?? false) !== true) { return mostrarError($c['body']['error']['message']); } $opciones = $c['body']['data']['opciones']; if ($opciones === []) { return mostrarAviso($c['body']['data']['mensaje']); // p. ej. contrato al día } $elegida = elegirEnPantalla($opciones); // su interfaz $cuerpo['opcion_id'] = $elegida['opcion_id']; } else { // 2b. Monto libre $cuerpo['amount'] = $monto; } // 3. LA CLAVE SE GENERA UNA VEZ Y SE PERSISTE ANTES DE LLAMAR if (empty($orden->idem_key)) { $orden->idem_key = bin2hex(random_bytes(16)); $orden->save(); } // 4. Comprar, con reintentos que CONSERVAN la clave for ($intento = 1; $intento <= 3; $intento++) { $r = $api->comprar($cuerpo, $orden->idem_key); if ($r['status'] === 201) { $d = $r['body']['data']; $orden->cobrada( $d['transaction_id'], $d['xid'], $d['reference'], // ← la del proveedor: la única forma de anular $d['auth_code'] ); if (!empty($d['details']['token_recarga'])) { $orden->guardarToken($d['details']['token_recarga']); // imprimir en el recibo } return exito($d); } if ($r['status'] === 409) { sleep(2); continue; } // la original sigue en curso if ($r['status'] === 202 || $r['status'] === 0 || $r['status'] >= 500) { // Desconocido. NO reintentar con clave nueva: verificar. $orden->verificando($r['body']['error']['details']['transaction_id'] ?? null); return verificarMasTarde(); } if ($r['status'] === 422) { // rechazo definitivo $orden->fallida($r['body']['error']['code']); return fallo($r['body']['error']['message']); } return fallo($r['body']['error']['message'] ?? 'Error inesperado'); // 4xx } ``` ### Node.js ```js import { randomUUID } from 'node:crypto'; const BASE = 'https://api.recarga.do'; const AUTH = `Bearer ${process.env.RECARGA_KEY}.${process.env.RECARGA_SECRET}`; async function llamar(metodo, ruta, cuerpo = null, idem = null) { const headers = { Authorization: AUTH }; if (cuerpo) headers['Content-Type'] = 'application/json'; if (idem) headers['Idempotency-Key'] = idem; try { const res = await fetch(BASE + ruta, { method: metodo, headers, body: cuerpo ? JSON.stringify(cuerpo) : undefined, signal: AbortSignal.timeout(40_000), }); return { status: res.status, body: await res.json() }; } catch (e) { // Timeout o red caída: resultado DESCONOCIDO, no fallo. return { status: 0, body: { error: { code: 'CLIENT_TIMEOUT' } } }; } } export async function vender(orden) { const { data: servicios } = (await llamar('GET', '/v1/servicios')).body; const servicio = servicios.find(s => s.id === orden.serviceId); const cuerpo = { service_id: servicio.id, identifier: orden.identifier }; if (servicio.requiere_consulta) { const c = await llamar('POST', '/v1/consultas', { service_id: servicio.id, identifier: orden.identifier }); if (!c.body.success) throw new Error(c.body.error.message); if (!c.body.data.opciones.length) return { aviso: c.body.data.mensaje }; cuerpo.opcion_id = (await elegirEnPantalla(c.body.data.opciones)).opcion_id; } else { cuerpo.amount = orden.amount; } // La clave se genera UNA vez y se persiste antes de llamar. orden.idemKey ??= randomUUID(); await orden.save(); for (let intento = 1; intento <= 3; intento++) { const r = await llamar('POST', '/v1/recargas', cuerpo, orden.idemKey); if (r.status === 201) { const d = r.body.data; await orden.cobrada(d.transaction_id, d.xid, d.reference, d.auth_code); if (d.details?.token_recarga) await orden.guardarToken(d.details.token_recarga); return d; } if (r.status === 409) { await new Promise(r => setTimeout(r, 2000)); continue; } if (r.status === 202 || r.status === 0 || r.status >= 500) { await orden.verificando(r.body.error?.details?.transaction_id ?? null); return { verificar: true }; } if (r.status === 422) { await orden.fallida(r.body.error.code); return { fallo: r.body.error }; } throw new Error(r.body.error?.message ?? 'Error inesperado'); } } ``` ### Python ```python import os, uuid, requests BASE = "https://api.recarga.do" AUTH = f"Bearer {os.environ['RECARGA_KEY']}.{os.environ['RECARGA_SECRET']}" def llamar(metodo, ruta, cuerpo=None, idem=None): headers = {"Authorization": AUTH} if cuerpo is not None: headers["Content-Type"] = "application/json" if idem is not None: headers["Idempotency-Key"] = idem try: r = requests.request(metodo, BASE + ruta, headers=headers, json=cuerpo, timeout=(5, 40)) return r.status_code, r.json() except requests.Timeout: # Resultado DESCONOCIDO, no fallo. return 0, {"error": {"code": "CLIENT_TIMEOUT"}} def vender(orden): servicios = llamar("GET", "/v1/servicios")[1]["data"] servicio = next(s for s in servicios if s["id"] == orden.service_id) cuerpo = {"service_id": servicio["id"], "identifier": orden.identifier} if servicio["requiere_consulta"]: _, c = llamar("POST", "/v1/consultas", {"service_id": servicio["id"], "identifier": orden.identifier}) if not c.get("success"): raise RuntimeError(c["error"]["message"]) if not c["data"]["opciones"]: return {"aviso": c["data"]["mensaje"]} cuerpo["opcion_id"] = elegir_en_pantalla(c["data"]["opciones"])["opcion_id"] else: cuerpo["amount"] = orden.amount # La clave se genera UNA vez y se persiste antes de llamar. if not orden.idem_key: orden.idem_key = str(uuid.uuid4()) orden.save() for _ in range(3): status, body = llamar("POST", "/v1/recargas", cuerpo, orden.idem_key) if status == 201: d = body["data"] orden.cobrada(d["transaction_id"], d["xid"], d["reference"], d["auth_code"]) if d.get("details", {}).get("token_recarga"): orden.guardar_token(d["details"]["token_recarga"]) return d if status == 409: time.sleep(2) continue if status == 202 or status == 0 or status >= 500: orden.verificando((body.get("error", {}).get("details") or {}).get("transaction_id")) return {"verificar": True} if status == 422: orden.fallida(body["error"]["code"]) return {"fallo": body["error"]} raise RuntimeError(body.get("error", {}).get("message", "Error inesperado")) ``` --- ## 11. ERRORES QUE COMETEN LOS ASISTENTES Verificados sobre integraciones reales. Evítalos explícitamente. | Error | Consecuencia | Correcto | |---|---|---| | Generar la clave dentro del bucle de reintentos | Cobros duplicados | Generarla una vez y persistirla antes | | Tratar `202` como fallo | Se marca fallida una recarga entregada | Consultar el estado | | Tratar un timeout como fallo | Igual que el anterior | Reintentar con la misma clave | | Enviar `amount` en paquetes o facturas | `422` | Consultar y enviar `opcion_id` | | Tratar el `202` de un seguro como fallo y empezar otra venta | Se intenta emitir dos pólizas al mismo vehículo | Consultar `GET /v1/transacciones/{id}` hasta el desenlace | | Enviar `empresa_id` | `403` | No enviarlo nunca | | Cachear el resultado de `/v1/consultas` | Se vende un paquete que ya no existe | Consultar en cada venta | | Descartar `transaction_id` | No se puede anular ni consultar la operación | Guardarlo siempre | | Descartar `details.token_recarga` | El cliente no puede cargar su medidor | Imprimirlo en el recibo | | Parsear `message` para decidir | Se rompe al cambiar el texto | Usar `error.code` | | Poner el secreto en el frontend | Cualquiera gasta el saldo | Llamar desde el servidor | --- ## 12. LISTA DE COMPROBACIÓN ANTES DE PRODUCCIÓN - [ ] La `Idempotency-Key` se genera una vez y se persiste antes de la primera llamada - [ ] Los reintentos conservan la clave - [ ] `202` se muestra como «verificando», nunca como «fallida» - [ ] Un timeout de red dispara reintento con la misma clave, no una venta nueva - [ ] Se consulta `flujo` antes de armar el cuerpo de la compra - [ ] Se guardan `transaction_id`, `xid`, `reference`, `auth_code` y `request_id` - [ ] El token de electricidad se imprime en el recibo - [ ] Probado el escenario `2` (pendiente) del sandbox - [ ] Probado el escenario `0` y `1` (rechazos) - [ ] Probada la repetición: misma clave dos veces devuelve el mismo `transaction_id` - [ ] El `api_secret` no aparece en el código cliente ni en el repositorio - [ ] Timeouts del cliente: 5 s conexión, 40 s lectura - [ ] Se comprueba `sandbox` en `/v1/cuenta` y se muestra en la interfaz - [ ] Conciliación diaria contra `GET /v1/transacciones` --- ## 13. RECURSOS | Recurso | URL | |---|---| | Este documento | https://doc.recarga.do/ia.md | | Especificación OpenAPI 3.0 | https://doc.recarga.do/openapi.yaml | | Colección de Postman | https://doc.recarga.do/recarga-do.postman_collection.json | | Manual de integración | https://doc.recarga.do/integracion.html | | Consola interactiva | https://doc.recarga.do/integracion.html#consola | | Estado del servicio | https://api.recarga.do/v1/estado | Al reportar una incidencia, incluye siempre el `request_id` y, si aplica, el `transaction_id`. Con eso se localiza la operación completa —incluida la conversación con el proveedor— en segundos.