# ROKI Connect - Canonical Integration Guide




> ante cualquier duda, los nombres de campo, los endpoints y los ejemplos de codigo son los que

> manda el API y no se traducen.



## 1. Qué es ROKI Connect

Le permite al servidor de un comercio aceptar pagos con tarjeta de **tres maneras**. En todas ellas, el
comercio nunca recibe, transmite ni almacena el número de tarjeta: el ingreso de la tarjeta siempre
ocurre en una superficie controlada por ROKI.

| Modo | Qué es | Ingreso de tarjeta | Qué necesita el frontend | Qué necesita el backend |
|---|---|---|---|---|
| **1 - Checkout alojado** | Redirigís al cliente a una página de ROKI | Página de ROKI | nada | `sk_*` crea el pago |
| **2 - Componentes embebidos** | Campos de tarjeta de ROKI en un iframe dentro de tu propio sitio | iframe de ROKI | `pk_*` | `sk_*` confirma con el monto |
| **3A - Pagos tokenizados** | Cobrarle a una tarjeta que el cliente ya guardó | ninguno | nada | `sk_*` le cobra a un `pm_*` opaco |

El modo 3B todavía no está disponible.

**Empezá con el modo 1.** Es el más simple, no necesita trabajo de frontend, y todos los demás modos
reutilizan su objeto de pago, sus webhooks y sus endpoints de anulación/reembolso/recibo. Pasá al modo
2 cuando el comercio quiera que el paso del pago se vea como su propio sitio, y al modo 3A cuando
necesités cobrarle a un cliente que vuelve sin volver a pedirle la tarjeta.

En los modos 2 y 3A el navegador del cliente habla **únicamente con tu propio backend**. Tu backend es el único
lugar que guarda la `sk_*` y que llama a ROKI directamente. Un botón de "pagar con tarjeta guardada" tiene que
llamar a tu servidor, nunca a un endpoint de ROKI de llave secreta, y la `sk_*` nunca debe aparecer en código
del navegador.

La consecuencia práctica es que el comercio queda fuera del alcance de las obligaciones de PCI-DSS que
vienen con el manejo de datos de tarjeta. Es el mismo modelo que Stripe Checkout o los enlaces de pago de
otros procesadores.

**Mercado:** Honduras. Moneda usual: lempira hondureño (HNL, código numérico ISO `340`).

## 2. Arquitectura y flujo

```
1. Customer confirms an order on the merchant's site
2. Merchant server  --POST /payments-->  ROKI            (with the secret key)
3. ROKI returns a payment in "pending" state with a checkout_url
4. Merchant stores the payment id locally and redirects the customer to checkout_url
5. Customer pays on ROKI's page (this is where card details are entered)
6. ROKI --signed webhook--> merchant server              (authoritative confirmation)
7. Merchant marks the order as paid
8. Customer is redirected to success_url                 (NOT payment confirmation, see 9.3)
```

Los pasos 6 y 8 son independientes y pueden llegar en cualquier orden - o el paso 8 puede que nunca
ocurra si el cliente cierra el navegador. **La verdad sobre el cobro vive en el paso 6 (o en una
consulta directa), nunca en el paso 8.**

## 3. URL base y versionado

```
https://aura.roki.systems/api/connect/v1
```

La API está versionada bajo `/v1`. **La base sin versión fue eliminada** y devuelve un 404 de ruteo;
cualquier integración escrita antes de agosto de 2026 que apunte ahí está rota.

No hay endpoint de descubrimiento, de salud ni de especificación publicada en la API.

## 4. Autenticación

Un solo encabezado, en cada petición:

```http
Authorization: Bearer sk_live_xxxxxxxxxxxx
Accept: application/json
Content-Type: application/json      <- POST only
```

- `sk_test_...` opera en **sandbox**; `sk_live_...` en **producción**. Las rutas son idénticas:
  el entorno lo determina únicamente el prefijo de la llave.
- La llave secreta es **solo del lado del servidor**. Nunca en un navegador, una app móvil, un repositorio ni un log.
- En el portal también existe una **llave publicable** (`pk_...`). El flujo de enlace de pago no la usa
  hoy; está pensada para futuras integraciones del lado del navegador.

**Errores de autenticación** (dos mensajes distintos, útiles para diagnosticar):

| Situación | HTTP | `message` |
|---|---|---|
| Falta el encabezado | 401 | `Encabezado Authorization ausente o invalido. Use: Bearer sk_test_... o Bearer sk_live_...` |
| Llave inválida o revocada | 401 | `Clave API invalida.` |

## 5. Credenciales: cómo obtenerlas y guardarlas

**De dónde salen** (un paso manual del comercio - un agente no puede hacerlo):
`https://aura.roki.systems/merchant/connect/api-integration` -> elegí el entorno con el interruptor
**Sandbox (prueba) / En vivo (producción)** -> copia la llave secreta.

**El portal muestra la llave secreta completa una sola vez** (en la configuración inicial, o después de
**Regenerar**). Después queda enmascarada. Si no la guardaste, la única salida es regenerarla - y la llave
anterior deja de funcionar **de inmediato**, rompiendo cualquier integración que la use.

**Dónde guardarlas en tu proyecto:** en una **tabla de configuración que se lea en tiempo de ejecución**
(por ejemplo `integration_settings` con `roki_secret_key`, `roki_webhook_secret`, `roki_environment`),
cifrada en reposo si el proyecto ya cifra secretos. Así, rotar una llave significa actualizar un registro -
idealmente desde el panel de administración - sin cambios de código ni redespliegue. Las variables de
entorno son el plan B cuando el proyecto no tiene tabla de configuración.

Nunca: en el repositorio, en código del navegador, ni cacheada en constantes de código.

## 6. Idioma de la respuesta

Encabezado opcional `Accept-Language: es` o `en`. El español es el valor por defecto.

```http
Accept-Language: en
```

Afecta los mensajes de la API y los errores de validacion:
`"El campo amount es obligatorio."` pasa a ser `"The amount field is required."`

**No** afecta los errores de ruteo (404 ruta no encontrada, 405), que siempre llegan en ingles
porque el framework los produce antes de la capa de aplicacion.

## 7. Crear un pago

```http
POST /api/connect/v1/payments
```

### 7.1 Campos obligatorios

| Campo | Tipo | Regla |
|---|---|---|
| `amount` | number | Mínimo 0.01. **Unidades decimales, no centavos** (150.50 = L 150.50). La API no impone un máximo. |
| `external_reference` | string | El id de tu orden. Máx 191. **No es único** (ver 11). |
| `name` | string | Etiqueta que el cliente ve en el checkout. Máx 191. |

### 7.2 Campos opcionales

| Campo | Tipo | Notas |
|---|---|---|
| `currency_code` | string | ISO 4217 **numérico** (`"340"` = HNL). Por defecto usa la moneda de la terminal. Solo se aceptan las monedas habilitadas en esa terminal. |
| `description` | string | Máx 120. |
| `metadata` | object | Tus propios pares clave/valor. Tiene que ser un objeto: un string devuelve 422. Ver 10. |
| `success_url` / `cancel_url` | string | URLs de retorno. Usá HTTPS (ver 12.4). |
| `expires_at` | string | Hora de Honduras (UTC-6), tiene que estar en el futuro. Acepta `YYYY-MM-DD HH:MM:SS` e ISO 8601. |
| `sales_tax_type` | enum | `none` (por defecto), `fixed`, `percentage`. |
| `sales_tax_value` | number | Obligatorio cuando el tipo no es `none`. Como porcentaje, máx 100. |
| `tip_enabled` | boolean | Ver 8.2. |
| `service_fee_enabled` | boolean | Ver 8.3. |
| `reusable` | boolean | Ver 8.4. |
| `customer` | object | Prellena el checkout: `name`, `email`, `phone`, `identity_number`. Ver 7.5. |
| `lock_customer_fields` | boolean | Deja los campos prellenados de solo lectura. Ver 7.5. |

### 7.3 Ejemplo mínimo

```json
{
  "amount": 150.00,
  "external_reference": "order-1001",
  "name": "Order #1001"
}
```

### 7.4 Respuesta (201)

```json
{
  "id": 706,
  "status": "pending",
  "name": "Order #1001",
  "description": null,
  "amount": 150,
  "reusable": false,
  "subtotal": 150,
  "sales_tax_amount": 0,
  "service_fee_amount": 0,
  "total": 150,
  "currency": "340",
  "currency_iso": "HNL",
  "external_reference": "order-1001",
  "metadata": {},
  "checkout_url": "https://aura.roki.systems/pay/link/example1a2b3c",
  "transaction_id": null,
  "expires_at": "2026-08-09 18:00:00",
  "created_at": "2026-08-08 14:30:07",
  "paid_at": null
}
```

**Guardá el `id` en tu base de datos** - es la única forma de volver a consultar el pago (no hay
búsqueda por `external_reference`). Después **redirigí al cliente a `checkout_url`**.

Dos notas sobre esa respuesta. `transaction_id` es `null` hasta que se cobra el pago, y es un
**string UUID** - cambió de tipo, en una versión anterior era un entero, y es el identificador que
exigen la anulación, el reembolso y los recibos. Y el slug de `checkout_url` es una cadena de 12
caracteres en minusculas: no lleva ningun prefijo, asi que la API de
producción no usa, así que nunca hagas coincidencia de patrones sobre un slug - redirigí a la URL
exacta que te dieron.

### 7.5 Prellenar los datos del cliente

El objeto opcional `customer` prellena el checkout alojado con los datos de un cliente conocido.
Cada campo dentro de él es opcional e independiente:

```json
{
  "customer": {
    "name": "Ahmed Khan",
    "email": "ahmed@example.com",
    "phone": "+50499999999",
    "identity_number": "0801199000000"
  },
  "lock_customer_fields": true
}
```

Por defecto los campos prellenados siguen siendo editables. Con `lock_customer_fields: true`, cada
campo que envíes con un valor no vacío queda de solo lectura en el checkout; los campos que omitas
quedan en blanco y editables incluso entonces.

Vale la pena enviar `identity_number`: también es el identificador preferido para buscar más
adelante las tarjetas guardadas de ese cliente (ver sección 23).

Ambos campos **se reflejan de vuelta en la respuesta**, lo cual importa más de lo que suena: como
esta API ignora silenciosamente los campos desconocidos, el objeto `customer` reflejado es tu única
forma de confirmar desde la respuesta que el prellenado sí se aplicó. Revisalo en lugar de asumir
que el `201` significa que funcionó.

```json
"customer": { "name": "Ahmed Khan", "email": "ahmed@example.com",
              "phone": "+50499999999", "identity_number": "0801199000000" },
"lock_customer_fields": true
```

## 8. Cálculos: impuesto, propina y traslado de la comisión al cliente

### 8.1 Impuesto sobre ventas

Se calcula solo sobre el subtotal.

```json
{ "amount": 100, "sales_tax_type": "percentage", "sales_tax_value": 15 }
-> subtotal 100, sales_tax_amount 15, total 115
```

`fixed` suma un monto en lugar de un porcentaje. Un porcentaje no puede pasar de 100.

### 8.2 Propina

`tip_enabled: true` **obliga a elegir entre dos modos**; omitir ambos devuelve 422
(`"Seleccione un tipo de propina o permita la seleccion del cliente en el checkout."`).

**Propina fija** - la define el comercio y queda reflejada en el total desde la creación:
```json
{ "amount": 100, "tip_enabled": true, "tip_type": "percentage", "tip_value": 10 }
-> total 110
```

**Elegida por el cliente** - el total en la creación **no** incluye propina; el cliente la agrega en
el checkout:
```json
{
  "amount": 100, "tip_enabled": true,
  "tip_customer_selectable": true,
  "tip_preset_percentages": [10, 15, 20],
  "tip_allow_custom": true,
  "tip_min_amount": 0, "tip_max_amount": 50
}
-> total 100 at creation; the customer decides when paying
```

`tip_max_amount` **no puede pasar de `amount`** - si lo hace, devuelve 422.

### 8.3 Trasladar los costos al cliente (`service_fee_enabled`)

Cuando está activo, la comisión de ROKI, el cargo de 3-D Secure y el impuesto sobre esa comisión
**se le trasladan al cliente**. ROKI recalcula el total con un **cálculo inverso** para que al
comercio le quede neto el `amount` que pediste.

```json
{ "amount": 100, "service_fee_enabled": true }
-> subtotal 100, service_fee_amount 5.52, total 105.52
```

**Nunca repliques este cálculo del lado del integrador.** Las tasas, el cargo fijo y el umbral son
configuración por comercio y cambian. **Lee `service_fee_amount` y `total` de la respuesta** - esa es
la única fuente correcta. La comisión se calcula sobre la base completa (monto más impuesto más
propina fija).

**El nombre del campo es exactamente `service_fee_enabled`.** Las variantes `service_fee`,
`service_fee_type`/`service_fee_value` y `pass_fees_to_customer` **se ignoran en silencio**: la API
devuelve 201 y el pago se crea **sin** traslado de la comisión al cliente. Ver 12.1.

### 8.4 Enlaces reutilizables (`reusable`)

Por defecto un enlace **se consume al pagarse**: después muestra "Enlace no disponible". Con
`reusable: true` el enlace acepta más de un cobro - útil para enlaces fijos que se comparten en redes
sociales.

## 9. Consultar un pago y su ciclo de vida

```http
GET /api/connect/v1/payments/{id}
```

Devuelve el mismo objeto que la creación, con el estado actual. Usa la llave del entorno en el que se
creó el pago.

### 9.1 Estados

| Estado | Significado |
|---|---|
| `pending` | Creado, todavía sin pagar. |
| `paid` | Cobrado con éxito. Aparecen `transaction_id` y `paid_at`. |
| `partially_refunded` | Reembolsado parcialmente. |
| `refunded` | Reembolsado por completo. |
| `voided` | Anulado antes de la liquidación; los fondos quedan liberados. |
| `expired` | El enlace venció sin pagarse. |
| `disabled` | Deshabilitado. |

Los pagos con historial de reembolsos también traen `refunded_amount` y `refund_status`
(`none` / `partial` / `full`).

### 9.2 Listar pagos

`GET /payments` devuelve los pagos del comercio, del más nuevo al más viejo, paginados y acotados al
comercio y al entorno de la llave - una llave de sandbox nunca ve pagos de producción.

```bash
curl "https://aura.roki.systems/api/connect/v1/payments?status=paid&per_page=50" \
  -H "Authorization: Bearer sk_test_..." -H "Accept: application/json"
```

```json
{
  "data": [ { "id": 938, "status": "pending", "...": "the full payment object" } ],
  "meta": { "current_page": 1, "per_page": 20, "total": 47, "last_page": 3 }
}
```

| Parámetro | Efecto |
|---|---|
| `per_page` | Tamaño de página, 20 por defecto. **`limit` se ignora** - solo funciona `per_page`. |
| `page` | Empieza en 1. Deja de paginar cuando llegues a `meta.last_page`. |
| `status` | Filtra por estado. Un valor desconocido devuelve **422**; no se ignora. |
| `external_reference` | Filtra por tu id de orden. Puede coincidir con varios, porque no es único. |
| `from` / `to` | Rango de fecha de creación, `YYYY-MM-DD`, hora de Honduras. |

`meta.total` cuenta todo lo que coincide con los filtros, no la página - contra eso paginas.

**Aun así, guarda el `id` del pago al crearlo.** El listado hace posible la conciliación, pero
recorrer páginas para encontrar un pago no sustituye tener su id.

### 9.3 Qué confirma un pago y qué no

**Confirma:** el webhook `payment.approved`, y un `GET /payments/{id}` que devuelva `status: "paid"`.

**No confirma:** que el cliente caiga en `success_url`. Ese redireccionamiento lo controla el
navegador del cliente y cualquiera puede entrar a la URL sin pagar. Trátalo como una señal de UX,
nunca como prueba de un cobro.

## 10. Datos del cliente: `metadata`

La API **no tiene campos para datos del cliente** ni forma de precargar el formulario del checkout: el
cliente ingresa sus datos en la página de ROKI.

Para llevar tu propia información, usá `metadata`, que vuelve intacta al consultar el pago y en cada
webhook:

```json
"metadata": { "order_id": "1001", "customer_email": "customer@example.com", "branch": "SPS" }
```

Es un canal entre tu sistema y ROKI: **no se le muestra al cliente**. Usalo para conciliar sin
depender únicamente de tu propia base de datos.

## 11. Idempotencia y duplicados

Opcional pero **siempre recomendado** al crear:

```http
Idempotency-Key: order-1001-create
```

Repetir la misma petición con la misma llave devuelve **el mismo pago**, sin duplicado.

**Dos comportamientos verificados que difieren del estándar de la industria:**

1. **La misma llave con un cuerpo distinto devuelve el pago original, sin error.** (Stripe
   devolvería 422.) La llave manda y el cuerpo se ignora al repetir. Si un bug reutiliza una llave
   para un monto distinto, recibís un 201 que describe un cobro que nadie pidió.
   -> **Derivá la llave del contenido de la orden** (por ejemplo un hash de id, monto y moneda),
   no solo de su identificador.

2. **`external_reference` no es único.** Sin un `Idempotency-Key`, enviarlo dos veces crea dos pagos
   distintos. La llave de idempotencia es la única protección contra duplicados.

## 12. Trampas verificadas

### 12.1 Los campos desconocidos se ignoran en silencio

Un cuerpo con un nombre de campo mal escrito devuelve **201 Created sin ninguna advertencia**, y el
pago se crea sin esa funcionalidad.

```json
{ "amount": 100, "service_fee": true }     <- wrong name
-> 201 Created, service_fee_amount: 0       <- the fee pass-through was NOT applied
```

Este es el modo de falla más peligroso de la API, porque el resultado parece exitoso. Es
especialmente peligroso para código generado por IA, que tiende a escribir con total confianza los
nombres de campo de otras pasarelas (`card_token`, `payment_method`, `customer_id`, `amount_cents`).

**Defensa obligatoria:** después de crear un pago, **verificá en la respuesta** que los campos
calculados (`sales_tax_amount`, `service_fee_amount`, `total`, `reusable`) coincidan con lo que
esperabas. Y validá el cuerpo de tu petición contra el esquema de `openapi.yaml`, que declara
`additionalProperties: false` justamente para atrapar lo que la API deja pasar.

### 12.2 `amount` no tiene tope y hace coerción de tipos

La API acepta montos absurdos (11 dígitos) y strings numéricos (`"150.00"`). Imponé un máximo
razonable antes de enviar.

### 12.3 Dos tipos distintos de 404

| Respuesta | Significado |
|---|---|
| `{"message":"Pago no encontrado."}` | La ruta existe; el pago no. Id equivocado, o un id del otro entorno. |
| `{"message":"The route ... could not be found."}` | **La ruta no existe.** URL mal formada o un endpoint inexistente. |

Distinguirlos ahorra horas: el segundo nunca se arregla cambiando el id.

### 12.4 Los timestamps vienen en hora local de Honduras sin offset

`expires_at`, `created_at` y `paid_at` vuelven como `YYYY-MM-DD HH:MM:SS` en **hora de Honduras
(UTC-6)**, sin marca de zona horaria en el string. Parsearlos como UTC corre cada valor seis horas -
suficiente para que un vencimiento futuro parezca vencido, o para que una orden parezca pagada antes
de haber sido creada. Agregá el offset explícitamente al parsear.

### 12.5 Una propina elegida por el cliente no está en el `total` que te dieron

Con `tip_customer_selectable`, el `total` que se devuelve al crear excluye la propina porque el
cliente todavía no la eligió. Por eso el monto realmente cobrado puede ser mayor que el total que
guardaste. Conciliá contra el payload del webhook o contra una consulta nueva, nunca contra la
respuesta de creación.

### 12.6 `metadata` puede volver como un arreglo vacío

Cuando no se envió metadata, la API puede devolver `"metadata": []` en vez de `{}`. Los
deserializadores estrictos que esperan un objeto van a fallar. Tratá ambas formas como "sin
metadata".

### 12.7 No hardcodees el dominio del checkout que aparece en la documentación oficial

La documentación oficial de ROKI muestra `checkout_url` en `pay.roki.app`; la API en realidad
devuelve `aura.roki.systems/pay/link/{slug}`. Nunca hardcodees ni pongas en lista blanca un dominio
de checkout - siempre redirigí al `checkout_url` exacto que devolvió la API.

### 12.8 `success_url` acepta `http://`

Aunque la documentación oficial exige HTTPS, la API acepta URLs sin cifrar. Usá HTTPS de todas
formas - es una URL de retorno en un flujo de pago.

## 13. Anulación, reembolso y recibos

Estas operaciones actúan sobre una **transacción**, identificada por el **UUID** `transaction_id` de la
respuesta del pago, nunca por el `id` numérico del pago.

Esa distinción importa porque un enlace reutilizable puede llevar varias transacciones: anular o
reembolsar la transacción de un cliente deja las demás intactas.

| Operación | Ruta |
|---|---|
| Anulación | `POST /payments/{transaction_id}/void` |
| Reembolso | `POST /payments/{transaction_id}/refund` |
| Metadatos del recibo | `GET /payments/{transaction_id}/receipt` |
| PDF del recibo | `GET /payments/{transaction_id}/receipt/download` |

### 13.1 Pasar el identificador equivocado parece un endpoint inexistente

La ruta solo coincide con el formato UUID, así que un id numérico de pago produce un **404 de ruteo**:

```
POST /payments/9e2b6a34-6f1d-4e2a-8c9b-2f7a1d4e5c6b/void  ->  the route matched
POST /payments/873/void                                    ->  "The route ... could not be found."
```

Si ves ese mensaje en una anulación, un reembolso o un recibo, pasaste el id del pago donde va el
UUID de la transacción. El endpoint está ahí.

### 13.2 ¿Anulación o reembolso?

| | Anulación | Reembolso |
|---|---|---|
| Cuándo | El mismo día, antes de la liquidación | Después de la liquidación |
| Monto | Solo total | Total o parcial |
| Velocidad | Inmediata | 3-5 días hábiles de vuelta a la tarjeta |
| El cliente ve | Que en realidad nunca se le cobró | Dinero devuelto |
| Webhook | `payment.voided` | `payment.refunded` / `payment.partially_refunded` |
| Caso típico | Error, cancelación inmediata | Devolución, disputa, cobro de más |

La elegibilidad la gobierna la ventana de tiempo configurada de la transacción, no solo el estado de
liquidación. Intentar un reembolso demasiado pronto puede devolver `422` con un mensaje del
procesador como `"Invalid transaction"` - ese texto viene del procesador y no es una respuesta que
ROKI garantice.

Implementá la reversión así: **probá primero la anulación; si se rechaza porque no es anulable o
porque ya está liquidada, reembolsá en su lugar.**

### 13.3 Anulación

```http
POST /api/connect/v1/payments/{transaction_id}/void
{ "reason": "Customer cancelled" }        # optional, max 2000 chars
```

Devuelve el pago con `status: "voided"`. Los rechazos llegan como `422` bajo `errors.void`: ya
anulado, ya reembolsado (usá reembolso), no está en un estado anulable, o ya liquidado.

**Una anulación también llena los campos de reembolso.** Verificado en dos transacciones reales el
2026-08-14: después de anular, el pago lleva

```json
{ "status": "voided", "refunded_amount": 25, "refund_status": "full" }
```

aunque no se reembolsó nada. Así que `refund_status === 'full'` **no** significa "esto fue
reembolsado" - es igual de cierto para una anulación. Ramificá según `status` (`voided` vs
`refunded` vs `partially_refunded`) y tratá los campos de reembolso como el monto devuelto al
tarjetahabiente por cualquier vía, no como evidencia de qué operación se usó. Un reporte que cuente
reembolsos por `refund_status` va a contar anulaciones como reembolsos sin avisar, y las dos son
comercialmente distintas: una anulación nunca llega al estado de cuenta del cliente, un reembolso sí.

El webhook `payment.voided` llegó como un segundo después en ambas transacciones, con los mismos campos.

### 13.4 Reembolso

```http
POST /api/connect/v1/payments/{transaction_id}/refund
{ "amount": 500.00, "reason": "Partial return" }
```

`amount` es obligatorio, mínimo 0.01, hasta el monto reembolsable restante. Un reembolso parcial deja
`partially_refunded`; uno total pone `refunded`. `422` bajo `errors.amount` (debajo del mínimo, arriba
del restante) o `errors.refund` (transacción anulada, no reembolsable).

El reembolso funciona igual para una transacción creada por cualquier modo, incluido un checkout
embebido o un cobro con tarjeta guardada. No hay un endpoint de reembolso aparte para cobros con
token: usá acá como `transaction_id` el `id` que devolvió el cobro.

### 13.5 Recibos

`GET /payments/{transaction_id}/receipt` devuelve `payment_id`, `transaction_id` y un `receipt_url`
público. Agregá `/download` para el PDF. Los recibos sobreviven a una anulación o un reembolso
mientras el cobro original siga teniendo una transacción.

### 13.6 Una nota sobre los identificadores

Dos referencias distintas, y mezclarlas es el error más común:

- El **`id` de pago** numérico es para `GET /payments/{id}`.
- El **UUID `transaction_id`** es para anulación, reembolso y recibos.

Vale la pena guardar los dos cuando se cobra un pago. El listado (9.2) puede recuperar un id perdido,
pero solo si sabés qué estás buscando.

## 14. Webhooks

Son la confirmación autoritativa de un cobro. Sin ellos, una integración depende de que el cliente
vuelva al sitio - cosa que no siempre pasa.

### 14.1 Registro (un paso manual del comercio)

En `https://aura.roki.systems/merchant/connect/webhooks`:

1. Elegí el entorno con el interruptor **Sandbox (prueba) / En vivo (producción)** - **tiene que
   coincidir con la llave `sk_` que usa la integración**. Este es el error más común: registrar el
   endpoint en producción mientras desarrollás con una llave de prueba, y no recibir nada.
2. Pegá la URL pública HTTPS de tu endpoint.
3. **Guardar endpoint.**
4. Copiá el **secreto de firma** que muestra el portal y guardalo en la configuración de tu proyecto.

Se pueden registrar URLs distintas para sandbox y producción, cada una con su propio secreto.

El portal muestra **"Entregas recientes"** (los últimos 10 intentos de entrega) - la herramienta de
diagnóstico cuando los eventos no están llegando.

### 14.2 Eventos

| Evento | Cuándo |
|---|---|
| `payment.approved` | El cliente pagó con éxito. Observado dentro de ~2 segundos del pago. |
| `payment.failed` | Tarjeta declinada o error en el pago. |
| `payment.expired` | El enlace venció antes de que se completara el checkout. **Observado ~5 minutos después de `expires_at`**, no en el instante: el vencimiento se barre por tarea programada. |
| `payment.voided` | Se anuló un pago aprobado. |
| `payment.refunded` | Reembolso total. |
| `payment.partially_refunded` | Reembolso parcial. |
| `payment_method.saved` | El cliente marcó "guardar mi tarjeta"; lleva el `pm_*` opaco (23.2). |

Los últimos tres también llegan cuando la acción se hace desde el portal.

### 14.3 Estructura del evento

Reproducida de una entrega real recibida y verificada el 2026-08-14, no del ejemplo publicado. Dos
cosas en la documentación oficial están mal y se corrigen acá.

**Encabezados**

| Encabezado | Ejemplo | Para qué sirve |
|---|---|---|
| `ROKI-Signature` | `t=1786747980,v1=f90f1c...` | Verificala. Ver 14.4. |
| `ROKI-Webhook-Event-Id` | `9a7a9698-e270-422a-ba8f-365e944248aa` | El id del evento, **también** en el encabezado. Deduplicá con esto sin parsear el cuerpo. |
| `ROKI-Webhook-Event-Type` | `payment.approved` | Enrutá sin parsear el cuerpo. |
| `User-Agent` | `GuzzleHttp/7` | El cliente propio de ROKI. No filtres por eso. |

**Cuerpo**

```json
{
  "id": "9a7a9698-e270-422a-ba8f-365e944248aa",
  "type": "payment.approved",
  "created_at": "2026-08-14 16:52:58",
  "data": {
    "id": 1049,
    "status": "paid",
    "amount": 10,
    "subtotal": 10,
    "total": 10,
    "sales_tax_amount": 0,
    "service_fee_amount": 0,
    "currency": "340",
    "currency_iso": "HNL",
    "external_reference": "demo-hosted-mstjlm2w",
    "name": "Prueba modo 1 - guardar tarjeta",
    "description": null,
    "metadata": [],
    "customer": { "name": null, "email": "demo@roki.la", "phone": null, "identity_number": "0801199000000" },
    "lock_customer_fields": false,
    "reusable": false,
    "checkout_url": "https://aura.roki.systems/pay/link/6ixekbmlwf8e",
    "transaction_id": "91b73300-38b3-48eb-b9d0-72941896b26e",
    "refunded_amount": 0,
    "refund_status": "none",
    "expires_at": "2026-08-14 17:22:35",
    "created_at": "2026-08-14 16:52:36",
    "paid_at": "2026-08-14 16:52:56"
  }
}
```

**Correcciones al ejemplo publicado:**

- El `id` del evento es un **UUID** simple, sin prefijo `evt_`. El código que busca `evt_` no
  encuentra nada. (El mismo tipo de error que el prefijo `plink_` en 9.1.) Confirmado en cinco
  entregas reales, tanto en el cuerpo como en el encabezado `ROKI-Webhook-Event-Id`.
- `data.transaction_id` es la **cadena UUID**, no un número, y es `null` hasta que el pago se cobra.
  La documentación v2 actual de ROKI ya lo muestra bien; era la versión anterior la que mostraba
  `9001`, que es la confusión que hace que la anulación y el reembolso devuelvan un 404 de ruteo
  (13.6). Guardarlo en una columna entera es el mismo error por otro camino - ver los esquemas en
  19.2.
- `data` lleva **el objeto de pago completo**, idéntico en forma a `GET /payments/{id}` - no el
  subconjunto que sugiere el ejemplo. `metadata` llega como `[]` cuando está vacío, no `{}`.

Los eventos de reembolso incluyen además `refund_amount`, `refunded_at` y `refund_reason` dentro de
`data`; `refunded_amount` y `refund_status` están presentes en todo pago pagado.

### 14.4 Verificación de firma (obligatoria)

Encabezado `ROKI-Signature: t={timestamp},v1={hmac_sha256_hex}`.

El HMAC se calcula sobre **`timestamp + "." + raw_body`** usando el secreto de firma:

```
expected = HMAC-SHA256(timestamp + "." + exact_raw_body, signing_secret)
valid    = constant_time_compare(expected, v1)
```

Tres reglas que rompen la verificación cuando se ignoran:

1. **Usá el cuerpo crudo, byte por byte.** Si tu framework parsea el JSON y vos lo volvés a
   serializar, la firma nunca va a coincidir: cambian los espacios, el orden de las claves o el
   escapado.
2. **Comparación en tiempo constante** (`hash_equals`, `crypto.timingSafeEqual`, `hmac.compare_digest`).
   Nunca `==`.
3. Firma inválida -> respondé **400**. Válida -> respondé **200 rápido** y procesá de forma
   asíncrona; no hagas trabajo pesado antes de responder.

### 14.5 Procesamiento idempotente

El mismo evento **puede llegar más de una vez**. Guardá el `id` del evento y descartá los repetidos,
o armá el procesamiento para que sea idempotente por naturaleza (marcar como pagada una orden que ya
estaba pagada no debe cobrar dos veces ni mandar dos correos).

La deduplicación más barata es sobre el encabezado `ROKI-Webhook-Event-Id`: es el mismo id que
aparece en el cuerpo, así que un repetido se puede descartar antes incluso de parsear el payload.

La política de reintentos de ROKI no está documentada. Asumí que puede haber reintentos y que el
orden de entrega no está garantizado.

### 14.6 Respaldo: consulta directa

Los webhooks se pueden perder (servidor caído, deploy, red). Implementá un respaldo: si una orden
sigue en `pending` después de X minutos, llamá a `GET /payments/{id}`. Acá esto importa más de lo
habitual porque no hay un endpoint de listado para conciliación masiva.

## 15. Catálogo de errores

| HTTP | Cuándo | Forma |
|---|---|---|
| **401** | Falta `Authorization` o la llave es inválida | `{"message": "..."}` |
| **404** (aplicación) | El pago no existe para esa llave | `{"message":"Pago no encontrado."}` |
| **404** (ruteo) | La ruta no existe | `{"message":"The route ... could not be found."}` - siempre en inglés |
| **405** | Método equivocado | `{"message":"The GET method is not supported for route ... Supported methods: POST."}` |
| **422** | Validación o regla de negocio | `{"message":"...", "errors":{"field":["..."]}}` |

El `message` de un 422 contiene el primer error, con el sufijo `(and N more errors)` cuando hay
varios; `errors` los agrupa por campo. **No hay códigos de error legibles por máquina**: no armes
lógica a partir de coincidencias de texto, porque el texto cambia con `Accept-Language` y puede ser
reescrito. Ramifica según el estado HTTP y según las **llaves** del objeto `errors`, que sí son
estables.

### 15.1 Mensajes 422 comunes

| Mensaje | Causa |
|---|---|
| `El campo amount es obligatorio.` | Falta un campo obligatorio |
| `El campo amount debe ser al menos 0.01.` | Monto por debajo del mínimo |
| `La moneda seleccionada no esta disponible en su terminal de enlaces de pago.` | `currency_code` no habilitado |
| `La fecha de vencimiento debe ser en el futuro.` | `expires_at` en el pasado |
| `El campo metadata debe ser un array.` | `metadata` enviado como cadena de texto |
| `Ingrese un monto o porcentaje de impuesto cuando el impuesto esta habilitado.` | Falta `sales_tax_value` |
| `El porcentaje no puede superar 100%.` | `sales_tax_value` mayor a 100 en modo porcentaje |
| `Seleccione un tipo de propina o permita la seleccion del cliente en el checkout.` | `tip_enabled` sin un modo |
| `Los limites de propina no pueden ser mayores que el monto del plan.` | `tip_max_amount` mayor que `amount` |
| `El entorno sandbox no esta configurado para enlaces de pago...` | Sandbox no aprovisionado para ese comercio (16.2) |

## 16. Entornos, pruebas y desarrollo local

### 16.1 Probar sin arriesgar dinero

Crear un pago **no cobra nada**: queda en `pending` hasta que alguien lo pague. Para probar sin
riesgo contra producción: un monto mínimo (L 1.00) y un `expires_at` cercano, y no lo pagues. El
enlace vence solo. Provocar errores 401 / 404 / 422 es igual de inofensivo.

### 16.2 Tarjetas de prueba de sandbox

Usá estas **solo** con llaves `sk_test_` / `pk_test_`. Cualquier fecha de vencimiento futura; el
nombre y el correo pueden ser valores de prueba.

| Marca | PAN | CVV |
|---|---|---|
| Visa | `4012000000020071` | 3 dígitos |
| Visa | `4333333333332222` | 3 dígitos |
| Amex | `343333333333335` | 4 dígitos |

Preferí los dos números Visa. No hay publicadas tarjetas para escenarios de rechazo forzado ni de
3-D Secure, así que los rechazos y los desafíos de autenticación siguen sin poder simularse a
propósito - manejá esos estados de forma defensiva aunque no los puedas ejercitar a demanda.

### 16.3 Si el sandbox rechaza toda creación

Una llave `sk_test_` puede autenticar correctamente y aun así fallar al crear pagos con:

```
422 "El entorno sandbox no esta configurado para enlaces de pago.
     Contacte a soporte ROKI o use su clave API live."
```

Eso no es culpa de la llave ni del código, y regenerar la llave no lo arregla: la terminal de sandbox
no está aprovisionada para ese comercio. Pedile a ROKI que habilite el sandbox para la cuenta.

### 16.4 Recibir webhooks en desarrollo local

ROKI tiene que poder alcanzar una URL **HTTPS pública**, así que `localhost` no va a funcionar.
Opciones:

- Un túnel (ngrok, Cloudflare Tunnel), registrando la URL generada en el portal bajo el entorno
  **sandbox**.
- Para solo inspeccionar eventos sin escribir código, un receptor público temporal (webhook.site y
  similares), útil para ver el payload y los encabezados reales.

Acordate de registrar la URL final antes de pasar a producción: las URLs de los túneles cambian.

## 17. Inicio rápido

```bash
# 1. Create a payment
curl -X POST https://aura.roki.systems/api/connect/v1/payments \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: order-1001-create" \
  -d '{
    "amount": 150.00,
    "external_reference": "order-1001",
    "name": "Order #1001",
    "metadata": { "order_id": "1001" },
    "success_url": "https://yoursite.com/thank-you",
    "cancel_url": "https://yoursite.com/cart",
    "expires_at": "2026-08-09 18:00:00"
  }'

# -> 201 with "id" and "checkout_url": store the id, redirect the customer to checkout_url

# 2. Check the status
curl https://aura.roki.systems/api/connect/v1/payments/706 \
  -H "Authorization: Bearer sk_test_..." \
  -H "Accept: application/json"
```

## 18. Aplicaciones móviles (iOS / Android)

No existe un SDK móvil de ROKI. Una app móvil se integra con el mismo flujo de checkout alojado que
la web, con tres restricciones propias de lo móvil y fáciles de equivocar.

### 18.1 La llave secreta nunca viaja dentro de la app

Un binario móvil se puede descompilar: de cualquier APK o IPA se pueden extraer las cadenas de texto,
y la ofuscación solo lo hace más lento. Una `sk_live_` filtrada le permite a cualquiera crear pagos
como el comercio y leer todos los pagos que tiene. **La app nunca debe llamar directamente a la API
de ROKI.**

La topología correcta agrega un salto:

```
Mobile app  -->  Merchant backend  --POST /payments-->  ROKI
                 (holds the secret key)
            <--  returns only { payment_id, checkout_url }
```

La app recibe el `checkout_url` y nada más. El backend se queda con la llave, recibe el webhook y es
dueño del estado del pedido.

### 18.2 Abrí el checkout en el navegador del sistema, no en un WebView

Usá **SFSafariViewController** en iOS y **Chrome Custom Tabs** en Android. No uses un `WKWebView` /
`WebView` pelado.

Cuatro razones por las que esto importa en una página de pago:

1. Los desafíos de 3-D Secure los renderiza el banco emisor, y muchos emisores bloquean o se
   comportan mal dentro de WebViews embebidos.
2. Los gestores de contraseñas y el autocompletado no funcionan en un WebView pelado, lo que aumenta
   la fricción al ingresar la tarjeta y el abandono.
3. El cliente no puede ver la barra de URL ni el candado, así que no puede verificar que está en un
   dominio de pago legítimo - un problema real de confianza a la hora de teclear un número de
   tarjeta.
4. El navegador del sistema comparte su almacén de cookies y su postura de seguridad, así que el
   checkout se comporta como ROKI lo probó.

### 18.3 Volver a la app: solo Universal Links / App Links

**Verificado contra la API en producción:** `success_url` y `cancel_url` aceptan solo URLs
`http`/`https`. Los esquemas personalizados se rechazan:

| Valor | Resultado |
|---|---|
| `https://yourapp.com/payment-done` | 201, aceptado |
| `myapp://payment/ok` | 422 en `success_url` |
| `com.yourapp://checkout/done` | 422 en `success_url` |
| `intent://payment#Intent;scheme=myapp;end` | 422 en `success_url` |

O sea que la ruta de retorno tiene que ser una **URL https que la app reclame** mediante Universal
Links (iOS) o App Links (Android). Esa misma URL debería mostrar una página web normal para los
clientes que no tienen la app instalada.

### 18.4 Confirmar el pago dentro de la app

La regla de 9.3 es todavía más importante en móvil, porque el viaje de vuelta es frágil: el cliente
puede cambiarse de app, perder conexión o cerrar la hoja del navegador antes de que se dispare la
redirección.

**La app nunca debe tomar el retorno como confirmación, y nunca debe preguntarle a ROKI
directamente.** En vez de eso:

1. El backend recibe el webhook `payment.approved` y actualiza el pedido.
2. La app le consulta periódicamente a su propio backend (o recibe de él una notificación push) el
   estado del pedido.
3. Si la hoja del navegador se cierra sin redirección, la app igual consulta - bien puede ser que el
   pago haya salido bien.

### 18.5 Lo que hoy no existe

No hay SDK móvil, no hay hoja de pago nativa y no hay soporte documentado para Apple Pay ni Google
Pay. El SDK de campos de tarjeta embebidos que está en el roadmap está orientado al navegador; una
app móvil que quiera captura de tarjeta dentro de su propia interfaz hoy no tiene un camino soportado
y tiene que usar el checkout alojado.

## 19. Ejemplos de integración por stack

### 19.0 Clientes de un solo archivo, por si preferís no escribir uno

Antes de escribir un cliente a mano, sabé que ya existe uno para PHP, TypeScript y Python. Cada uno
es un solo archivo sin dependencias, generado a partir de `openapi.yaml` y regenerado en cada
despliegue, así que no puede describir un contrato que la API ya no tiene.

| Lenguaje | Descarga | Requiere |
|---|---|---|
| PHP | `https://mcp.roki.la/sdk/RokiConnect.php` | PHP 8.0+, ext-curl |
| TypeScript / JavaScript | `https://mcp.roki.la/sdk/roki-connect.ts` | Node 18+, o cualquier runtime con `fetch` |
| Python | `https://mcp.roki.la/sdk/roki_connect.py` | Python 3.10+, solo biblioteca estándar |

Traen las tres defensas que esta API necesita y que un cliente escrito a mano suele olvidar:

- **Los campos desconocidos se rechazan antes de enviar la petición**, sugiriendo el nombre correcto.
  `service_fee` se detecta y se apunta hacia `service_fee_enabled`, en lugar de ser aceptado con un
  `201` e ignorado.
- **Se genera `Idempotency-Key`** donde la especificación lo exige y vos no pasaste ninguno.
- **Un 404 de una anulación, un reembolso o un recibo te lo dice**: explica que la ruta quiere el
  UUID de `transaction_id`, no el `id` numérico del pago.

Cada uno también incluye `verifyWebhook` / `verify_webhook`, que calcula el HMAC sobre
`timestamp + "." + raw_body` y compara en tiempo constante (14.4).

### 19.1 Si venís de otra pasarela

Si el desarrollador conoce Stripe o Mercado Pago, los errores son predecibles: escribe
`amount_cents`, `payment_method_types`, `notification_url`, `line_items`. Cuarenta y cinco nombres
de ese tipo están mapeados a su equivalente en ROKI - o a un explícito "no hay equivalente, hacé
esto en su lugar" - y el mapeo lo aplican tanto `roki_validate_request` como los clientes de arriba.

Los cuatro que más importan:

| En otro lado | En ROKI |
|---|---|
| `amount_cents: 150000` | `amount: 1500.00` - unidades decimales, nunca unidades menores |
| `payment_method_types` | Nada. El modo es el endpoint que llamás (ver 1). |
| `notification_url` por pago | Nada. Los webhooks se registran una sola vez por entorno (14). |
| `line_items` / `items` | Nada. Sumá el carrito vos mismo y enviá un solo `amount`. |

Preguntale a `roki_validate_request` con el payload que estabas por enviar; te dice de qué pasarela
viene tu costumbre y cómo le llama ROKI a lo mismo.

### 19.2 Ejemplos completos

Cuatro integraciones completas y ejecutables. Las cuatro siguen la misma forma: credenciales desde
un almacén de configuración en tiempo de ejecución, un cliente de API con idempotencia derivada del
contenido, el flujo de checkout, un manejador de webhooks que verifica la firma sobre el cuerpo
crudo, y una consulta periódica como respaldo.

Elegí la más cercana a tu stack; la estructura se traslada directamente a cualquier otra.

### PHP / Laravel

Probado contra PHP 8.2+ y Laravel 11/12, usando la fachada `Http`. Todo aquello de lo que depende el dinero del cliente ocurre del lado del servidor; la llave secreta nunca llega a un navegador.

Dos reglas guían todo el diseño y vale la pena dejarlas claras desde el inicio:

- **La API ignora silenciosamente los campos desconocidos.** Un nombre de campo mal escrito devuelve `201 Created` sin ninguna advertencia, y terminás con un pago sin esa funcionalidad. Por eso el cliente de abajo verifica la respuesta contra lo que envió antes de redirigir a nadie.
- **Los montos son unidades decimales**, nunca centavos. `150.50` significa L 150.50.

La anulación, el reembolso y los recibos existen y se identifican por el UUID de la transacción (`transaction_id`), nunca por el id numérico del pago. Sigue sin existir un endpoint de listado. La anulación y el reembolso también disparan webhooks cuando un humano los ejecuta en el portal, así que un manejador no debe asumir que solo sus propias llamadas cambian el estado.

#### 1. Configuración: llaves desde una tabla de ajustes, no desde el código

Ambos secretos viven en una fila de `settings`, cifrados en reposo. Rotar una llave es un `UPDATE`, no un despliegue.

```php
// database/migrations/2026_08_08_000100_create_settings_table.php
Schema::create('settings', function (Blueprint $table) {
    $table->id();
    $table->string('key')->unique();
    $table->text('value');           // encrypted with the app key
    $table->timestamps();
});
```

```php
// config/roki.php
return [
    'base_url'        => env('ROKI_BASE_URL', 'https://aura.roki.systems/api/connect/v1'),
    'timeout'         => (int) env('ROKI_TIMEOUT', 15),
    'connect_timeout' => (int) env('ROKI_CONNECT_TIMEOUT', 5),

    // Accept-Language drives the language of validation messages. 'en' keeps logs greppable.
    // Routing errors (404 route not found, 405) are always English regardless.
    'language' => env('ROKI_LANGUAGE', 'en'),

    'keys' => [
        'secret'         => 'roki.secret_key',      // sk_test_... or sk_live_...
        'webhook_secret' => 'roki.webhook_secret',  // per environment, from the portal
    ],

    // Per-process store by default: decrypted secrets never land in a shared Redis.
    'settings_cache_store' => env('ROKI_SETTINGS_CACHE', 'array'),
    'settings_cache_ttl'   => 60,
];
```

```php
// app/Support/Settings.php
namespace App\Support;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class Settings
{
    public function get(string $key): ?string
    {
        $store = Cache::store(config('roki.settings_cache_store'));

        return $store->remember("settings:{$key}", config('roki.settings_cache_ttl'), function () use ($key) {
            $value = DB::table('settings')->where('key', $key)->value('value');

            return $value === null ? null : Crypt::decryptString($value);
        });
    }

    public function getOrFail(string $key): string
    {
        $value = $this->get($key);

        if ($value === null || trim($value) === '') {
            // Fail closed. A blank key must never degrade into an unauthenticated call.
            throw new RuntimeException("Missing runtime setting [{$key}].");
        }

        return $value;
    }

    public function put(string $key, string $value): void
    {
        DB::table('settings')->updateOrInsert(
            ['key' => $key],
            ['value' => Crypt::encryptString($value), 'updated_at' => now(), 'created_at' => now()],
        );

        Cache::store(config('roki.settings_cache_store'))->forget("settings:{$key}");
    }
}
```

Rotación, sin redespliegue y sin reinicio:

```php
// php artisan tinker
app(App\Support\Settings::class)->put('roki.secret_key', 'sk_live_...');
app(App\Support\Settings::class)->put('roki.webhook_secret', 'whsec_from_the_portal');
```

El TTL de la caché limita cuánto tiempo los workers viejos conservan la llave anterior: 60 segundos. Si corrés más de un nodo, mantené el TTL corto en vez de compartir un almacén de caché.

#### 2. El cliente de la API

Primero las excepciones, para que el código que llama pueda ramificar según los modos de falla reales.

```php
// app/Services/Roki/Exceptions.php
namespace App\Services\Roki;

use RuntimeException;

class RokiException extends RuntimeException {}

/** 401: the Authorization header is missing or the key is invalid. Operator problem, not user. */
final class RokiAuthException extends RokiException {}

/** 404 with {"message":"Pago no encontrado."} - the route exists, the payment does not. */
final class RokiPaymentNotFoundException extends RokiException {}

/** 404 with "The route ... could not be found." - the path itself does not exist in the API. */
final class RokiRouteNotFoundException extends RokiException {}

/** 422: validation or business rule. Carries the per-field `errors` object. */
final class RokiValidationException extends RokiException
{
    /** @param array<string, array<int, string>> $errors */
    public function __construct(string $message, private readonly array $errors = [])
    {
        parent::__construct($message);
    }

    /** @return array<string, array<int, string>> */
    public function errors(): array
    {
        return $this->errors;
    }

    /** @return array<int, string> */
    public function errorsFor(string $field): array
    {
        return $this->errors[$field] ?? [];
    }

    public function failedOn(string $field): bool
    {
        return isset($this->errors[$field]);
    }
}

/** The response came back 201/200 but a field we sent was silently dropped. */
final class RokiFieldIgnoredException extends RokiException
{
    /** @param array<int, string> $fields */
    public function __construct(public readonly array $fields)
    {
        parent::__construct('ROKI ignored these fields: '.implode(', ', $fields));
    }
}
```

La llave de idempotencia es una huella del **contenido** de la orden. Esto no es decoración: reenviar una llave con un cuerpo distinto devuelve el pago original **sin ningún error** - la llave gana y el cuerpo nuevo se descarta. Una llave derivada solo del id de la orden cobraría silenciosamente el monto viejo después de que el cliente edite el carrito.

```php
// app/Services/Roki/IdempotencyKey.php
namespace App\Services\Roki;

use JsonException;

final class IdempotencyKey
{
    /**
     * Derive the key from the payload itself, so any change to the order content
     * produces a new key. Reusing a key with a changed body returns the ORIGINAL
     * payment silently - the amount you meant to charge would be discarded.
     *
     * @param array<string, mixed> $payload
     * @throws JsonException
     */
    public static function forPayload(string $scope, array $payload): string
    {
        $canonical = json_encode(
            self::sortRecursive($payload),
            JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE,
        );

        // Header max length is 191.
        return substr($scope.'-'.hash('sha256', $canonical), 0, 191);
    }

    /**
     * @param array<string, mixed> $data
     * @return array<string, mixed>
     */
    private static function sortRecursive(array $data): array
    {
        foreach ($data as $key => $value) {
            if (is_array($value)) {
                $data[$key] = self::sortRecursive($value);
            }
        }

        ksort($data);

        return $data;
    }
}
```

Normalizá los montos antes de calcular la huella (`150.5` y `150.50` no deben producir dos llaves); el constructor del payload del paso 3 redondea a dos decimales exactamente por esa razón.

```php
// app/Services/Roki/RokiClient.php
namespace App\Services\Roki;

use App\Support\Settings;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Throwable;

final class RokiClient
{
    public function __construct(private readonly Settings $settings) {}

    /**
     * @param array<string, mixed> $payload
     * @return array<string, mixed> the Payment object
     */
    public function createPayment(array $payload, string $idempotencyKey): array
    {
        $response = $this->request()
            ->withHeaders(['Idempotency-Key' => $idempotencyKey])
            ->post('/payments', $payload);

        return $this->decode($response);
    }

    /** @return array<string, mixed> the Payment object */
    public function getPayment(int $id): array
    {
        return $this->decode($this->request()->get("/payments/{$id}"));
    }

    /** 'live' or 'test' - a payment can only be read back with a key from its own environment. */
    public function environment(): string
    {
        return str_starts_with($this->secretKey(), 'sk_live_') ? 'live' : 'test';
    }

    private function secretKey(): string
    {
        $key = $this->settings->getOrFail(config('roki.keys.secret'));

        if (! str_starts_with($key, 'sk_live_') && ! str_starts_with($key, 'sk_test_')) {
            throw new RokiAuthException('Stored ROKI key has an unexpected prefix.');
        }

        return $key;
    }

    private function request(): PendingRequest
    {
        return Http::baseUrl(config('roki.base_url'))
            ->withToken($this->secretKey())              // Authorization: Bearer sk_...
            ->withHeaders(['Accept-Language' => config('roki.language')])
            ->acceptJson()
            ->asJson()
            ->connectTimeout(config('roki.connect_timeout'))
            ->timeout(config('roki.timeout'))
            // Retrying a POST is only safe because every create carries an Idempotency-Key.
            // Never retry 4xx: the request is wrong, not unlucky.
            ->retry(3, 300, function (Throwable $e): bool {
                if ($e instanceof ConnectionException) {
                    return true;
                }

                return $e instanceof RequestException && $e->response->status() >= 500;
            }, throw: false);
    }

    /** @return array<string, mixed> */
    private function decode(Response $response): array
    {
        if ($response->successful()) {
            $body = $response->json();

            if (! is_array($body)) {
                throw new RokiException('ROKI returned a non-JSON body with status '.$response->status().'.');
            }

            return $body;
        }

        $body    = is_array($response->json()) ? $response->json() : [];
        $message = is_string($body['message'] ?? null) ? $body['message'] : $response->body();

        throw match ($response->status()) {
            401 => new RokiAuthException($message),
            // Two different 404s. The routing one always arrives in English and means
            // the path does not exist - typically a call to a removed endpoint.
            404 => str_contains($message, 'could not be found')
                ? new RokiRouteNotFoundException($message)
                : new RokiPaymentNotFoundException($message),
            422 => new RokiValidationException($message, (array) ($body['errors'] ?? [])),
            default => new RokiException("ROKI HTTP {$response->status()}: {$message}"),
        };
    }
}
```

En un `422`, `message` contiene solo el primer error (con el sufijo `(and N more errors)`); `errors` los contiene todos indexados por campo. Ramificá según las **llaves**, nunca según el texto del mensaje - el texto se localiza con `Accept-Language`, las llaves son estables.

```php
try {
    $payment = $client->createPayment($payload, $key);
} catch (RokiValidationException $e) {
    if ($e->failedOn('currency_code')) {
        // Currency not enabled on this terminal, or sandbox not provisioned for payment links.
        Log::error('ROKI rejected the currency', ['errors' => $e->errorsFor('currency_code')]);
    }

    if ($e->failedOn('expires_at')) {
        // Expiry must be in the future, in Honduras time (UTC-6).
    }

    report($e);
}
```

Enlazalo una sola vez:

```php
// app/Providers/AppServiceProvider.php
$this->app->scoped(RokiClient::class, fn ($app) => new RokiClient($app->make(Settings::class)));
```

#### 3. Checkout: crear al confirmar la orden, verificar y después redirigir

Espejo local del pago. La API no tiene endpoint de listado, así que esta tabla es el único asidero que tenés sobre los pagos en curso.

```php
// database/migrations/2026_08_08_000200_create_roki_payments_table.php
Schema::create('roki_payments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->index();
    $table->unsignedBigInteger('payment_id')->unique();   // the ROKI id
    $table->string('status', 32)->index();                // pending|paid|partially_refunded|refunded|voided|expired|disabled
    $table->string('external_reference', 191);
    $table->decimal('amount', 12, 2);
    $table->decimal('subtotal', 12, 2);
    $table->decimal('sales_tax_amount', 12, 2)->default(0);
    $table->decimal('service_fee_amount', 12, 2)->default(0);
    $table->decimal('total', 12, 2);
    $table->string('currency_iso', 8);
    $table->text('checkout_url');
    $table->uuid('transaction_id')->nullable();          // UUID, nunca un entero
    $table->string('expires_at')->nullable();
    $table->timestamp('paid_at')->nullable();
    $table->decimal('refunded_amount', 12, 2)->nullable();
    $table->string('refund_status', 16)->nullable();      // none|partial|full
    $table->string('idempotency_key', 191);
    $table->string('key_environment', 8);                 // live|test - read it back with the same key
    $table->timestamp('next_poll_at')->nullable()->index();
    $table->unsignedSmallInteger('poll_attempts')->default(0);
    $table->timestamp('reconciled_at')->nullable();
    $table->timestamps();
});
```

El constructor del payload. Cada llave acá es un nombre de campo real - un error de tipeo no fallaría, dejaría pasar un pago mal configurado.

```php
// app/Services/Roki/PaymentPayload.php
namespace App\Services\Roki;

use App\Models\Order;
use Illuminate\Support\Str;
use InvalidArgumentException;

final class PaymentPayload
{
    /** The API enforces no maximum amount. Enforce your own. */
    private const MAX_AMOUNT = 500_000.00;

    /** @return array<string, mixed> */
    public static function forOrder(Order $order): array
    {
        // Decimal units: 150.50 = L 150.50. Rounding here also keeps the
        // idempotency fingerprint stable across 150.5 / "150.50".
        $amount = round((float) $order->total, 2);

        if ($amount < 0.01 || $amount > self::MAX_AMOUNT) {
            throw new InvalidArgumentException("Order {$order->id} amount out of range: {$amount}");
        }

        $payload = [
            'amount'             => $amount,
            'external_reference' => (string) $order->id,   // NOT unique on ROKI's side
            'name'               => Str::limit("Order #{$order->number}", 191, ''),
            'description'        => Str::limit((string) $order->summary, 120, ''),
            'currency_code'      => '340',                 // HNL, ISO 4217 numeric
            'metadata'           => [
                // Carried untouched into every webhook. This is the reconciliation channel;
                // it is not shown to the customer and does not prefill the checkout form.
                'order_id'       => (string) $order->id,
                'customer_email' => (string) $order->customer_email,
            ],
            'success_url'        => route('checkout.return', $order),
            'cancel_url'         => route('cart.index'),
            // Honduras time (UTC-6), must be in the future.
            'expires_at'         => now('America/Tegucigalpa')->addHours(2)->format('Y-m-d H:i:s'),
            // Passes ROKI's commission + 3-D Secure charge on to the customer. ROKI recomputes
            // the total by reverse calculation so you net `amount`. Never replicate the formula:
            // read service_fee_amount and total from the response.
            'service_fee_enabled' => true,
        ];

        if ($order->applies_sales_tax) {
            $payload['sales_tax_type']  = 'percentage';   // none | fixed | percentage
            $payload['sales_tax_value'] = 15;             // percentage: cannot exceed 100
        }

        return array_filter($payload, static fn ($v) => $v !== null && $v !== '');
    }
}
```

La guarda que detecta un campo ignorado silenciosamente. Ejecutala en cada creación, antes de persistir nada y antes de redirigir a ningún cliente.

```php
// app/Services/Roki/ResponseGuard.php
namespace App\Services\Roki;

use Illuminate\Support\Facades\Log;

final class ResponseGuard
{
    /**
     * @param array<string, mixed> $sent    the body we posted
     * @param array<string, mixed> $payment the Payment returned
     */
    public static function assertHonoured(array $sent, array $payment): void
    {
        $ignored = [];

        // Each check asserts "the feature we asked for is visible in the response".
        if (self::cents($payment['amount'] ?? 0) !== self::cents($sent['amount'])) {
            $ignored[] = 'amount';
        }

        if (($payment['external_reference'] ?? null) !== $sent['external_reference']) {
            $ignored[] = 'external_reference';
        }

        if (isset($sent['currency_code']) && ($payment['currency'] ?? null) !== $sent['currency_code']) {
            $ignored[] = 'currency_code';
        }

        if (isset($sent['expires_at']) && blank($payment['expires_at'] ?? null)) {
            $ignored[] = 'expires_at';
        }

        // An empty metadata may come back as [] instead of {} - both mean "none".
        if (filled($sent['metadata'] ?? null) && blank($payment['metadata'] ?? null)) {
            $ignored[] = 'metadata';
        }

        if (($sent['sales_tax_type'] ?? 'none') !== 'none' && self::cents($payment['sales_tax_amount'] ?? 0) === 0) {
            $ignored[] = 'sales_tax_type/sales_tax_value';
        }

        if (($sent['service_fee_enabled'] ?? false) === true && self::cents($payment['service_fee_amount'] ?? 0) === 0) {
            $ignored[] = 'service_fee_enabled';
        }

        if ($ignored !== []) {
            // Also fires when an Idempotency-Key collides: the API replays the original
            // payment for a changed body without erroring, so the amounts will not match.
            throw new RokiFieldIgnoredException($ignored);
        }

        if (($payment['status'] ?? null) !== 'pending') {
            throw new RokiException('Fresh payment is not pending: '.var_export($payment['status'] ?? null, true));
        }

        if (! str_starts_with((string) ($payment['checkout_url'] ?? ''), 'https://')) {
            throw new RokiException('Payment has no usable checkout_url.');
        }

        // total is authoritative. This is a consistency alarm, not a recomputation.
        $parts = self::cents($payment['subtotal'] ?? 0)
            + self::cents($payment['sales_tax_amount'] ?? 0)
            + self::cents($payment['service_fee_amount'] ?? 0);

        if (abs(self::cents($payment['total'] ?? 0) - $parts) > 1) {
            Log::warning('ROKI total does not match its parts', ['payment_id' => $payment['id'] ?? null]);
        }
    }

    private static function cents(mixed $value): int
    {
        return (int) round(((float) $value) * 100);
    }
}
```

```php
// app/Services/Roki/CheckoutService.php
namespace App\Services\Roki;

use App\Models\Order;
use App\Models\RokiPayment;
use Illuminate\Support\Facades\DB;

final class CheckoutService
{
    public function __construct(private readonly RokiClient $client) {}

    /** @return string the checkout_url to redirect to */
    public function start(Order $order): string
    {
        $payload = PaymentPayload::forOrder($order);
        $key     = IdempotencyKey::forPayload("order-{$order->id}", $payload);

        // Same content as an existing live link: reuse it. external_reference is not
        // unique, so without this a double-click would leave two payable links behind.
        $existing = RokiPayment::query()
            ->where('order_id', $order->id)
            ->where('status', 'pending')
            ->where('idempotency_key', $key)
            ->latest('id')
            ->first();

        if ($existing !== null) {
            return $existing->checkout_url;
        }

        $payment = $this->client->createPayment($payload, $key);

        // Verify before persisting or redirecting. Everything downstream trusts this.
        ResponseGuard::assertHonoured($payload, $payment);

        DB::transaction(function () use ($order, $payment, $key): void {
            RokiPayment::updateOrCreate(
                ['payment_id' => (int) $payment['id']],
                [
                    'order_id'           => $order->id,
                    'status'             => $payment['status'],
                    'external_reference' => $payment['external_reference'],
                    'amount'             => $payment['amount'],
                    'subtotal'           => $payment['subtotal'],
                    'sales_tax_amount'   => $payment['sales_tax_amount'] ?? 0,
                    'service_fee_amount' => $payment['service_fee_amount'] ?? 0,
                    'total'              => $payment['total'],     // charge this, not your own sum
                    'currency_iso'       => $payment['currency_iso'],
                    'checkout_url'       => $payment['checkout_url'],
                    'expires_at'         => $payment['expires_at'] ?? null,
                    'idempotency_key'    => $key,
                    'key_environment'    => $this->client->environment(),
                    'next_poll_at'       => now()->addMinutes(2),
                ],
            );

            $order->forceFill(['payment_status' => 'awaiting_payment'])->save();
        });

        return $payment['checkout_url'];
    }
}
```

```php
// app/Http/Controllers/CheckoutController.php
public function pay(Order $order, CheckoutService $checkout): RedirectResponse
{
    try {
        return redirect()->away($checkout->start($order));
    } catch (RokiValidationException $e) {
        report($e);

        return back()->withErrors(['payment' => 'We could not start the payment. Please review your order.']);
    } catch (RokiAuthException|RokiFieldIgnoredException $e) {
        // Operator-side faults: a rotated/blank key, or a payment that would have been
        // created wrong. Never send the customer to a link built on either.
        report($e);

        return back()->withErrors(['payment' => 'Payments are temporarily unavailable.']);
    }
}

/** success_url lands here. Anyone can open this URL - it is NOT proof of payment. */
public function return(Order $order, RokiClient $client, PaymentStateWriter $writer): View
{
    $row = RokiPayment::where('order_id', $order->id)->latest('id')->firstOrFail();

    try {
        $writer->apply($client->getPayment($row->payment_id), source: 'return_url');
    } catch (RokiException $e) {
        report($e);   // fall back to whatever the webhook or the poller already wrote
    }

    return view('checkout.return', ['payment' => $row->fresh()]);
}
```

#### 4. Manejador de webhooks

Registrá el endpoint en el portal, en `/merchant/connect/webhooks`. Las URLs y los secretos de firma son **separados por entorno**; un secreto de sandbox nunca va a validar un evento de producción.

La ruta debe estar exenta de CSRF - ROKI no tiene token de sesión. En Laravel 11/12:

```php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['webhooks/roki']);
})
```

En Laravel 10 y versiones anteriores, agregá `'webhooks/roki'` a `$except` en `app/Http/Middleware/VerifyCsrfToken`. (Registrarlo en `routes/api.php` también evita el CSRF, pero entonces la URL del portal debe incluir el prefijo `/api`.)

```php
// routes/web.php
Route::post('/webhooks/roki', RokiWebhookController::class)
    ->middleware('throttle:240,1')
    ->name('webhooks.roki');
```

```php
// app/Services/Roki/SignatureVerifier.php
namespace App\Services\Roki;

final class SignatureVerifier
{
    public function __construct(private readonly int $toleranceSeconds = 300) {}

    /** Header format: t={unix_timestamp},v1={hmac_sha256_hex} */
    public function isValid(string $rawBody, ?string $header, string $secret): bool
    {
        if ($header === null || $header === '') {
            return false;
        }

        $parts = [];

        foreach (explode(',', $header) as $piece) {
            $kv = explode('=', trim($piece), 2);

            if (count($kv) === 2) {
                $parts[$kv[0]] = $kv[1];
            }
        }

        $timestamp = $parts['t'] ?? null;
        $provided  = $parts['v1'] ?? null;

        if (! is_string($timestamp) || ! ctype_digit($timestamp) || ! is_string($provided)) {
            return false;
        }

        // Replay window. Our own hardening, not an API requirement - widen it if
        // ROKI's retries legitimately arrive later than this.
        if (abs(time() - (int) $timestamp) > $this->toleranceSeconds) {
            return false;
        }

        $expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);

        // Constant time: a timing-sensitive === here leaks the signature byte by byte.
        return hash_equals($expected, $provided);
    }
}
```

```php
// app/Http/Controllers/RokiWebhookController.php
namespace App\Http\Controllers;

use App\Jobs\ProcessRokiEvent;
use App\Services\Roki\SignatureVerifier;
use App\Support\Settings;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use JsonException;

final class RokiWebhookController extends Controller
{
    public function __construct(
        private readonly SignatureVerifier $verifier,
        private readonly Settings $settings,
    ) {}

    public function __invoke(Request $request): Response
    {
        // The RAW body, byte for byte. $request->json()/all() re-encode it and the
        // HMAC will never match - key order and whitespace are part of the signature.
        $raw = $request->getContent();

        $secret = $this->settings->getOrFail(config('roki.keys.webhook_secret'));

        if (! $this->verifier->isValid($raw, $request->header('ROKI-Signature'), $secret)) {
            Log::warning('ROKI webhook rejected: invalid signature', ['ip' => $request->ip()]);

            return response('invalid signature', 400);
        }

        try {
            /** @var array<string, mixed> $event */
            $event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return response('invalid payload', 400);
        }

        $eventId = is_string($event['id'] ?? null) ? $event['id'] : null;
        $type    = is_string($event['type'] ?? null) ? $event['type'] : null;

        if ($eventId === null || $type === null || ! is_array($event['data'] ?? null)) {
            return response('invalid payload', 400);
        }

        // Idempotency lives on the unique index over event_id: a redelivered event
        // inserts zero rows and queues nothing.
        $isNew = DB::table('roki_webhook_events')->insertOrIgnore([
            'event_id'   => $eventId,
            'type'       => $type,
            'payload'    => $raw,
            'created_at' => $event['created_at'] ?? null,
            'received_at' => now(),
        ]) === 1;

        if ($isNew) {
            ProcessRokiEvent::dispatch($eventId);
        }

        // Fast 200. All real work happens on the queue.
        return response()->json(['received' => true]);
    }
}
```

```php
// database/migrations/2026_08_08_000300_create_roki_webhook_events_table.php
Schema::create('roki_webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('event_id', 191)->unique();   // the idempotency anchor
    $table->string('type', 64);
    $table->longText('payload');
    $table->string('created_at')->nullable();    // event created_at, as sent
    $table->timestamp('received_at');
    $table->timestamp('processed_at')->nullable();
});
```

Un único escritor atiende tanto al webhook como al proceso de consulta periódica, de modo que los dos caminos nunca pueden contradecirse.

```php
// app/Services/Roki/PaymentStateWriter.php
namespace App\Services\Roki;

use App\Events\OrderPaid;
use App\Models\RokiPayment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

final class PaymentStateWriter
{
    /**
     * Monotonic ranking so a redelivered or out-of-order event cannot walk a payment
     * backwards (a late payment.approved must not overwrite a refund). Our convention,
     * built on the documented status set.
     */
    private const RANK = [
        'pending' => 0, 'expired' => 1, 'disabled' => 1, 'paid' => 2,
        'voided'  => 3, 'partially_refunded' => 3, 'refunded' => 4,
    ];

    /** @param array<string, mixed> $payment a Payment object (webhook `data` or GET response) */
    public function apply(array $payment, string $source): void
    {
        $paymentId = filter_var($payment['id'] ?? null, FILTER_VALIDATE_INT);
        $status    = $payment['status'] ?? null;

        if ($paymentId === false || ! is_string($status) || ! isset(self::RANK[$status])) {
            Log::warning('ROKI: unusable payment payload', ['source' => $source]);

            return;
        }

        $becamePaid = false;

        DB::transaction(function () use ($payment, $paymentId, $status, $source, &$becamePaid): void {
            // Match on id. external_reference is not unique and cannot identify a payment.
            $row = RokiPayment::where('payment_id', $paymentId)->lockForUpdate()->first();

            if ($row === null) {
                Log::warning('ROKI: payment not tracked locally', [
                    'payment_id'         => $paymentId,
                    'external_reference' => $payment['external_reference'] ?? null,
                    'source'             => $source,
                ]);

                return;
            }

            if (self::RANK[$status] < self::RANK[$row->status]) {
                Log::info('ROKI: stale state ignored', ['payment_id' => $paymentId, 'incoming' => $status]);

                return;
            }

            $becamePaid = $row->status !== 'paid' && $status === 'paid';

            $row->forceFill([
                'status'             => $status,
                // Re-read the money fields: a customer-selected tip is added at checkout,
                // so the final total can exceed the one returned at creation.
                'total'              => $payment['total'] ?? $row->total,
                'subtotal'           => $payment['subtotal'] ?? $row->subtotal,
                'sales_tax_amount'   => $payment['sales_tax_amount'] ?? $row->sales_tax_amount,
                'service_fee_amount' => $payment['service_fee_amount'] ?? $row->service_fee_amount,
                'transaction_id'     => $payment['transaction_id'] ?? $row->transaction_id,
                'paid_at'            => $payment['paid_at'] ?? $row->paid_at,
                'refunded_amount'    => $payment['refunded_amount'] ?? $row->refunded_amount,
                'refund_status'      => $payment['refund_status'] ?? $row->refund_status,
                'reconciled_at'      => now(),
            ])->save();

            if (in_array($status, ['refunded', 'partially_refunded', 'voided'], true)) {
                // Refund events also carry refund_amount, refunded_at and refund_reason.
                Log::info('ROKI: money returned', [
                    'payment_id'    => $paymentId,
                    'refund_amount' => $payment['refund_amount'] ?? null,
                    'refunded_at'   => $payment['refunded_at'] ?? null,
                    'refund_reason' => $payment['refund_reason'] ?? null,
                ]);
            }
        });

        if ($becamePaid) {
            // Fires exactly once, on the transition. Both webhook and poller land here.
            OrderPaid::dispatch($paymentId);
        }
    }
}
```

```php
// app/Jobs/ProcessRokiEvent.php
namespace App\Jobs;

use App\Services\Roki\PaymentStateWriter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;

final class ProcessRokiEvent implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public array $backoff = [10, 30, 120, 600];

    public function __construct(private readonly string $eventId) {}

    public function handle(PaymentStateWriter $writer): void
    {
        $row = DB::table('roki_webhook_events')->where('event_id', $this->eventId)->first();

        if ($row === null || $row->processed_at !== null) {
            return;   // second line of idempotency: a retried job is a no-op
        }

        $event = json_decode($row->payload, true);

        // payment.approved | payment.failed | payment.expired
        // payment.voided | payment.refunded | payment.partially_refunded
        // The last three also arrive when the action was performed in the portal.
        $writer->apply($event['data'], source: 'webhook:'.$event['type']);

        DB::table('roki_webhook_events')
            ->where('event_id', $this->eventId)
            ->update(['processed_at' => now()]);
    }
}
```

#### 5. Respaldo por consulta periódica

Los webhooks fallan: un despliegue, un parpadeo de DNS, un secreto rotado. No existe ningún endpoint que liste pagos, así que el barrido se maneja desde tus propias filas de `roki_payments`.

```php
// app/Console/Commands/ReconcileRokiPayments.php
namespace App\Console\Commands;

use App\Models\RokiPayment;
use App\Services\Roki\PaymentStateWriter;
use App\Services\Roki\RokiAuthException;
use App\Services\Roki\RokiClient;
use App\Services\Roki\RokiException;
use App\Services\Roki\RokiPaymentNotFoundException;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

final class ReconcileRokiPayments extends Command
{
    protected $signature   = 'roki:reconcile {--limit=200}';
    protected $description = 'Poll ROKI for payments still pending locally (webhook fallback).';

    public function handle(RokiClient $client, PaymentStateWriter $writer): int
    {
        $due = RokiPayment::query()
            ->where('status', 'pending')
            ->where('key_environment', $client->environment())   // only what this key can read
            ->where('next_poll_at', '<=', now())
            ->where('created_at', '>=', now()->subDays(3))       // give up eventually, see below
            ->orderBy('next_poll_at')
            ->limit((int) $this->option('limit'))
            ->get();

        foreach ($due as $row) {
            try {
                // Same writer as the webhook: whichever arrives first wins, the other is a no-op.
                $writer->apply($client->getPayment($row->payment_id), source: 'poll');
            } catch (RokiAuthException $e) {
                // The key is gone or was rotated to something invalid. Stop; do not
                // burn the whole backlog against a broken credential.
                Log::critical('ROKI reconciliation aborted: key rejected', ['error' => $e->getMessage()]);

                return self::FAILURE;
            } catch (RokiPaymentNotFoundException $e) {
                // "Pago no encontrado." for an id we created almost always means a
                // test/live key mix-up. Surface it, do not retry forever.
                Log::error('ROKI payment unreadable with the current key', [
                    'payment_id'      => $row->payment_id,
                    'key_environment' => $row->key_environment,
                ]);
            } catch (RokiException $e) {
                Log::warning('ROKI poll failed', ['payment_id' => $row->payment_id, 'error' => $e->getMessage()]);
            }

            $row->refresh();

            if ($row->status === 'pending') {
                // Exponential backoff, capped at an hour: 30s, 1m, 2m, ... 60m.
                $attempts = $row->poll_attempts + 1;

                $row->forceFill([
                    'poll_attempts' => $attempts,
                    'next_poll_at'  => now()->addSeconds(min(3600, 30 * (2 ** min($attempts, 7)))),
                ])->save();
            }
        }

        return self::SUCCESS;
    }
}
```

```php
// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::command('roki:reconcile')->everyMinute()->withoutOverlapping();
```

Después del corte, dejá de consultar y alertá a una persona: nunca adivines localmente un estado terminal. Un enlace que pasó su `expires_at` va a ser reportado como `expired` por la propia API; si días después sigue leyéndose `pending`, esa es una pregunta para soporte, no un valor que se inventa.

Una prueba de humo de punta a punta que vale la pena mantener en CI, usando `Http::fake()`:

```php
Http::fake([
    '*/payments' => Http::response([
        'id' => 706, 'status' => 'pending', 'amount' => 100, 'subtotal' => 100,
        'sales_tax_amount' => 0, 'service_fee_amount' => 0,     // service_fee_enabled was dropped
        'total' => 100, 'currency' => '340', 'currency_iso' => 'HNL',
        'external_reference' => '1', 'checkout_url' => 'https://aura.roki.systems/pay/link/x',
        'created_at' => '2026-08-08 14:30:07',
    ], 201),
]);

$this->expectException(RokiFieldIgnoredException::class);
app(CheckoutService::class)->start($order);
```

Esa prueba es toda la razón de ser de `ResponseGuard`: es la única señal que vas a recibir de que un campo fue ignorado.

---

### Node.js / Express (ESM, fetch nativo, Node 20+)

ESM ejecutable para Node 20+ (`fetch` nativo, `AbortController`, `node:crypto`). Nada está escrito en duro: la llave secreta y el secreto de firma del webhook se leen de una tabla de configuración en tiempo de ejecución, así que rotar cualquiera de las dos es una actualización de base de datos, no un redespliegue.

Dos reglas guían la mayor parte del código defensivo de abajo:

- **Los campos desconocidos se ignoran en silencio.** Un campo mal escrito devuelve `201` sin advertencia alguna y un pago al que calladamente le falta la característica que pediste. El cliente aplica una lista blanca a los campos de la petición y el flujo de checkout vuelve a leer los campos calculados desde la respuesta.
- **La semántica de `Idempotency-Key` difiere del estándar de la industria.** Repetir una llave con un cuerpo *distinto* devuelve el pago *original* sin ningún error. Por eso la llave debe derivarse del contenido de la orden, no del id de la orden.

La anulación, el reembolso y los recibos existen y se identifican con el UUID de la transacción (`transaction_id`), nunca con el id numérico del pago. No hay endpoint de listado.

**Almacenamiento.** Postgres vía `pg`; adaptá el SQL a tu driver.

```sql
-- Runtime configuration. Rotating a key = UPDATE here, no deploy.
CREATE TABLE app_settings (
  key        TEXT PRIMARY KEY,
  value      TEXT        NOT NULL,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO app_settings (key, value) VALUES
  ('roki.secret_key',     'sk_live_...'),
  ('roki.webhook_secret', '...');   -- per environment: sandbox and live have different secrets

CREATE TABLE roki_payments (
  id                 BIGSERIAL PRIMARY KEY,
  order_id           TEXT        NOT NULL,
  idempotency_key    TEXT        NOT NULL UNIQUE,
  payment_id         BIGINT      UNIQUE,   -- Payment.id; NULL while the create call is in flight
  status             TEXT,                 -- Payment.status verbatim; NULL until the API answers
  amount             NUMERIC(14,2),
  subtotal           NUMERIC(14,2),
  sales_tax_amount   NUMERIC(14,2),
  service_fee_amount NUMERIC(14,2),
  total              NUMERIC(14,2),
  currency           TEXT,
  currency_iso       TEXT,
  external_reference TEXT,
  checkout_url       TEXT,
  transaction_id     TEXT,          -- UUID, never an integer
  refunded_amount    NUMERIC(14,2),
  refund_status      TEXT,
  expires_at         TIMESTAMPTZ,
  paid_at            TIMESTAMPTZ,
  poll_attempts      INT         NOT NULL DEFAULT 0,
  next_poll_at       TIMESTAMPTZ,
  needs_review       BOOLEAN     NOT NULL DEFAULT false,
  created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX roki_payments_poll_idx ON roki_payments (next_poll_at) WHERE status = 'pending';

-- One row per webhook event id: this table IS the idempotency guard.
CREATE TABLE roki_webhook_events (
  event_id     TEXT PRIMARY KEY,     -- WebhookEvent.id
  type         TEXT        NOT NULL,
  payment_id   BIGINT,
  payload      JSONB       NOT NULL,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed_at TIMESTAMPTZ
);
```

#### 1. Configuración desde un almacén de ajustes

El prefijo de la llave por sí solo selecciona el entorno (`sk_test_` sandbox, `sk_live_` producción) sobre rutas idénticas, así que el entorno se deriva, nunca se configura aparte. Un caché con TTL corto mantiene la ruta caliente fuera de la base de datos y, aun así, recoge una llave rotada en menos de un minuto.

```js
// src/config/settings.js
import pg from 'pg';

export const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const TTL_MS = 60_000;
const cache = new Map(); // key -> { value, expiresAt }

/** Reads a setting from the runtime store. Cached briefly so rotation propagates on its own. */
export async function getSetting(key) {
  const now = Date.now();
  const hit = cache.get(key);
  if (hit && hit.expiresAt > now) return hit.value;

  const { rows } = await db.query('SELECT value FROM app_settings WHERE key = $1', [key]);
  const value = rows[0]?.value?.trim();
  if (!value) {
    cache.delete(key);
    throw new Error(`Missing runtime setting "${key}". Insert it into app_settings.`);
  }
  cache.set(key, { value, expiresAt: now + TTL_MS });
  return value;
}

/** Call after an admin rotates a credential so the change is visible immediately. */
export function invalidateSettings() {
  cache.clear();
}

export const ROKI_BASE_URL = 'https://aura.roki.systems/api/connect/v1';

/** Enforce a ceiling of your own: the API accepts arbitrarily large amounts. */
export const MAX_AMOUNT = 500_000; // L 500,000.00

export async function rokiConfig() {
  const secretKey = await getSetting('roki.secret_key');
  if (!/^sk_(test|live)_/.test(secretKey)) {
    throw new Error('roki.secret_key must start with sk_test_ or sk_live_.');
  }
  return {
    baseUrl: ROKI_BASE_URL,
    secretKey,
    live: secretKey.startsWith('sk_live_'),
    timeoutMs: 15_000,
  };
}

/** Signing secret for the webhook endpoint. Sandbox and live have separate secrets. */
export const rokiWebhookSecret = () => getSetting('roki.webhook_secret');
```

La llave secreta es exclusivamente del lado del servidor. Nunca debe llegar a un bundle de navegador, a una aplicación móvil ni a una variable de entorno del lado del cliente.

#### 2. Cliente de la API

`createPayment()` y `getPayment()`, con un tiempo de espera por intento mediante `AbortController`, un `Idempotency-Key` derivado del contenido, reintentos que reutilizan esa llave, y errores tipados que llevan el objeto `errors` por campo de un `422`.

```js
// src/roki/errors.js
export class RokiError extends Error {
  constructor(message, { status = null, body = null, retryable = false } = {}) {
    super(message);
    this.name = new.target.name;
    this.status = status;
    this.body = body;
    this.retryable = retryable;
  }
}

/** 401 - Authorization header missing or key invalid. Never retry; page an operator. */
export class RokiAuthError extends RokiError {}

/** 404 with {"message":"Pago no encontrado."} - the route exists, the payment does not
 *  (wrong id, or a key from the other environment). */
export class RokiPaymentNotFoundError extends RokiError {}

/** 404 with "The route ... could not be found." - the path itself does not exist.
 *  Void, refund and receipts are keyed by the transaction UUID, not the payment id.
 *  A numeric id here produces a routing 404. There is no list endpoint. */
export class RokiRouteNotFoundError extends RokiError {}

/** 422 - validation or business rule. `fieldErrors` is the API `errors` object:
 *  { field: [message, ...] }. The keys are stable; the messages are localized. */
export class RokiValidationError extends RokiError {
  constructor(message, { status, body }) {
    super(message, { status, body, retryable: false });
    this.fieldErrors = body?.errors ?? {};
  }
  /** e.g. hasField('currency_code') -> currency not enabled on the terminal. */
  hasField(field) {
    return Object.hasOwn(this.fieldErrors, field);
  }
  flatten() {
    return Object.entries(this.fieldErrors)
      .map(([field, msgs]) => `${field}: ${msgs.join(' ')}`)
      .join(' | ');
  }
}

/** Request aborted by our own timeout. Retryable: the payment may or may not exist,
 *  which is exactly what the Idempotency-Key resolves. */
export class RokiTimeoutError extends RokiError {}

/** DNS/TLS/socket failure, or an undocumented status. */
export class RokiTransportError extends RokiError {}
```

```js
// src/roki/client.js
import { createHash } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { rokiConfig, MAX_AMOUNT } from '../config/settings.js';
import {
  RokiAuthError, RokiPaymentNotFoundError, RokiRouteNotFoundError,
  RokiValidationError, RokiTimeoutError, RokiTransportError,
} from './errors.js';

/** Exactly the fields PaymentCreateRequest defines. Anything else is silently
 *  ignored by the API, so we reject it here instead of shipping a broken payment. */
const CREATE_FIELDS = new Set([
  'amount', 'external_reference', 'name', 'currency_code', 'description', 'metadata',
  'success_url', 'cancel_url', 'expires_at',
  'sales_tax_type', 'sales_tax_value',
  'tip_enabled', 'tip_type', 'tip_value', 'tip_customer_selectable',
  'tip_preset_percentages', 'tip_allow_custom', 'tip_min_amount', 'tip_max_amount',
  'service_fee_enabled', 'reusable',
]);

/** Amounts are DECIMAL units (150.50 = L 150.50), never cents. */
export const money = (n) => Math.round(Number(n) * 100) / 100;
const cents = (n) => Math.round(Number(n) * 100);

function assertCreatePayload(payload) {
  const unknown = Object.keys(payload).filter((k) => !CREATE_FIELDS.has(k));
  if (unknown.length) {
    // The API would answer 201 and drop these. Fail loudly at build time instead.
    throw new TypeError(`Unknown ROKI field(s): ${unknown.join(', ')}`);
  }
  for (const required of ['amount', 'external_reference', 'name']) {
    if (payload[required] === undefined || payload[required] === null || payload[required] === '') {
      throw new TypeError(`"${required}" is required by PaymentCreateRequest.`);
    }
  }
  if (!(payload.amount >= 0.01) || payload.amount > MAX_AMOUNT) {
    throw new RangeError(`amount out of range: ${payload.amount} (0.01 .. ${MAX_AMOUNT})`);
  }
  if (payload.external_reference.length > 191) throw new RangeError('external_reference max length is 191.');
  if (payload.name.length > 191) throw new RangeError('name max length is 191.');
  if (payload.description && payload.description.length > 120) throw new RangeError('description max length is 120.');
}

/** Stable JSON: sorted keys, so two logically equal bodies hash identically. */
function canonical(value) {
  if (Array.isArray(value)) return value.map(canonical);
  if (value && typeof value === 'object') {
    return Object.fromEntries(Object.keys(value).sort().map((k) => [k, canonical(value[k])]));
  }
  return value;
}

/**
 * Idempotency key derived from the ORDER CONTENT, not from its id.
 *
 * Verified caveat: replaying a key with a different body returns the ORIGINAL payment
 * with no error at all (no 422). If the key were `order-1001`, an order edited from
 * L 150 to L 900 would silently reuse the L 150 payment and you would under-charge.
 * Hashing the exact body means any change to amount, tax, tip or fee yields a new key.
 */
export function idempotencyKeyFor(payload) {
  const digest = createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex');
  const ref = String(payload.external_reference).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 100);
  return `${ref}.${digest.slice(0, 32)}`; // well under the 191-char limit
}

async function request(path, { method = 'GET', payload, idempotencyKey, timeoutMs } = {}) {
  const cfg = await rokiConfig();
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs ?? cfg.timeoutMs);

  const headers = {
    Authorization: `Bearer ${cfg.secretKey}`,
    Accept: 'application/json',
    // Keys of the `errors` object are stable; only the message text is localized.
    // English keeps logs greppable.
    'Accept-Language': 'en',
  };
  if (payload !== undefined) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  let res;
  try {
    res = await fetch(`${cfg.baseUrl}${path}`, {
      method,
      headers,
      body: payload === undefined ? undefined : JSON.stringify(payload),
      signal: controller.signal,
    });
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new RokiTimeoutError(`ROKI ${method} ${path} timed out`, { retryable: true });
    }
    throw new RokiTransportError(`ROKI ${method} ${path} failed: ${err.message}`, { retryable: true });
  } finally {
    clearTimeout(timer);
  }

  const text = await res.text();
  let body = null;
  try { body = text ? JSON.parse(text) : null; } catch { /* non-JSON error page */ }

  if (res.ok) {
    if (!body || typeof body !== 'object') {
      throw new RokiTransportError('ROKI returned a non-JSON success body', { status: res.status, retryable: true });
    }
    return body;
  }

  const message = body?.message ?? text.slice(0, 300);

  switch (res.status) {
    case 401:
      // Two distinct texts: missing Authorization header vs. "Clave API invalida."
      throw new RokiAuthError(`ROKI 401: ${message}`, { status: 401, body });
    case 404:
      // Routing 404 always arrives in English and ignores Accept-Language.
      if (/could not be found/i.test(message)) {
        throw new RokiRouteNotFoundError(`ROKI route does not exist: ${method} ${path}`, { status: 404, body });
      }
      throw new RokiPaymentNotFoundError(`ROKI 404: ${message}`, { status: 404, body });
    case 422:
      throw new RokiValidationError(`ROKI 422: ${message}`, { status: 422, body });
    default:
      // The spec documents 201/401/422 on create and 200/401/404 on retrieve.
      // Anything else is unexpected; treat >=500 as transient.
      throw new RokiTransportError(`ROKI ${res.status}: ${message}`, {
        status: res.status, body, retryable: res.status >= 500,
      });
  }
}

const RETRY_DELAYS_MS = [400, 1200];

/**
 * POST /payments -> 201 Payment.
 * Retries reuse the SAME Idempotency-Key: that is the whole point of the header.
 * A retry after a timeout returns the payment the first attempt may already have created.
 */
export async function createPayment(payload, { idempotencyKey } = {}) {
  assertCreatePayload(payload);
  const key = idempotencyKey ?? idempotencyKeyFor(payload);

  for (let attempt = 0; ; attempt++) {
    try {
      return await request('/payments', { method: 'POST', payload, idempotencyKey: key });
    } catch (err) {
      if (!err.retryable || attempt >= RETRY_DELAYS_MS.length) throw err;
      await sleep(RETRY_DELAYS_MS[attempt]);
    }
  }
}

/** GET /payments/{id} -> 200 Payment. Source of truth for a charge, together with webhooks. */
export async function getPayment(paymentId) {
  const id = Number(paymentId);
  if (!Number.isInteger(id) || id < 1) throw new TypeError(`Invalid payment id: ${paymentId}`);

  for (let attempt = 0; ; attempt++) {
    try {
      return await request(`/payments/${id}`);
    } catch (err) {
      if (!err.retryable || attempt >= RETRY_DELAYS_MS.length) throw err;
      await sleep(RETRY_DELAYS_MS[attempt]);
    }
  }
}

/** metadata comes back as {} or, when none was sent, sometimes []. Both mean "no metadata". */
export const readMetadata = (payment) =>
  (payment.metadata && !Array.isArray(payment.metadata)) ? payment.metadata : {};

/** expires_at is Honduras wall-clock time (UTC-6, no DST). */
export function hondurasExpiry(minutesFromNow) {
  const t = Date.now() + minutesFromNow * 60_000 - 6 * 3_600_000;
  return new Date(t).toISOString().slice(0, 19).replace('T', ' '); // "YYYY-MM-DD HH:MM:SS"
}

export function fromHondurasTime(value) {
  if (!value) return null;
  if (/[Zz]|[+-]\d{2}:?\d{2}$/.test(value)) return new Date(value); // already offset-qualified
  return new Date(`${value.replace(' ', 'T')}-06:00`);
}

export { cents };
```

Manejar un `422` en el punto de llamada se ramifica según las llaves estables de `errors`, no según el texto localizado:

```js
try {
  payment = await createPayment(payload);
} catch (err) {
  if (err instanceof RokiValidationError) {
    // e.g. currency_code -> currency not enabled on the terminal, or sandbox not
    // provisioned for payment links; expires_at -> date in the past;
    // tip_max_amount -> greater than `amount`.
    req.log.warn({ fields: err.fieldErrors }, 'ROKI rejected the payment payload');
    return res.status(400).render('checkout/error', { detail: err.flatten() });
  }
  throw err;
}
```

#### 3. Flujo de checkout

Creá el pago cuando la orden se confirma, reclamá la llave de idempotencia en la base de datos *antes* de la llamada de red para que un formulario enviado dos veces no pueda producir dos pagos, persistí el pago, verificá los campos calculados y recién ahí redirigí.

```js
// src/checkout/createRokiPayment.js
import { db } from '../config/settings.js';
import { createPayment, idempotencyKeyFor, hondurasExpiry, fromHondurasTime, cents } from '../roki/client.js';

const CHECKOUT_HOST = 'aura.roki.systems';

function buildPayload(order) {
  return {
    amount: order.subtotal,                 // decimal units, NOT cents
    external_reference: `order-${order.id}`, // not unique on the API side
    name: `Order #${order.id}`,
    description: order.tableLabel?.slice(0, 120),
    currency_code: '340',                   // HNL
    metadata: {                             // returned untouched on GET and in every webhook
      order_id: String(order.id),
      customer_email: order.customerEmail,
    },
    success_url: `${process.env.PUBLIC_URL}/orders/${order.id}/thank-you`,
    cancel_url: `${process.env.PUBLIC_URL}/cart`,
    expires_at: hondurasExpiry(60),
    sales_tax_type: 'percentage',
    sales_tax_value: 15,
    service_fee_enabled: true,              // exact name; variants are silently ignored
    reusable: false,
  };
}

/**
 * Verifies that the payment ROKI created is the payment we asked for.
 * This is the only defense against the silent-ignore behaviour: a dropped field
 * produces a 201 and a payment missing tax, tip or fee.
 */
function verifyComputedFields(payment, sent) {
  const problems = [];

  if (cents(payment.amount) !== cents(sent.amount)) problems.push(`amount ${payment.amount} != ${sent.amount}`);
  if (payment.external_reference !== sent.external_reference) problems.push('external_reference mismatch');
  if (sent.currency_code && payment.currency !== sent.currency_code) problems.push(`currency ${payment.currency}`);
  if (payment.currency_iso !== 'HNL') problems.push(`currency_iso ${payment.currency_iso}`);
  if (payment.status !== 'pending') problems.push(`status ${payment.status}`);
  if (sent.reusable !== undefined && payment.reusable !== sent.reusable) problems.push('reusable was dropped');

  const taxRequested = sent.sales_tax_type && sent.sales_tax_type !== 'none';
  if (taxRequested && cents(payment.sales_tax_amount) === 0) problems.push('sales_tax_amount is 0 but tax was requested');
  if (!taxRequested && cents(payment.sales_tax_amount) !== 0) problems.push('unexpected sales_tax_amount');

  if (sent.service_fee_enabled === true && cents(payment.service_fee_amount) <= 0) {
    problems.push('service_fee_amount is 0 but service_fee_enabled was sent');
  }
  if (sent.service_fee_enabled !== true && cents(payment.service_fee_amount) !== 0) {
    problems.push('unexpected service_fee_amount');
  }

  // total = subtotal + sales_tax_amount + service_fee_amount. Only assert it when no
  // fixed tip is configured: a fixed tip contributes to the total outside these fields,
  // and a customer-selectable tip is added later, at checkout.
  if (!sent.tip_type && !sent.tip_value) {
    const expected = cents(payment.subtotal) + cents(payment.sales_tax_amount) + cents(payment.service_fee_amount);
    if (cents(payment.total) !== expected) problems.push(`total ${payment.total} != breakdown ${expected / 100}`);
  }

  if (!payment.checkout_url || new URL(payment.checkout_url).hostname !== CHECKOUT_HOST) {
    problems.push(`unexpected checkout_url host`);
  }

  return problems;
}

export async function createRokiPaymentForOrder(order, log) {
  const payload = buildPayload(order);
  const key = idempotencyKeyFor(payload);

  // Claim the attempt first. The UNIQUE index turns a double-submit into a no-op.
  const claim = await db.query(
    `INSERT INTO roki_payments (order_id, idempotency_key, external_reference)
     VALUES ($1, $2, $3)
     ON CONFLICT (idempotency_key) DO NOTHING
     RETURNING id`,
    [order.id, key, payload.external_reference],
  );

  if (claim.rowCount === 0) {
    const { rows } = await db.query('SELECT * FROM roki_payments WHERE idempotency_key = $1', [key]);
    // Already created: reuse the link instead of creating a second payment.
    if (rows[0]?.checkout_url) return rows[0];
    // Still in flight (or a previous attempt crashed): safe to call again with the same
    // key, the API returns the same payment.
  }

  const payment = await createPayment(payload, { idempotencyKey: key });

  const problems = verifyComputedFields(payment, payload);
  if (problems.length) {
    // The payment exists and is chargeable, so do not silently proceed: flag it and stop.
    await db.query('UPDATE roki_payments SET needs_review = true WHERE idempotency_key = $1', [key]);
    log.error({ payment_id: payment.id, problems }, 'ROKI payment does not match the request');
    throw new Error(`ROKI payment ${payment.id} failed verification: ${problems.join('; ')}`);
  }

  const { rows } = await db.query(
    `UPDATE roki_payments SET
        payment_id = $2, status = $3, amount = $4, subtotal = $5, sales_tax_amount = $6,
        service_fee_amount = $7, total = $8, currency = $9, currency_iso = $10,
        checkout_url = $11, transaction_id = $12, expires_at = $13, paid_at = $14,
        next_poll_at = now() + interval '2 minutes', updated_at = now()
      WHERE idempotency_key = $1
      RETURNING *`,
    [
      key, payment.id, payment.status, payment.amount, payment.subtotal,
      payment.sales_tax_amount ?? 0, payment.service_fee_amount ?? 0, payment.total,
      payment.currency, payment.currency_iso, payment.checkout_url,
      payment.transaction_id, fromHondurasTime(payment.expires_at), fromHondurasTime(payment.paid_at),
    ],
  );

  log.info({ payment_id: payment.id, total: payment.total }, 'ROKI payment created');
  return rows[0];
}
```

```js
// src/routes/checkout.js
import { Router } from 'express';
import { createRokiPaymentForOrder } from '../checkout/createRokiPayment.js';
import { RokiAuthError, RokiValidationError } from '../roki/errors.js';

export const checkout = Router();

checkout.post('/orders/:id/pay', async (req, res, next) => {
  try {
    const order = await loadConfirmedOrder(req.params.id, req.user);
    if (order.paidAt) return res.redirect(303, `/orders/${order.id}/thank-you`);

    const row = await createRokiPaymentForOrder(order, req.log);

    // Charge shown to the customer is `total`, not `amount`. With
    // tip_customer_selectable it can still grow at checkout.
    await linkOrderToPayment(order.id, row.payment_id, row.total);

    return res.redirect(303, row.checkout_url);
  } catch (err) {
    if (err instanceof RokiValidationError) {
      req.log.warn({ fields: err.fieldErrors }, 'ROKI validation failed');
      return res.status(400).render('checkout/error', { detail: err.flatten() });
    }
    if (err instanceof RokiAuthError) {
      req.log.error('ROKI key rejected - rotate or fix roki.secret_key in app_settings');
      return res.status(503).render('checkout/unavailable');
    }
    return next(err);
  }
});
```

Que el cliente llegue a `success_url` **no** es una confirmación: cualquiera puede navegar a esa URL. La página de agradecimiento debe leer el estado guardado localmente, que solo lo fija el webhook o el proceso de consulta periódica.

#### 4. Manejador del webhook

**La firma se calcula sobre los bytes crudos de la petición.** `express.json()` consume el stream y te deja solamente un objeto ya parseado; volver a serializarlo con `JSON.stringify` no es idéntico byte a byte a lo que se firmó. El orden de las llaves puede cambiar, los espacios en blanco insignificantes desaparecen, `105.50` se vuelve `105.5`, y el escapado de caracteres no ASCII difiere. Un solo byte distinto cambia el HMAC, así que *todos* los webhooks fallarían la verificación. Montá `express.raw()` en la ruta del webhook, antes de cualquier parser global de JSON.

```js
// src/app.js
import express from 'express';
import { rokiWebhook } from './webhooks/roki.js';
import { checkout } from './routes/checkout.js';

export const app = express();

// 1) The ROKI webhook MUST receive the raw body. This route is registered BEFORE the
//    global JSON parser, and uses express.raw() so req.body is a Buffer of the exact
//    bytes ROKI signed. Do not move it below express.json().
app.post('/webhooks/roki', express.raw({ type: '*/*', limit: '1mb' }), rokiWebhook);

// 2) Everything else parses JSON normally.
app.use(express.json());
app.use(checkout);
```

Si un parser global es inevitable (montado por un framework que no controlás), capturá el buffer en su lugar y verificá contra `req.rawBody`:

```js
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
```

```js
// src/webhooks/verify.js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300; // replay window; our choice, not an API rule

/**
 * ROKI-Signature: t={unix_timestamp},v1={hmac_sha256_hex}
 * Expected value: HMAC-SHA256(timestamp + "." + raw_body, signing_secret)
 */
export function verifyRokiSignature(rawBody, header, secret, now = Date.now()) {
  if (!Buffer.isBuffer(rawBody) || !header || !secret) return false;

  const parts = {};
  for (const piece of header.split(',')) {
    const eq = piece.indexOf('=');
    if (eq > 0) parts[piece.slice(0, eq).trim()] = piece.slice(eq + 1).trim();
  }

  const { t, v1 } = parts;
  if (!/^\d+$/.test(t ?? '')) return false;
  if (!/^[0-9a-f]{64}$/i.test(v1 ?? '')) return false; // reject before Buffer.from(hex)

  // Reject stale or far-future timestamps: the signed payload includes `t`, so an
  // attacker cannot replay an old body under a fresh timestamp.
  if (Math.abs(Math.floor(now / 1000) - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
  const received = Buffer.from(v1, 'hex');

  // timingSafeEqual throws on length mismatch; both are SHA-256 digests, but check anyway.
  if (received.length !== expected.length) return false;
  return timingSafeEqual(expected, received);
}
```

```js
// src/webhooks/roki.js
import { db } from '../config/settings.js';
import { rokiWebhookSecret } from '../config/settings.js';
import { verifyRokiSignature } from './verify.js';
import { applyPaymentState } from '../roki/applyPaymentState.js';

export async function rokiWebhook(req, res) {
  const raw = req.body; // Buffer, thanks to express.raw()
  if (!Buffer.isBuffer(raw)) {
    req.log.error('Webhook body is not a Buffer - express.json() is stealing the raw body');
    return res.status(400).json({ error: 'raw body unavailable' });
  }

  let secret;
  try {
    secret = await rokiWebhookSecret();
  } catch {
    // Do not 400 a possibly valid event because of our own misconfiguration:
    // 5xx makes ROKI retry once the secret is in place.
    return res.status(503).json({ error: 'signing secret unavailable' });
  }

  if (!verifyRokiSignature(raw, req.get('ROKI-Signature'), secret)) {
    req.log.warn({ ip: req.ip }, 'Rejected ROKI webhook: invalid signature');
    return res.status(400).json({ error: 'invalid signature' });
  }

  let event;
  try {
    event = JSON.parse(raw.toString('utf8'));
  } catch {
    return res.status(400).json({ error: 'invalid JSON' });
  }
  if (!event?.id || !event?.type || !event?.data?.id) {
    return res.status(400).json({ error: 'malformed event' });
  }

  // Idempotency: an event can be redelivered. The primary key on event_id is the guard.
  const claim = await db.query(
    `INSERT INTO roki_webhook_events (event_id, type, payment_id, payload)
     VALUES ($1, $2, $3, $4)
     ON CONFLICT (event_id) DO NOTHING
     RETURNING event_id`,
    [event.id, event.type, event.data.id, event],
  );

  if (claim.rowCount === 0) {
    req.log.info({ event_id: event.id }, 'Duplicate ROKI event ignored');
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Answer fast; the event is durably stored, so processing can finish afterwards.
  res.status(200).json({ received: true });

  try {
    await handleEvent(event, req.log);
    await db.query('UPDATE roki_webhook_events SET processed_at = now() WHERE event_id = $1', [event.id]);
  } catch (err) {
    // Left with processed_at NULL: a sweeper reprocesses these from the stored payload.
    req.log.error({ err, event_id: event.id }, 'ROKI event processing failed');
  }
}

async function handleEvent(event, log) {
  const payment = event.data; // the full Payment object
  const changed = await applyPaymentState(payment, { source: `webhook:${event.type}` });

  switch (event.type) {
    case 'payment.approved':
      if (changed) await fulfillOrder(payment);        // only on the transition into paid
      break;
    case 'payment.failed':
      // Retrying needs a NEW payment: the idempotency key is content-derived, so an
      // unchanged order reuses the same key and would return the failed payment.
      await markOrderPaymentFailed(payment);
      break;
    case 'payment.expired':
      await releaseOrderReservation(payment);
      break;
    case 'payment.voided':
    case 'payment.refunded':
    case 'payment.partially_refunded':
      // These also arrive when a human voids or refunds from the merchant portal:
      // the portal is currently the only way to run them. Refund events additionally
      // carry refund_amount, refunded_at and refund_reason alongside the payment.
      log.info({
        payment_id: payment.id,
        refund_status: payment.refund_status,
        refunded_amount: payment.refunded_amount,
        refund_amount: event.data.refund_amount,
        refunded_at: event.data.refunded_at,
        refund_reason: event.data.refund_reason,
      }, 'ROKI reversal received');
      await reverseOrder(payment);
      break;
    default:
      log.warn({ type: event.type }, 'Unhandled ROKI event type');
  }
}
```

La máquina de estados se comparte con el proceso de consulta periódica para que ambos caminos converjan, y las entregas fuera de orden o reenviadas no pueden hacer retroceder un pago:

```js
// src/roki/applyPaymentState.js
import { db } from '../config/settings.js';
import { fromHondurasTime, cents } from './client.js';

// Higher rank never regresses. `expired` must not overwrite `paid`.
const RANK = {
  pending: 0, disabled: 1, expired: 1, paid: 2, voided: 3, partially_refunded: 3, refunded: 4,
};

/** Returns true when the stored status actually advanced (side effects hang off that). */
export async function applyPaymentState(payment, { source }) {
  if (!Object.hasOwn(RANK, payment.status)) throw new Error(`Unknown ROKI status: ${payment.status}`);

  const client = await db.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(
      'SELECT status, total FROM roki_payments WHERE payment_id = $1 FOR UPDATE',
      [payment.id],
    );
    const current = rows[0];
    if (!current) {           // a payment we never stored: created elsewhere, or the portal
      await client.query('ROLLBACK');
      return false;
    }
    if (RANK[payment.status] < RANK[current.status ?? 'pending']) {
      await client.query('ROLLBACK');
      return false;           // stale delivery
    }

    // A customer-selected tip is added at checkout, so the paid total may EXCEED the
    // created total. Only a total lower than what we created is suspicious.
    const needsReview = cents(payment.total) < cents(current.total ?? payment.total);

    await client.query(
      `UPDATE roki_payments SET
         status = $2, total = $3, sales_tax_amount = $4, service_fee_amount = $5,
         transaction_id = $6, paid_at = $7, refunded_amount = $8, refund_status = $9,
         needs_review = needs_review OR $10, next_poll_at = NULL, updated_at = now()
       WHERE payment_id = $1`,
      [
        payment.id, payment.total, payment.sales_tax_amount ?? 0, payment.service_fee_amount ?? 0,
        payment.transaction_id, fromHondurasTime(payment.paid_at),
        payment.refunded_amount ?? null, payment.refund_status ?? null, needsReview,
      ],
    );
    await client.query('COMMIT');
    return (current.status ?? 'pending') !== payment.status;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}
```

#### 5. Respaldo por consulta periódica

Los webhooks fallan: tu endpoint se cae durante un despliegue, la entrega se pierde, el DNS parpadea. `GET /payments/{id}` es la otra fuente de verdad. No hay endpoint de listado (`GET /payments` responde `405`), así que consultá los ids desde tu propia tabla con un backoff, y canalizá los resultados por el mismo `applyPaymentState`.

```js
// src/roki/poller.js
import { db } from '../config/settings.js';
import { getPayment } from './client.js';
import { applyPaymentState } from './applyPaymentState.js';
import { RokiAuthError, RokiPaymentNotFoundError } from './errors.js';

const BACKOFF_MINUTES = [1, 2, 5, 10, 30, 60];

export async function pollPendingPayments(log, { batch = 50 } = {}) {
  const { rows } = await db.query(
    `SELECT payment_id, poll_attempts, expires_at
       FROM roki_payments
      WHERE status = 'pending'
        AND payment_id IS NOT NULL
        AND created_at > now() - interval '7 days'
        AND (next_poll_at IS NULL OR next_poll_at <= now())
      ORDER BY next_poll_at NULLS FIRST
      LIMIT $1
      FOR UPDATE SKIP LOCKED`,
    [batch],
  );

  for (const row of rows) {
    try {
      const payment = await getPayment(row.payment_id);
      const changed = await applyPaymentState(payment, { source: 'poll' });

      if (changed && payment.status === 'paid') {
        // The webhook never arrived (or arrived first and this is a no-op).
        await fulfillOrder(payment);
        log.warn({ payment_id: payment.id }, 'Payment confirmed by polling, not by webhook');
      }
      if (payment.status === 'pending') await scheduleNextPoll(row);
    } catch (err) {
      if (err instanceof RokiAuthError) {
        // The key was rotated or revoked: every further call fails. Stop the run.
        log.error('Polling aborted - roki.secret_key rejected');
        return;
      }
      if (err instanceof RokiPaymentNotFoundError) {
        // The route exists, this payment does not: usually a key from the OTHER
        // environment (sk_test_ vs sk_live_) after a rotation.
        await db.query(
          'UPDATE roki_payments SET needs_review = true, next_poll_at = NULL WHERE payment_id = $1',
          [row.payment_id],
        );
        log.error({ payment_id: row.payment_id }, 'Payment not found for the key in use');
        continue;
      }
      log.warn({ err, payment_id: row.payment_id }, 'Poll attempt failed');
      await scheduleNextPoll(row);
    }
  }
}

async function scheduleNextPoll(row) {
  const attempts = row.poll_attempts + 1;
  const delay = BACKOFF_MINUTES[Math.min(attempts, BACKOFF_MINUTES.length - 1)];

  // Stop once the link is well past expiry: the API moves it to `expired` on its own
  // and emits payment.expired.
  const giveUp = row.expires_at && row.expires_at.getTime() + 30 * 60_000 < Date.now();

  await db.query(
    `UPDATE roki_payments
        SET poll_attempts = $2,
            next_poll_at  = CASE WHEN $3 THEN NULL ELSE now() + ($4 || ' minutes')::interval END
      WHERE payment_id = $1`,
    [row.payment_id, attempts, giveUp, String(delay)],
  );
}
```

Ejecutalo desde un planificador que garantice un único ejecutor (cron más un advisory lock, o tu cola de trabajos). Un intervalo dentro del proceso está bien para una sola instancia:

```js
// src/index.js
import { app } from './app.js';
import { pollPendingPayments } from './roki/poller.js';
import { logger } from './logger.js';

app.listen(3000, () => logger.info('listening on :3000'));

setInterval(() => {
  pollPendingPayments(logger).catch((err) => logger.error({ err }, 'poller crashed'));
}, 60_000).unref();
```

---

### Python / FastAPI (httpx, asíncrono, con tipado completo; pydantic v2 + SQLAlchemy 2.0 async)

Integración asíncrona con `httpx`, con tipado completo. Requiere Python 3.11+, `fastapi`, `httpx`,
`pydantic` v2, `sqlalchemy` 2.0 (async) y `tzdata` en Windows.

Tres propiedades de esta API guían cada decisión de abajo:

- **Los campos desconocidos se ignoran en silencio.** Un campo mal escrito igual devuelve `201 Created`, y el
  pago se crea sin esa funcionalidad. Por eso el modelo de la petición declara `extra="forbid"`,
  y la respuesta se verifica contra la petición antes de redirigir a nadie.
- **La idempotencia se rige primero por la llave.** Reenviar un `Idempotency-Key` con un cuerpo *distinto* devuelve el
  pago original sin ningún error. La llave debe derivarse del contenido de la orden.
- **La anulación, el reembolso y los recibos toman el UUID de la transacción, no el id del pago.** No hay endpoint de listado. Esas rutas de acciones de reversión
  devuelven un 404 de enrutamiento. Las anulaciones y los reembolsos ocurren en el portal del comercio y aun así llegan como
  webhooks, así que la conciliación pasa por `metadata` y por el canal de webhooks.

Funciones como `load_confirmed_order`, `fulfil_order` y `reverse_fulfilment` son código de tu propio
dominio; aquí solo se detallan las partes que miran hacia ROKI.

#### 1. Configuración: credenciales desde una tabla de ajustes

La llave secreta y el secreto de firma del webhook viven en una tabla `settings`, no en la imagen ni en el
entorno, así que rotar una llave es un `UPDATE` más una invalidación de caché. El entorno sale del
prefijo de la llave (`sk_test_` sandbox, `sk_live_` producción): la URL base es idéntica para ambos, así que
el prefijo es lo único que decide dónde cae un cobro.

```python
# app/roki/config.py
from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass
from typing import Final

from sqlalchemy import String, Text, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class Setting(Base):
    """Runtime configuration. Rotating a key is an UPDATE, never a redeploy."""

    __tablename__ = "settings"

    key: Mapped[str] = mapped_column(String(64), primary_key=True)
    value: Mapped[str] = mapped_column(Text, nullable=False)


SECRET_KEY_SETTING: Final[str] = "roki.secret_key"
SIGNING_SECRET_SETTING: Final[str] = "roki.webhook_signing_secret"
CACHE_TTL_SECONDS: Final[float] = 30.0


class MissingSettingError(RuntimeError):
    """A required ROKI setting is absent or malformed."""


@dataclass(frozen=True, slots=True)
class RokiCredentials:
    secret_key: str
    webhook_signing_secret: str

    @property
    def is_sandbox(self) -> bool:
        return self.secret_key.startswith("sk_test_")

    def __repr__(self) -> str:
        # Never let a key reach a log line or a traceback.
        return f"RokiCredentials(env={'sandbox' if self.is_sandbox else 'live'})"


class CredentialStore:
    """Reads ROKI credentials from the settings table behind a short TTL cache."""

    def __init__(
        self,
        session_factory: async_sessionmaker[AsyncSession],
        ttl_seconds: float = CACHE_TTL_SECONDS,
    ) -> None:
        self._session_factory = session_factory
        self._ttl = ttl_seconds
        self._lock = asyncio.Lock()
        self._cached: RokiCredentials | None = None
        self._expires_at: float = 0.0

    async def get(self) -> RokiCredentials:
        cached = self._cached
        if cached is not None and time.monotonic() < self._expires_at:
            return cached
        async with self._lock:
            if self._cached is not None and time.monotonic() < self._expires_at:
                return self._cached
            credentials = await self._load()
            self._cached = credentials
            self._expires_at = time.monotonic() + self._ttl
            return credentials

    def invalidate(self) -> None:
        """Call from the admin endpoint that writes a new key, for instant rotation."""
        self._cached = None
        self._expires_at = 0.0

    async def _load(self) -> RokiCredentials:
        wanted = (SECRET_KEY_SETTING, SIGNING_SECRET_SETTING)
        async with self._session_factory() as session:
            result = await session.execute(select(Setting).where(Setting.key.in_(wanted)))
            values: dict[str, str] = {row.key: row.value for row in result.scalars()}

        try:
            secret_key = values[SECRET_KEY_SETTING].strip()
            signing_secret = values[SIGNING_SECRET_SETTING].strip()
        except KeyError as exc:
            raise MissingSettingError(f"Setting {exc.args[0]!r} is not configured") from exc

        if not secret_key.startswith(("sk_test_", "sk_live_")):
            raise MissingSettingError("roki.secret_key must start with sk_test_ or sk_live_")
        if not signing_secret:
            raise MissingSettingError("roki.webhook_signing_secret is empty")
        return RokiCredentials(secret_key=secret_key, webhook_signing_secret=signing_secret)
```

La URL del webhook **y** su secreto de firma se registran por entorno en el portal del comercio.
Actualizá ambas filas en una sola transacción cuando movés un comercio entre sandbox y producción, o los
webhooks verificados empiezan a rebotar contra un secreto viejo.

#### 2. El cliente de la API

Existen dos operaciones: `createPayment` (`POST /payments`) y `getPayment` (`GET /payments/{id}`).

#### Modelos de petición y respuesta

`extra="forbid"` en la petición es el único lugar donde se puede atrapar un error de tipeo - la API no lo va a hacer por
vos. `extra="allow"` en la respuesta mantiene el cliente funcionando cuando ROKI agrega un campo.

```python
# app/roki/models.py
from __future__ import annotations

from decimal import ROUND_HALF_UP, Decimal
from enum import StrEnum
from typing import Any, Final, Literal, Self

from pydantic import (
    BaseModel,
    ConfigDict,
    Field,
    field_serializer,
    field_validator,
    model_validator,
)

CURRENCY_HNL: Final[str] = "340"  # ISO 4217 numeric code for the Honduran lempira
MAX_AMOUNT: Final[Decimal] = Decimal("250000.00")  # merchant policy: the API enforces no ceiling
CENT: Final[Decimal] = Decimal("0.01")


class PaymentStatus(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    PARTIALLY_REFUNDED = "partially_refunded"
    REFUNDED = "refunded"
    VOIDED = "voided"
    EXPIRED = "expired"
    DISABLED = "disabled"


#: The only status that can still turn into a payment on its own. Everything else is settled as far
#: as checkout is concerned; later refunds and voids arrive as webhooks.
OPEN_STATUSES: Final[frozenset[str]] = frozenset({PaymentStatus.PENDING.value})


class PaymentCreateRequest(BaseModel):
    """Body of POST /payments.

    `extra="forbid"` mirrors `additionalProperties: false` in the spec. The live API answers 201 and
    silently drops any field it does not recognise, so a typo such as `service_fee` instead of
    `service_fee_enabled` yields a payment with no fee pass-through and no error anywhere.
    """

    model_config = ConfigDict(extra="forbid", frozen=True)

    # Required.
    amount: Decimal = Field(gt=Decimal("0"), le=MAX_AMOUNT)
    external_reference: str = Field(min_length=1, max_length=191)
    name: str = Field(min_length=1, max_length=191)

    # Optional.
    currency_code: str | None = Field(default=None, pattern=r"^[0-9]{3}$")
    description: str | None = Field(default=None, max_length=120)
    metadata: dict[str, str] | None = None
    success_url: str | None = None
    cancel_url: str | None = None
    expires_at: str | None = None

    sales_tax_type: Literal["none", "fixed", "percentage"] | None = None
    sales_tax_value: Decimal | None = Field(default=None, ge=Decimal("0"))

    tip_enabled: bool | None = None
    tip_type: Literal["fixed", "percentage"] | None = None
    tip_value: Decimal | None = Field(default=None, ge=Decimal("0"))
    tip_customer_selectable: bool | None = None
    tip_preset_percentages: list[float] | None = None
    tip_allow_custom: bool | None = None
    tip_min_amount: Decimal | None = Field(default=None, ge=Decimal("0"))
    tip_max_amount: Decimal | None = Field(default=None, ge=Decimal("0"))

    service_fee_enabled: bool | None = None
    reusable: bool | None = None

    @field_validator("amount", "sales_tax_value", "tip_value", "tip_min_amount", "tip_max_amount")
    @classmethod
    def _quantize(cls, value: Decimal | None) -> Decimal | None:
        if value is None:
            return None
        return value.quantize(CENT, rounding=ROUND_HALF_UP)

    @field_serializer(
        "amount",
        "sales_tax_value",
        "tip_value",
        "tip_min_amount",
        "tip_max_amount",
        when_used="unless-none",
    )
    def _money_on_the_wire(self, value: Decimal) -> float:
        # Amounts are DECIMAL UNITS, never cents: 150.50 means L 150.50. Money stays Decimal
        # everywhere in the codebase and becomes a JSON number only here; a two-decimal value of
        # this magnitude round-trips through a float exactly.
        return float(value)

    @model_validator(mode="after")
    def _business_rules(self) -> Self:
        # Rules the API answers with a 422. Checking them locally saves a round trip and turns a
        # localized message into a typed failure.
        if self.sales_tax_type in ("fixed", "percentage") and self.sales_tax_value is None:
            raise ValueError("sales_tax_value is required when sales_tax_type is not 'none'")
        if self.sales_tax_type == "percentage" and (self.sales_tax_value or Decimal("0")) > 100:
            raise ValueError("sales_tax_value cannot exceed 100 for a percentage tax")
        if self.tip_enabled:
            fixed_tip = self.tip_type is not None and self.tip_value is not None
            if not fixed_tip and not self.tip_customer_selectable:
                raise ValueError(
                    "tip_enabled requires tip_type + tip_value, or tip_customer_selectable"
                )
        if self.tip_max_amount is not None and self.tip_max_amount > self.amount:
            raise ValueError("tip_max_amount cannot exceed amount")
        return self

    @property
    def has_fixed_tip(self) -> bool:
        return bool(self.tip_enabled) and self.tip_type is not None


class Payment(BaseModel):
    """The payment object, returned identically by createPayment and getPayment."""

    model_config = ConfigDict(extra="allow")

    id: int
    status: str  # kept as str so an unknown future status parses instead of exploding
    name: str
    description: str | None = None
    amount: Decimal
    reusable: bool = False
    subtotal: Decimal
    sales_tax_amount: Decimal = Decimal("0")
    service_fee_amount: Decimal = Decimal("0")
    total: Decimal
    currency: str
    currency_iso: str
    external_reference: str
    # The API returns an empty array, not an empty object, when no metadata was sent.
    metadata: dict[str, Any] | list[Any] = Field(default_factory=dict)
    checkout_url: str
    transaction_id: int | None = None
    expires_at: str | None = None
    created_at: str
    paid_at: str | None = None
    # Present only on payments with a void or refund history.
    refunded_amount: Decimal | None = None
    refund_status: Literal["none", "partial", "full"] | None = None

    @property
    def meta(self) -> dict[str, Any]:
        return self.metadata if isinstance(self.metadata, dict) else {}

    @property
    def is_open(self) -> bool:
        return self.status in OPEN_STATUSES
```

#### Errores

```python
# app/roki/errors.py
from __future__ import annotations

from typing import Mapping


class RokiError(Exception):
    def __init__(self, message: str, *, status_code: int | None = None) -> None:
        super().__init__(message)
        self.message = message
        self.status_code = status_code


class RokiAuthError(RokiError):
    """401: missing Authorization header, or invalid key. Never retry; fix the settings row."""


class RokiPaymentNotFoundError(RokiError):
    """404 'Payment not found': the route exists, no payment with that id for THIS key.

    In practice this nearly always means the key was rotated to the other environment. A payment
    created with sk_live_ is invisible to sk_test_ and vice versa.
    """


class RokiRoutingError(RokiError):
    """404 'The route ... could not be found.': the PATH does not exist.

    This is what void, refund, receipt and list-payments return. Seeing it in production means code
    was written against an endpoint this API does not have.
    """


class RokiValidationError(RokiError):
    """422: validation or business-rule failure, with per-field detail."""

    def __init__(
        self,
        message: str,
        *,
        errors: Mapping[str, list[str]],
        status_code: int | None = 422,
    ) -> None:
        super().__init__(message, status_code=status_code)
        # Message text is localized by Accept-Language; the KEYS are stable. Branch on the keys.
        self.errors: Mapping[str, list[str]] = errors

    @property
    def fields(self) -> list[str]:
        return sorted(self.errors)

    def first_for(self, field: str) -> str | None:
        messages = self.errors.get(field)
        return messages[0] if messages else None


class RokiServerError(RokiError):
    """5xx. Not part of the documented contract; treated as retryable."""


class RokiTransportError(RokiError):
    """Timeout, DNS failure, connection reset. Retryable."""


class RokiUnexpectedStatusError(RokiError):
    """Anything else, including the 405 from calling GET /payments (there is no listing)."""
```

#### El cliente

Los timeouts están separados para que una lectura lenta no pueda consumir el presupuesto de conexión. Los reintentos cubren solo
fallas de transporte y 5xx, y son seguros precisamente por el `Idempotency-Key`: un reenvío
devuelve el pago original en lugar de crear un segundo.

```python
# app/roki/client.py
from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import re
from decimal import Decimal
from typing import Any, Final, Mapping, Sequence

import httpx

from app.roki.config import CredentialStore
from app.roki.errors import (
    RokiAuthError,
    RokiError,
    RokiPaymentNotFoundError,
    RokiRoutingError,
    RokiServerError,
    RokiTransportError,
    RokiUnexpectedStatusError,
    RokiValidationError,
)
from app.roki.models import Payment, PaymentCreateRequest

logger = logging.getLogger(__name__)

BASE_URL: Final[str] = "https://aura.roki.systems/api/connect/v1"
TIMEOUT: Final[httpx.Timeout] = httpx.Timeout(connect=5.0, read=15.0, write=10.0, pool=5.0)
MAX_ATTEMPTS: Final[int] = 3
IDEMPOTENCY_KEY_MAX_LENGTH: Final[int] = 191

_ROUTING_404 = re.compile(r"could not be found", re.IGNORECASE)
_UNSAFE_KEY_CHARS = re.compile(r"[^A-Za-z0-9_.-]")


def build_idempotency_key(request: PaymentCreateRequest) -> str:
    """Derive the Idempotency-Key from the ORDER CONTENT, not from the order id alone.

    Verified behaviour, and it differs from the industry standard: replaying a key with a DIFFERENT
    body returns the ORIGINAL payment, with no error. The key wins and the body is ignored. A plain
    `order-1001` key reused after the customer edited the cart would therefore charge the old
    amount, silently. Hashing the exact serialized body makes any change produce a different key,
    while a genuine retry of the same body reuses it.

    This only holds if the body is deterministic: never build it from `datetime.now()`.
    See `link_expiry()` in the checkout flow.
    """
    payload = request.model_dump(mode="json", exclude_none=True)
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    prefix = _UNSAFE_KEY_CHARS.sub("-", request.external_reference)[:96]
    return f"{prefix}-{digest[:48]}"[:IDEMPOTENCY_KEY_MAX_LENGTH]


def _decode(response: httpx.Response) -> Any:
    # parse_float=Decimal keeps 105.52 exact. Money must never pass through a binary float.
    return json.loads(response.text or "null", parse_float=Decimal)


def _classify(response: httpx.Response) -> RokiError:
    try:
        payload = _decode(response)
    except json.JSONDecodeError:
        payload = None
    message = payload.get("message", "") if isinstance(payload, dict) else ""
    status = response.status_code

    if status == 401:
        # Two distinct texts exist (header missing vs. invalid key). Both mean the same thing to the
        # caller: stop, and page an operator. The settings row is wrong, revoked, or from the other
        # environment.
        return RokiAuthError(message or "Unauthorized", status_code=status)

    if status == 404:
        # Two different 404s. The routing one is always English and means the path is not there.
        if _ROUTING_404.search(message):
            return RokiRoutingError(message, status_code=status)
        return RokiPaymentNotFoundError(message or "Payment not found", status_code=status)

    if status == 422:
        raw = payload.get("errors") if isinstance(payload, dict) else None
        errors: dict[str, list[str]] = {}
        if isinstance(raw, dict):
            for field, messages in raw.items():
                errors[str(field)] = (
                    [str(m) for m in messages] if isinstance(messages, list) else [str(messages)]
                )
        return RokiValidationError(message or "Validation failed", errors=errors)

    if status >= 500:
        return RokiServerError(f"HTTP {status}", status_code=status)
    return RokiUnexpectedStatusError(f"HTTP {status}: {message}", status_code=status)


class RokiClient:
    """Client for the two operations this API actually has.

    Do not add void(), refund(), receipt() or list_payments(): those paths return a routing 404.
    Voids and refunds can be called from your backend with the transaction UUID, and also reach you as webhooks when performed in the portal.
    """

    def __init__(self, http: httpx.AsyncClient, credentials: CredentialStore) -> None:
        self._http = http
        self._credentials = credentials

    async def create_payment(
        self,
        request: PaymentCreateRequest,
        *,
        idempotency_key: str | None = None,
    ) -> Payment:
        """operationId createPayment - POST /payments."""
        body = request.model_dump(mode="json", exclude_none=True)
        key = idempotency_key or build_idempotency_key(request)
        # 201 is the documented success status. 200 is accepted defensively: the status returned by
        # an idempotent replay is not pinned down by the spec.
        payload = await self._request(
            "POST", "/payments", body=body, idempotency_key=key, expected=(201, 200)
        )
        return Payment.model_validate(payload)

    async def get_payment(self, payment_id: int) -> Payment:
        """operationId getPayment - GET /payments/{id}.

        This call, together with the webhook, is the source of truth. A customer landing on
        `success_url` is NOT confirmation: anyone can type that URL.
        """
        payload = await self._request("GET", f"/payments/{payment_id}", expected=(200,))
        return Payment.model_validate(payload)

    async def _request(
        self,
        method: str,
        path: str,
        *,
        expected: Sequence[int],
        body: Mapping[str, Any] | None = None,
        idempotency_key: str | None = None,
    ) -> Any:
        credentials = await self._credentials.get()
        headers: dict[str, str] = {
            "Authorization": f"Bearer {credentials.secret_key}",
            "Accept": "application/json",
            # English keeps log lines readable. The `errors` keys are stable in either language.
            "Accept-Language": "en",
        }
        if idempotency_key is not None:
            headers["Idempotency-Key"] = idempotency_key

        last_error: RokiError | None = None
        for attempt in range(1, MAX_ATTEMPTS + 1):
            try:
                response = await self._http.request(
                    method, path, json=body, headers=headers, timeout=TIMEOUT
                )
            except httpx.TimeoutException:
                # Safe to retry: the payment may well have been created, and the Idempotency-Key
                # guarantees the replay returns that same payment rather than a duplicate.
                last_error = RokiTransportError(f"{method} {path} timed out")
            except httpx.TransportError as exc:
                last_error = RokiTransportError(f"{method} {path} failed: {type(exc).__name__}")
            else:
                if response.status_code in expected:
                    return _decode(response)
                error = _classify(response)
                if isinstance(error, RokiServerError) and attempt < MAX_ATTEMPTS:
                    last_error = error
                else:
                    raise error

            logger.warning("roki call failed, attempt %s/%s: %s", attempt, MAX_ATTEMPTS, last_error)
            if attempt < MAX_ATTEMPTS:
                await asyncio.sleep(0.5 * 2 ** (attempt - 1))

        raise last_error if last_error is not None else RokiTransportError(f"{method} {path} failed")
```

Construilo una sola vez por proceso para que el pool de conexiones se comparta:

```python
# app/deps.py
from __future__ import annotations

from fastapi import Request

from app.roki.client import RokiClient
from app.roki.config import CredentialStore


def get_roki_client(request: Request) -> RokiClient:
    return request.app.state.roki


def get_credential_store(request: Request) -> CredentialStore:
    return request.app.state.roki_credentials
```

#### Manejo de 401 / 404 / 422 en el punto de llamada

```python
from app.roki.errors import (
    RokiAuthError,
    RokiPaymentNotFoundError,
    RokiRoutingError,
    RokiValidationError,
)

try:
    payment = await client.create_payment(request)
except RokiValidationError as exc:
    # `errors` is a per-field map: {"amount": ["The amount field is required."], ...}.
    # Branch on the keys, which are stable; never on the message, which is localized.
    if "currency_code" in exc.errors:
        # Either the currency is not enabled on the terminal, or this is a sk_test_ key whose
        # sandbox is not provisioned for payment links. Both are configuration, not user input.
        logger.error("roki rejected the currency: %s", exc.first_for("currency_code"))
    if "amount" in exc.errors:
        logger.error("roki rejected the amount: %s", exc.first_for("amount"))
    logger.error("roki 422 on fields %s: %s", exc.fields, exc.message)
    raise
except RokiAuthError as exc:
    # The key in the settings table is missing, revoked or malformed. Do not retry.
    logger.critical("roki authentication failed: %s", exc.message)
    raise
except RokiRoutingError as exc:
    # The path does not exist. Fix the code, not the data.
    logger.critical("roki route missing: %s", exc.message)
    raise
except RokiPaymentNotFoundError as exc:
    # From getPayment only: no payment with that id for the key currently configured.
    logger.warning("roki payment not visible to the current key: %s", exc.message)
    raise
```

#### 3. Flujo de checkout

Creá el pago cuando la orden se confirma, persistí antes de la llamada de red y otra vez después de ella,
verificá los campos calculados y después redirigí.

Una fila por **intento**, no por orden. `external_reference` no es único, así que nada del lado de
ROKI impide que una orden adquiera dos enlaces activos; la fila del intento más el `Idempotency-Key`
derivado del contenido es lo que lo evita. La fila también guarda `attempt_started_at`, que alimenta
`expires_at` y por lo tanto hace que el cuerpo de la petición sea idéntico byte a byte entre reintentos del mismo intento.

```python
# app/checkout/models.py
from __future__ import annotations

from datetime import datetime
from decimal import Decimal
from typing import Final

from sqlalchemy import (
    BigInteger,
    DateTime,
    Integer,
    Numeric,
    String,
    Text,
    UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column

from app.roki.config import Base

#: Local-only state, never a ROKI status: the row exists but createPayment has not returned yet.
LOCAL_CREATING: Final[str] = "creating"


class PaymentAttempt(Base):
    """Local mirror of one ROKI payment. There is no list endpoint: a payment id you did not store
    is a payment you cannot reach again."""

    __tablename__ = "payment_attempts"
    __table_args__ = (UniqueConstraint("order_id", "attempt"),)

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
    order_id: Mapped[int] = mapped_column(BigInteger, index=True)
    attempt: Mapped[int] = mapped_column(Integer)
    # Seed for expires_at. Stored so a replay rebuilds an identical body and an identical key.
    attempt_started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

    idempotency_key: Mapped[str | None] = mapped_column(String(191), nullable=True)
    payment_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
    external_reference: Mapped[str] = mapped_column(String(191), index=True)
    status: Mapped[str] = mapped_column(String(32), default=LOCAL_CREATING, index=True)

    checkout_url: Mapped[str | None] = mapped_column(Text, nullable=True)
    total: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
    currency_iso: Mapped[str | None] = mapped_column(String(3), nullable=True)
    transaction_id: Mapped[str | None] = mapped_column(String(36), nullable=True)  # UUID
    paid_at: Mapped[str | None] = mapped_column(String(32), nullable=True)
    expires_at: Mapped[str | None] = mapped_column(String(32), nullable=True)
    refunded_amount: Mapped[Decimal | None] = mapped_column(Numeric(14, 2), nullable=True)
    refund_status: Mapped[str | None] = mapped_column(String(16), nullable=True)

    # Reconciliation bookkeeping.
    last_event_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    next_poll_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True, index=True
    )
    poll_attempts: Mapped[int] = mapped_column(Integer, default=0)
    needs_review: Mapped[bool] = mapped_column(default=False)
```

#### Construcción de una petición determinista

```python
# app/checkout/service.py
from __future__ import annotations

from datetime import datetime, timedelta
from decimal import Decimal
from typing import Final
from zoneinfo import ZoneInfo

from app.checkout.models import PaymentAttempt
from app.roki.models import CURRENCY_HNL, PaymentCreateRequest

HONDURAS: Final[ZoneInfo] = ZoneInfo("America/Tegucigalpa")  # UTC-6, the zone expires_at is read in
LINK_TTL: Final[timedelta] = timedelta(hours=2)
PUBLIC_BASE_URL: Final[str] = "https://yoursite.com"
SALES_TAX_PERCENTAGE: Final[Decimal] = Decimal("15")


def link_expiry(started_at: datetime) -> str:
    """Expiry derived from the attempt's stored start time, never from now().

    A body containing now() changes on every call, which changes the Idempotency-Key, which defeats
    the duplicate protection exactly when it matters: on a retry. Format is the one the API takes,
    in Honduras time.
    """
    return (started_at.astimezone(HONDURAS) + LINK_TTL).strftime("%Y-%m-%d %H:%M:%S")


def build_payment_request(order: "Order", attempt: PaymentAttempt) -> PaymentCreateRequest:
    return PaymentCreateRequest(
        amount=order.subtotal,  # decimal units: 150.50 is L 150.50, never cents
        external_reference=attempt.external_reference,
        name=f"Order #{order.id}",
        description=(order.summary or None) and order.summary[:120],
        currency_code=CURRENCY_HNL,
        # metadata comes back untouched on retrieval and in every webhook. With no list endpoint it
        # is the reconciliation channel. Keep every value a string.
        metadata={
            "order_id": str(order.id),
            "attempt": str(attempt.attempt),
            "customer_email": order.customer_email,
        },
        success_url=f"{PUBLIC_BASE_URL}/checkout/return?order_id={order.id}",
        cancel_url=f"{PUBLIC_BASE_URL}/cart",
        expires_at=link_expiry(attempt.attempt_started_at),
        sales_tax_type="percentage",
        sales_tax_value=SALES_TAX_PERCENTAGE,
        service_fee_enabled=True,  # exact name; service_fee / pass_fees_to_customer are ignored
        reusable=False,
    )
```

Como `attempt` y `expires_at` cambian los dos en un intento nuevo, un intento fresco después de un
enlace vencido produce una llave distinta y por lo tanto un pago genuinamente nuevo - mientras que un
reintento dentro del mismo intento repite la llave vieja y recibe de vuelta el mismo pago.

#### Verificar la respuesta

La única defensa contra un campo ignorado en silencio es revisar la respuesta. Fijate en lo que está
**ausente** acá: cualquier recálculo de `service_fee_amount`. Las tasas que hay detrás son
configuración por comercio que puede cambiar; `service_fee_amount` y `total` de la respuesta son la
única fuente correcta.

```python
# app/checkout/verify.py
from __future__ import annotations

from decimal import Decimal
from urllib.parse import urlparse

from app.roki.models import CENT, Payment, PaymentCreateRequest, PaymentStatus


class PaymentVerificationError(RuntimeError):
    def __init__(self, payment_id: int, problems: list[str]) -> None:
        super().__init__(f"payment {payment_id} does not match the request: {'; '.join(problems)}")
        self.payment_id = payment_id
        self.problems = problems


def verify_created_payment(payment: Payment, request: PaymentCreateRequest) -> None:
    """Catch a silently ignored field before the customer ever sees a checkout page.

    The API answers 201 for a body it only partly understood, so an unverified response can mean a
    payment with no tax, no tip or no fee pass-through, and no error anywhere.
    """
    problems: list[str] = []

    if payment.status != PaymentStatus.PENDING.value:
        problems.append(f"status is {payment.status!r}, expected 'pending'")
    if payment.amount != request.amount:
        problems.append(f"amount echoed {payment.amount}, sent {request.amount}")
    if payment.subtotal != request.amount:
        problems.append(f"subtotal is {payment.subtotal}, expected {request.amount}")
    if payment.external_reference != request.external_reference:
        problems.append("external_reference was not echoed")
    if request.currency_code and payment.currency != request.currency_code:
        problems.append(f"currency is {payment.currency!r}, requested {request.currency_code!r}")
    if request.reusable is not None and payment.reusable != request.reusable:
        problems.append("reusable was ignored")

    wants_tax = request.sales_tax_type in ("fixed", "percentage")
    if wants_tax and payment.sales_tax_amount <= 0:
        problems.append("sales_tax_type/sales_tax_value ignored: sales_tax_amount is 0")
    if not wants_tax and payment.sales_tax_amount != 0:
        problems.append(f"unexpected sales_tax_amount {payment.sales_tax_amount}")

    if request.service_fee_enabled and payment.service_fee_amount <= 0:
        problems.append("service_fee_enabled ignored: service_fee_amount is 0")
    if not request.service_fee_enabled and payment.service_fee_amount != 0:
        problems.append(f"unexpected service_fee_amount {payment.service_fee_amount}")

    # total = subtotal + tax + service fee. A customer-selected tip is added later at checkout, and
    # a FIXED tip is folded into the total without a field of its own, so assert the identity only
    # when no fixed tip was requested.
    if not request.has_fixed_tip:
        expected_total = payment.subtotal + payment.sales_tax_amount + payment.service_fee_amount
        if abs(payment.total - expected_total) > CENT:
            problems.append(f"total {payment.total} != {expected_total} (subtotal + tax + fee)")

    if payment.total <= Decimal("0"):
        problems.append(f"total is {payment.total}")
    if urlparse(payment.checkout_url).scheme != "https":
        problems.append("checkout_url is not https")

    if problems:
        raise PaymentVerificationError(payment.id, problems)
```

#### El endpoint

```python
# app/checkout/routes.py
from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.checkout.models import LOCAL_CREATING, PaymentAttempt
from app.checkout.service import build_payment_request
from app.checkout.verify import PaymentVerificationError, verify_created_payment
from app.deps import get_roki_client, get_session
from app.orders.service import load_confirmed_order
from app.reconcile.state import apply_payment_state
from app.roki.client import RokiClient, build_idempotency_key
from app.roki.errors import RokiAuthError, RokiError, RokiValidationError
from app.roki.models import PaymentStatus

logger = logging.getLogger(__name__)
router = APIRouter()

FIRST_POLL_DELAY = timedelta(seconds=30)
SEE_OTHER = status.HTTP_303_SEE_OTHER


@router.post("/orders/{order_id}/checkout", status_code=SEE_OTHER)
async def start_checkout(
    order_id: int,
    session: AsyncSession = Depends(get_session),
    client: RokiClient = Depends(get_roki_client),
) -> RedirectResponse:
    order = await load_confirmed_order(session, order_id)

    latest = await session.scalar(
        select(PaymentAttempt)
        .where(PaymentAttempt.order_id == order_id)
        .order_by(PaymentAttempt.attempt.desc())
        .limit(1)
    )

    if latest is not None and latest.payment_id is not None:
        # Refresh before trusting a stored 'pending': the link may have expired, or been paid in a
        # tab we never heard about.
        current = await client.get_payment(latest.payment_id)
        apply_payment_state(latest, current)
        await session.commit()
        if current.is_open and latest.checkout_url:
            return RedirectResponse(latest.checkout_url, status_code=SEE_OTHER)
        if current.status != PaymentStatus.EXPIRED.value:
            raise HTTPException(409, f"Order {order_id} is already settled ({current.status})")
        latest = None  # expired: start a new attempt, which yields a new Idempotency-Key

    if latest is None:
        # New attempt. Committed BEFORE the network call so a crash mid-create leaves a row we can
        # replay: same attempt_started_at -> same body -> same key -> same payment.
        previous_count = await session.scalar(
            select(PaymentAttempt.attempt)
            .where(PaymentAttempt.order_id == order_id)
            .order_by(PaymentAttempt.attempt.desc())
            .limit(1)
        )
        latest = PaymentAttempt(
            order_id=order.id,
            attempt=(previous_count or 0) + 1,
            attempt_started_at=datetime.now(timezone.utc),
            external_reference=f"order-{order.id}",
            status=LOCAL_CREATING,
        )
        session.add(latest)
        await session.commit()

    request = build_payment_request(order, latest)
    idempotency_key = build_idempotency_key(request)
    latest.idempotency_key = idempotency_key
    await session.commit()

    try:
        payment = await client.create_payment(request, idempotency_key=idempotency_key)
    except RokiValidationError as exc:
        logger.error("roki rejected order %s on %s: %s", order_id, exc.fields, exc.message)
        raise HTTPException(502, "Payment could not be created") from exc
    except RokiAuthError as exc:
        logger.critical("roki credentials rejected: %s", exc.message)
        raise HTTPException(503, "Payment provider unavailable") from exc
    except RokiError as exc:
        logger.error("roki create failed for order %s: %s", order_id, exc)
        raise HTTPException(502, "Payment could not be created") from exc

    # Persist the id and status before verifying and before redirecting.
    latest.payment_id = payment.id
    latest.checkout_url = payment.checkout_url
    apply_payment_state(latest, payment)
    latest.next_poll_at = datetime.now(timezone.utc) + FIRST_POLL_DELAY
    await session.commit()

    try:
        verify_created_payment(payment, request)
    except PaymentVerificationError as exc:
        # Do NOT redirect: the customer would pay a total we did not intend. There is no cancel or
        # void endpoint, so flag the row and let the link expire on its own; a human closes the loop
        # from the merchant portal.
        latest.needs_review = True
        await session.commit()
        logger.critical("roki payment %s failed verification: %s", payment.id, exc.problems)
        raise HTTPException(502, "Payment could not be created") from exc

    logger.info(
        "order %s -> roki payment %s, total %s %s",
        order.id, payment.id, payment.total, payment.currency_iso,
    )
    return RedirectResponse(payment.checkout_url, status_code=SEE_OTHER)
```

#### La página de retorno no es una confirmación

```python
@router.get("/checkout/return")
async def checkout_return(
    order_id: int,
    session: AsyncSession = Depends(get_session),
    client: RokiClient = Depends(get_roki_client),
) -> dict[str, str]:
    """success_url landing. Anyone can open this URL, so confirm with getPayment before showing
    anything that resembles a receipt."""
    attempt = await session.scalar(
        select(PaymentAttempt)
        .where(PaymentAttempt.order_id == order_id, PaymentAttempt.payment_id.is_not(None))
        .order_by(PaymentAttempt.attempt.desc())
        .limit(1)
    )
    if attempt is None or attempt.payment_id is None:
        raise HTTPException(404, "Unknown order")

    payment = await client.get_payment(attempt.payment_id)
    apply_payment_state(attempt, payment)
    await session.commit()
    return {"status": payment.status, "total": str(payment.total)}
```

#### 4. Manejador del webhook

El HMAC cubre los bytes exactos que ROKI envió. Leé el cuerpo crudo con `await request.body()`
**antes** de cualquier parseo, y nunca lo re-serialices: un cuerpo recodificado difiere en el orden de
las llaves, en los espacios o en el escapado unicode, y la firma no va a coincidir. Por la misma razón
esta ruta recibe un `Request` pelado y no declara ningún parámetro de cuerpo de Pydantic - en el
momento en que FastAPI parsea y revalida un modelo de cuerpo, los bytes exactos dejan de ser lo que
estás verificando.

```python
# app/webhooks/signature.py
from __future__ import annotations

import hashlib
import hmac
import re
import time
from typing import Final

_HEX = re.compile(r"^[0-9a-fA-F]+$")

#: Client-side replay window. The signature format is documented; a tolerance is not, and whether a
#: redelivery is re-signed with a fresh timestamp is unverified. Disabled by default so a legitimate
#: redelivery is never rejected; replay protection comes from the event-id dedupe below.
DEFAULT_TOLERANCE_SECONDS: Final[int | None] = None


def parse_signature_header(header: str) -> tuple[str, str] | None:
    """Parse `t={unix_timestamp},v1={hmac_sha256_hex}`."""
    parts: dict[str, str] = {}
    for chunk in header.split(","):
        name, separator, value = chunk.strip().partition("=")
        if separator:
            parts[name.strip()] = value.strip()
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return None
    return timestamp, signature


def verify_signature(
    *,
    raw_body: bytes,
    header: str | None,
    secret: str,
    tolerance_seconds: int | None = DEFAULT_TOLERANCE_SECONDS,
) -> bool:
    """HMAC-SHA256(timestamp + "." + raw_body, signing_secret), compared in constant time."""
    if not header or not secret:
        return False
    parsed = parse_signature_header(header)
    if parsed is None:
        return False
    timestamp, received = parsed
    if not _HEX.match(received):
        # compare_digest raises on non-ASCII str input, so reject anything that is not hex first.
        return False
    if tolerance_seconds is not None:
        try:
            sent_at = int(timestamp)
        except ValueError:
            return False
        if abs(time.time() - sent_at) > tolerance_seconds:
            return False

    # The timestamp goes into the MAC exactly as it arrived: no int() round-trip, no reformatting.
    # Same for the body: the raw bytes, untouched.
    signed_payload = timestamp.encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received.lower())
```

```python
# app/webhooks/models.py
from __future__ import annotations

from datetime import datetime, timezone
from decimal import Decimal
from enum import StrEnum
from typing import Final

from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column

from app.roki.config import Base
from app.roki.models import Payment


class WebhookEventType(StrEnum):
    APPROVED = "payment.approved"
    FAILED = "payment.failed"
    EXPIRED = "payment.expired"
    VOIDED = "payment.voided"
    REFUNDED = "payment.refunded"
    PARTIALLY_REFUNDED = "payment.partially_refunded"


#: Emitted even when a human performs the action in the merchant portal, which today is the only way
#: to void or refund. This is the reconciliation channel for those operations.
PORTAL_ORIGINATED: Final[frozenset[str]] = frozenset(
    {
        WebhookEventType.VOIDED.value,
        WebhookEventType.REFUNDED.value,
        WebhookEventType.PARTIALLY_REFUNDED.value,
    }
)


class WebhookPaymentData(Payment):
    """The payment object, plus the three fields refund events carry on top of it."""

    refund_amount: Decimal | None = None
    refunded_at: str | None = None
    refund_reason: str | None = None


class WebhookEvent(BaseModel):
    model_config = ConfigDict(extra="allow")

    id: str  # unique per event; the dedupe key, because an event can be redelivered
    type: str
    created_at: str
    data: WebhookPaymentData


class ProcessedEvent(Base):
    __tablename__ = "roki_webhook_events"

    event_id: Mapped[str] = mapped_column(String(191), primary_key=True)
    event_type: Mapped[str] = mapped_column(String(64))
    payment_id: Mapped[int] = mapped_column(BigInteger, index=True)
    received_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
```

```python
# app/webhooks/routes.py
from __future__ import annotations

import logging

from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
from fastapi.responses import JSONResponse
from pydantic import ValidationError
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession

from app.deps import get_credential_store, get_session
from app.roki.config import CredentialStore
from app.webhooks.models import ProcessedEvent, WebhookEvent
from app.webhooks.processing import process_event
from app.webhooks.signature import verify_signature

logger = logging.getLogger(__name__)
router = APIRouter()


@router.post("/webhooks/roki", include_in_schema=False)
async def roki_webhook(
    request: Request,
    background: BackgroundTasks,
    session: AsyncSession = Depends(get_session),
    credentials: CredentialStore = Depends(get_credential_store),
) -> Response:
    # 1. RAW BYTES FIRST. The HMAC covers exactly these bytes; parsing and re-encoding breaks it.
    raw_body: bytes = await request.body()
    signature_header = request.headers.get("ROKI-Signature")

    # 2. Verify before trusting a single byte of the payload.
    secret = (await credentials.get()).webhook_signing_secret
    if not verify_signature(raw_body=raw_body, header=signature_header, secret=secret):
        logger.warning("rejected roki webhook: invalid ROKI-Signature")
        return JSONResponse({"detail": "invalid signature"}, status_code=400)

    # 3. Only now parse.
    try:
        event = WebhookEvent.model_validate_json(raw_body)
    except ValidationError as exc:
        logger.error("signed roki webhook failed to parse: %s errors", exc.error_count())
        return JSONResponse({"detail": "malformed event"}, status_code=400)

    # 4. Dedupe on the event id, committed before the 200 goes out, so a redelivery racing this
    #    request cannot start a second processing run.
    statement = (
        insert(ProcessedEvent)
        .values(event_id=event.id, event_type=event.type, payment_id=event.data.id)
        .on_conflict_do_nothing(index_elements=["event_id"])
    )
    result = await session.execute(statement)
    await session.commit()

    if result.rowcount == 0:
        logger.info("roki event %s already seen; ignoring redelivery", event.id)
        return Response(status_code=200)

    # 5. Answer fast, do the work afterwards.
    background.add_task(process_event, event)
    return Response(status_code=200)
```

`on_conflict_do_nothing` es del dialecto de PostgreSQL. En MySQL usá
`sqlalchemy.dialects.mysql.insert(...).prefix_with("IGNORE")`; en SQLite, el dialecto sqlite tiene el
mismo `on_conflict_do_nothing`. Lo que importa es que el insert haga commit antes del 200 y que
`rowcount` te diga si esta entrega es la primera.

```python
# app/webhooks/processing.py
from __future__ import annotations

import logging
from datetime import datetime
from zoneinfo import ZoneInfo

from sqlalchemy import select

from app.checkout.models import PaymentAttempt
from app.db import session_factory
from app.orders.service import (
    close_abandoned_checkout,
    fulfil_order,
    note_failed_attempt,
    reverse_fulfilment,
)
from app.reconcile.state import apply_payment_state
from app.webhooks.models import PORTAL_ORIGINATED, WebhookEvent, WebhookEventType

logger = logging.getLogger(__name__)
HONDURAS = ZoneInfo("America/Tegucigalpa")


def parse_api_timestamp(value: str | None) -> datetime | None:
    """API timestamps arrive as 'YYYY-MM-DD HH:MM:SS'.

    The spec pins expires_at to Honduras time (UTC-6); the others arrive in the same naive shape and
    are read the same way. The original strings are stored verbatim too, so nothing is lost if that
    assumption ever needs revisiting.
    """
    if not value:
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    return parsed.replace(tzinfo=HONDURAS) if parsed.tzinfo is None else parsed


async def process_event(event: WebhookEvent) -> None:
    """Runs after the 200 has been sent, with its own session: the request's is already closed."""
    async with session_factory() as session:
        attempt = await session.scalar(
            select(PaymentAttempt).where(PaymentAttempt.payment_id == event.data.id)
        )
        if attempt is None:
            # A payment created by another system, or a row we lost. Do not guess: alert.
            logger.error(
                "roki event %s references unknown payment %s (external_reference=%s)",
                event.id, event.data.id, event.data.external_reference,
            )
            return

        applied = apply_payment_state(
            attempt, event.data, event_at=parse_api_timestamp(event.created_at)
        )
        if not applied:
            await session.commit()
            return

        # The business action comes from the event type; the payment state comes from data.status.
        if event.type == WebhookEventType.APPROVED.value:
            await fulfil_order(session, attempt.order_id, payment=event.data)
        elif event.type == WebhookEventType.FAILED.value:
            # The card was declined. The link is not necessarily dead: the customer can try again
            # until it expires, so leave the order awaiting payment.
            await note_failed_attempt(session, attempt.order_id)
        elif event.type == WebhookEventType.EXPIRED.value:
            await close_abandoned_checkout(session, attempt.order_id)
        elif event.type in PORTAL_ORIGINATED:
            # Void, full refund or partial refund, normally initiated by a human in the portal.
            await reverse_fulfilment(
                session,
                attempt.order_id,
                refund_amount=event.data.refund_amount,
                refund_reason=event.data.refund_reason,
                refunded_at=event.data.refunded_at,
                refund_status=event.data.refund_status,
            )
        else:
            logger.warning("unhandled roki event type %r", event.type)

        await session.commit()
```

Un solo lugar aplica localmente un estado de pago de ROKI, compartido por el webhook y el proceso de consulta periódica:

```python
# app/reconcile/state.py
from __future__ import annotations

import logging
from datetime import datetime

from app.checkout.models import PaymentAttempt
from app.roki.models import OPEN_STATUSES, Payment

logger = logging.getLogger(__name__)


def apply_payment_state(
    attempt: PaymentAttempt,
    payment: Payment,
    *,
    event_at: datetime | None = None,
) -> bool:
    """Copy the authoritative payment state onto the local row. Returns False if ignored as stale."""
    if event_at is not None and attempt.last_event_at is not None and event_at < attempt.last_event_at:
        # Events can arrive out of order. Never let a late payment.approved overwrite a refund.
        logger.info("ignoring stale roki event for payment %s", payment.id)
        return False

    attempt.status = payment.status
    attempt.total = payment.total
    attempt.currency_iso = payment.currency_iso
    attempt.transaction_id = payment.transaction_id
    attempt.paid_at = payment.paid_at
    attempt.expires_at = payment.expires_at
    attempt.refunded_amount = payment.refunded_amount
    attempt.refund_status = payment.refund_status
    if event_at is not None:
        attempt.last_event_at = event_at
    if payment.status not in OPEN_STATUSES:
        attempt.next_poll_at = None  # settled: stop the polling fallback
    return True
```

Para una orden de alto valor, releer el pago antes de despachar cuesta una llamada y elimina cualquier
duda sobre lo que realmente se le cobró al cliente:

```python
if event.type == WebhookEventType.APPROVED.value:
    confirmed = await client.get_payment(event.data.id)
    if confirmed.status != PaymentStatus.PAID.value:
        logger.error("payment %s reported approved but reads %s", confirmed.id, confirmed.status)
        return
    await fulfil_order(session, attempt.order_id, payment=confirmed)
```

#### 5. Respaldo con consulta periódica

Los webhooks se pierden: un despliegue reinicia el proceso a media petición, un proxy se queda sin
tiempo, el DNS falla un momento, o una firma se rechaza porque un secreto se rotó en el orden
equivocado. Por eso cada pago que queda en `pending` lleva un `next_poll_at`, y un barredor recorre
los que ya toca revisar con un backoff que se va ampliando. Llama a `getPayment` y a nada más - no hay
nada más que llamar.

```python
# app/reconcile/poller.py
from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Final

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker

from app.checkout.models import PaymentAttempt
from app.orders.service import fulfil_order
from app.reconcile.state import apply_payment_state
from app.roki.client import RokiClient
from app.roki.errors import RokiAuthError, RokiPaymentNotFoundError, RokiTransportError
from app.roki.models import OPEN_STATUSES, PaymentStatus

logger = logging.getLogger(__name__)

#: Seconds to wait after each attempt: dense while the customer is still on the payment page,
#: sparse afterwards.
BACKOFF_SECONDS: Final[tuple[int, ...]] = (15, 30, 60, 120, 300, 600, 900, 1800, 3600)
GIVE_UP_AFTER: Final[timedelta] = timedelta(hours=26)  # past any 2h link expiry, with slack
SWEEP_INTERVAL_SECONDS: Final[float] = 10.0
BATCH_SIZE: Final[int] = 50


def _next_delay(attempts: int) -> timedelta:
    return timedelta(seconds=BACKOFF_SECONDS[min(attempts, len(BACKOFF_SECONDS) - 1)])


async def poll_attempt(session: AsyncSession, client: RokiClient, row: PaymentAttempt) -> None:
    if row.payment_id is None:
        return
    now = datetime.now(timezone.utc)
    try:
        payment = await client.get_payment(row.payment_id)
    except RokiTransportError as exc:
        logger.warning("poll of payment %s failed: %s", row.payment_id, exc)
        row.poll_attempts += 1
        row.next_poll_at = now + _next_delay(row.poll_attempts)
        return
    except RokiAuthError as exc:
        # Do not burn the whole batch against a bad key: back off hard and alert.
        logger.critical("polling paused, roki credentials rejected: %s", exc.message)
        row.next_poll_at = now + timedelta(minutes=15)
        return
    except RokiPaymentNotFoundError:
        # The route exists but this id is invisible to the current key, which in practice means the
        # key now belongs to the other environment (sk_test_ vs sk_live_). Polling cannot recover.
        logger.critical(
            "payment %s not visible to the configured key; check the environment", row.payment_id
        )
        row.needs_review = True
        row.next_poll_at = None
        return

    was_open = row.status in OPEN_STATUSES
    apply_payment_state(row, payment)

    if payment.status == PaymentStatus.PAID.value and was_open:
        # The webhook never landed. fulfil_order must be idempotent anyway: this can also race a
        # webhook that is being processed right now.
        logger.warning("payment %s settled via polling; no webhook seen", payment.id)
        await fulfil_order(session, row.order_id, payment=payment)
        return

    if payment.status in OPEN_STATUSES:
        row.poll_attempts += 1
        row.next_poll_at = now + _next_delay(row.poll_attempts)


async def sweep_once(session_factory: async_sessionmaker[AsyncSession], client: RokiClient) -> None:
    now = datetime.now(timezone.utc)
    async with session_factory() as session:
        due = await session.scalars(
            select(PaymentAttempt)
            .where(
                PaymentAttempt.status.in_(OPEN_STATUSES),
                PaymentAttempt.payment_id.is_not(None),
                PaymentAttempt.next_poll_at.is_not(None),
                PaymentAttempt.next_poll_at <= now,
            )
            .order_by(PaymentAttempt.next_poll_at)
            .limit(BATCH_SIZE)
            .with_for_update(skip_locked=True)  # safe with more than one sweeper running
        )
        for row in due:
            if now - row.attempt_started_at > GIVE_UP_AFTER:
                # Long past expiry and still pending: stop spending calls, hand it to a human.
                row.next_poll_at = None
                row.needs_review = True
                continue
            await poll_attempt(session, client, row)
        await session.commit()


async def run_poller(
    session_factory: async_sessionmaker[AsyncSession],
    client: RokiClient,
    stop: asyncio.Event,
) -> None:
    while not stop.is_set():
        try:
            await sweep_once(session_factory, client)
        except Exception:  # a failed sweep must never kill the loop
            logger.exception("roki poll sweep failed")
        try:
            await asyncio.wait_for(stop.wait(), timeout=SWEEP_INTERVAL_SECONDS)
        except asyncio.TimeoutError:
            continue
```

Arrancalo desde el lifespan junto con el cliente HTTP compartido. Cada proceso worker corre su propia
copia: `with_for_update(skip_locked=True)` mantiene eso correcto, pero un worker dedicado o una cola
de trabajos (arq, Celery beat) es más limpio que N workers web barriendo todos a la vez.

```python
# app/main.py
from __future__ import annotations

import asyncio
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI

from app.checkout.routes import router as checkout_router
from app.db import session_factory
from app.reconcile.poller import run_poller
from app.roki.client import BASE_URL, RokiClient
from app.roki.config import CredentialStore
from app.webhooks.routes import router as webhook_router


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    stop = asyncio.Event()
    credentials = CredentialStore(session_factory)
    async with httpx.AsyncClient(
        base_url=BASE_URL,
        limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
        headers={"User-Agent": "acme-store/1.0 (+https://yoursite.com)"},
    ) as http:
        client = RokiClient(http, credentials)
        app.state.roki = client
        app.state.roki_credentials = credentials
        poller = asyncio.create_task(run_poller(session_factory, client, stop))
        try:
            yield
        finally:
            stop.set()
            await poller


app = FastAPI(lifespan=lifespan)
app.include_router(checkout_router)
app.include_router(webhook_router)
```

---

### PHP 7.4+ puro con cURL (sin framework, sin Composer)

Objetivo: PHP 7.4 o más nuevo con las extensiones `curl`, `json` y `pdo_mysql`. Sin Composer, sin
librerías externas - solo cURL y funciones nativas de PHP. Cada archivo de abajo es autónomo y se puede
meter en un sitio hecho a mano o en un CMS viejo.

Los montos son **unidades decimales** (`150.50` = L 150.50), nunca centavos. La moneda es HNL, ISO numérico `"340"`.

Endpoints usados, y los únicos que existen:

| Llamada | Ruta |
|---|---|
| Crear un pago | `POST https://aura.roki.systems/api/connect/v1/payments` |
| Obtener un pago | `GET  https://aura.roki.systems/api/connect/v1/payments/{id}` |

La anulación, el reembolso y los recibos se identifican con el UUID de la transacción. No hay endpoint
de listado. Las reversiones también las hace una persona en el portal del comercio, y tu integración se
entera de ellas por medio de los webhooks.

---

#### 1. Configuración: las llaves en una tabla de configuración, nunca en el código

Dejar `sk_live_...` fijo dentro de un archivo PHP significa que una rotación de llave necesita un
redespliegue, y que la llave termina en los respaldos, en el administrador de archivos del CMS y, tarde
o temprano, en un repositorio público. Guardá los dos secretos en una tabla de configuración para que
rotarlos sea un solo `UPDATE`.

```sql
CREATE TABLE roki_settings (
  name       VARCHAR(64)  NOT NULL PRIMARY KEY,
  value      TEXT         NOT NULL,
  updated_at DATETIME     NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO roki_settings (name, value, updated_at) VALUES
  ('roki_secret_key',     'sk_test_replace_me', NOW()),
  ('roki_webhook_secret', 'replace_me',         NOW());
```

```php
<?php
// roki/RokiConfig.php
declare(strict_types=1);

/**
 * Runtime configuration for ROKI Connect.
 *
 * The base URL is a constant on purpose: there is one server, and the environment is selected
 * exclusively by the key prefix (sk_test_ = sandbox, sk_live_ = production) over identical routes.
 * Only the secrets are runtime-configurable.
 */
final class RokiConfig
{
    const BASE_URL = 'https://aura.roki.systems/api/connect/v1';

    /** @var PDO */
    private $db;
    /** @var array<string,string> */
    private $cache = array();

    public function __construct(PDO $db)
    {
        $this->db = $db;
    }

    /** Merchant secret key. Server-side only: never render it in a page or a mobile app. */
    public function secretKey()
    {
        return $this->get('roki_secret_key');
    }

    /** Webhook signing secret. Separate value per environment - rotate it together with the key. */
    public function webhookSecret()
    {
        return $this->get('roki_webhook_secret');
    }

    /** 'live' or 'test', derived from the key prefix. Used for logging and for sanity checks. */
    public function environment()
    {
        return strpos($this->secretKey(), 'sk_live_') === 0 ? 'live' : 'test';
    }

    private function get($name)
    {
        if (isset($this->cache[$name])) {
            return $this->cache[$name];
        }
        $stmt = $this->db->prepare('SELECT value FROM roki_settings WHERE name = ?');
        $stmt->execute(array($name));
        $value = $stmt->fetchColumn();
        if ($value === false || trim((string) $value) === '') {
            throw new RuntimeException("ROKI setting '" . $name . "' is missing or empty.");
        }
        $this->cache[$name] = trim((string) $value);

        return $this->cache[$name];
    }
}
```

```php
<?php
// roki/bootstrap.php - shared wiring, included by every entry point below.
declare(strict_types=1);

require __DIR__ . '/RokiConfig.php';
require __DIR__ . '/RokiApiException.php';
require __DIR__ . '/RokiClient.php';
require __DIR__ . '/payments.php';

function roki_db()
{
    static $db = null;
    if ($db === null) {
        $db = new PDO(
            'mysql:host=localhost;dbname=shop;charset=utf8mb4',
            'shop_user',
            getenv('SHOP_DB_PASSWORD'),
            array(
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            )
        );
    }

    return $db;
}

function roki_client()
{
    static $client = null;
    if ($client === null) {
        // Accept-Language 'en' keeps validation messages in English for your logs.
        $client = new RokiClient(new RokiConfig(roki_db()), 'en');
    }

    return $client;
}

/** Never let a secret reach a log file. */
function roki_log($message, array $context = array())
{
    $line = '[roki] ' . $message;
    if ($context) {
        $line .= ' ' . json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
    }
    error_log(preg_replace('/sk_(test|live)_[A-Za-z0-9]+/', 'sk_$1_[redacted]', $line));
}
```

Procedimiento de rotación: `UPDATE roki_settings SET value = ?, updated_at = NOW() WHERE name = ?`. La
siguiente petición ya la toma. Si cambiás entre `sk_test_` y `sk_live_`, tenés que actualizar
`roki_webhook_secret` en la misma transacción - el secreto de firma y la URL del webhook son por
ambiente, y una llave de producción con un secreto de firma de sandbox rechaza todos los eventos con un
400.

---

#### 2. El cliente de la API

Tres cosas que este cliente hace bien y que la mayoría de los escritos a mano no:

- **Timeouts.** Un checkout que se queda colgado en un socket muerto bloquea un worker de PHP-FPM. Un
  timeout total más un timeout de conexión es obligatorio.
- **La llave de idempotencia se deriva del contenido de la orden, no de su id.** Comportamiento
  verificado: repetir una llave con un cuerpo *distinto* devuelve el pago original **sin ningún error**.
  Si tu llave fuera `order-1001` y el cliente editara el carrito, el reintento reutilizaría en silencio
  el monto viejo y cobraría el total equivocado. Hashear el payload hace que una orden cambiada produzca
  una llave distinta.
- **`errors` se lee campo por campo.** La cadena `message` está localizada y solo trae el primer error;
  las **llaves** de `errors` son estables y son sobre las que tu lógica ramifica.

```php
<?php
// roki/RokiApiException.php
declare(strict_types=1);

final class RokiApiException extends RuntimeException
{
    /** @var int HTTP status; 0 for a transport-level failure. */
    private $status;
    /** @var array<string,string[]> Per-field errors from a 422. */
    private $errors;
    /** @var string Raw response body, for logging. */
    private $body;

    public function __construct($message, $status = 0, array $errors = array(), $body = '')
    {
        parent::__construct($message, $status);
        $this->status = (int) $status;
        $this->errors = $errors;
        $this->body   = (string) $body;
    }

    public function status()
    {
        return $this->status;
    }

    /** @return array<string,string[]> Field name => list of messages. Empty unless status is 422. */
    public function errors()
    {
        return $this->errors;
    }

    public function body()
    {
        return $this->body;
    }

    /** First message recorded against a specific request field, or null. */
    public function errorFor($field)
    {
        return isset($this->errors[$field][0]) ? $this->errors[$field][0] : null;
    }

    /**
     * True when the 404 means "this path does not exist in the API" rather than
     * "no payment with that id". Routing errors always arrive in English and ignore Accept-Language.
     * A routing 404 on /payments/{id} means your base URL is wrong.
     */
    public function isRoutingError()
    {
        return $this->status === 404 && stripos($this->getMessage(), 'could not be found') !== false;
    }

    /** True when the payment id does not exist for the key in use (wrong id, or wrong environment). */
    public function isPaymentNotFound()
    {
        return $this->status === 404 && !$this->isRoutingError();
    }
}
```

```php
<?php
// roki/RokiClient.php
declare(strict_types=1);

final class RokiClient
{
    /** Whole-request timeout, seconds. A checkout must not hang a PHP worker. */
    const TIMEOUT = 20;
    /** TCP/TLS connect timeout, seconds. */
    const CONNECT_TIMEOUT = 5;
    /** Attempts for transport failures and 5xx. Safe for POST because of the Idempotency-Key. */
    const MAX_ATTEMPTS = 3;

    /** @var RokiConfig */
    private $config;
    /** @var string 'es' or 'en' */
    private $language;

    public function __construct(RokiConfig $config, $language = 'en')
    {
        $this->config   = $config;
        $this->language = ($language === 'es') ? 'es' : 'en';
    }

    /**
     * POST /payments
     *
     * @param array  $payload        Body built with roki_build_payload().
     * @param string $idempotencyKey Result of RokiClient::idempotencyKey($payload).
     * @return array The Payment object.
     * @throws RokiApiException
     */
    public function createPayment(array $payload, $idempotencyKey)
    {
        return $this->request(
            'POST',
            '/payments',
            array(201, 200), // documented 201; an idempotent replay is accepted either way
            $payload,
            array('Idempotency-Key: ' . $idempotencyKey)
        );
    }

    /**
     * GET /payments/{id} - the source of truth for whether a payment was charged.
     *
     * @param int $id
     * @return array The Payment object.
     * @throws RokiApiException
     */
    public function getPayment($id)
    {
        $id = (int) $id;
        if ($id < 1) {
            throw new InvalidArgumentException('Payment id must be a positive integer.');
        }

        return $this->request('GET', '/payments/' . $id, array(200));
    }

    /**
     * Idempotency key derived from the ORDER CONTENT.
     *
     * Verified caveat: replaying a key with a different body returns the ORIGINAL payment and no
     * error - the key wins and the body is ignored. Keying on the order id alone would therefore
     * charge a stale amount after the customer edits the cart. Hashing the canonical payload means
     * any change to amount, tax, tip or fee configuration yields a different key.
     *
     * Max length is 191; this produces at most 170 characters.
     */
    public static function idempotencyKey(array $payload)
    {
        $canonical = self::canonicalize($payload);
        $json      = json_encode($canonical, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        $reference = isset($payload['external_reference']) ? (string) $payload['external_reference'] : 'noref';
        $reference = substr(preg_replace('/[^A-Za-z0-9_.-]/', '', $reference), 0, 100);

        return 'roki-' . $reference . '-' . hash('sha256', (string) $json);
    }

    /** Recursively sort keys so equivalent payloads always hash identically. */
    private static function canonicalize($value)
    {
        if (!is_array($value)) {
            return $value;
        }
        $out = array();
        foreach ($value as $k => $v) {
            $out[$k] = self::canonicalize($v);
        }
        if (array_keys($out) !== range(0, count($out) - 1)) {
            ksort($out);
        }

        return $out;
    }

    /**
     * @param string     $method
     * @param string     $path
     * @param int[]      $acceptStatuses
     * @param array|null $body
     * @param string[]   $extraHeaders
     * @return array Decoded JSON object.
     * @throws RokiApiException
     */
    private function request($method, $path, array $acceptStatuses, array $body = null, array $extraHeaders = array())
    {
        $headers = array_merge(
            array(
                'Authorization: Bearer ' . $this->config->secretKey(),
                'Accept: application/json',
                'Accept-Language: ' . $this->language,
                'Expect:', // stop cURL from doing a 100-continue round trip on larger bodies
            ),
            $extraHeaders
        );

        $encodedBody = null;
        if ($body !== null) {
            $encodedBody = json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            if ($encodedBody === false) {
                throw new RokiApiException('Could not encode the request body as JSON.');
            }
            $headers[] = 'Content-Type: application/json';
        }

        $lastTransportError = '';

        for ($attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++) {
            $ch = curl_init(RokiConfig::BASE_URL . $path);
            curl_setopt_array($ch, array(
                CURLOPT_CUSTOMREQUEST  => $method,
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_HEADER         => false,
                CURLOPT_FOLLOWLOCATION => false,
                CURLOPT_TIMEOUT        => self::TIMEOUT,
                CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_HTTPHEADER     => $headers,
                CURLOPT_USERAGENT      => 'roki-php-curl/1.0',
            ));
            if ($encodedBody !== null) {
                curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedBody);
            }

            $raw    = curl_exec($ch);
            $errno  = curl_errno($ch);
            $error  = curl_error($ch);
            $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
            curl_close($ch);

            // Transport failure: DNS, TLS, connect or read timeout. Retry with the SAME key.
            if ($raw === false || $errno !== 0) {
                $lastTransportError = 'cURL error ' . $errno . ': ' . $error;
                roki_log('transport failure', array('attempt' => $attempt, 'error' => $lastTransportError));
                if ($attempt < self::MAX_ATTEMPTS) {
                    usleep(250000 * $attempt); // 0.25s, 0.50s
                    continue;
                }
                throw new RokiApiException($lastTransportError, 0);
            }

            // Server-side hiccup: retry. Never retry a 4xx - the request itself is wrong.
            if ($status >= 500 || $status === 429) {
                $lastTransportError = 'HTTP ' . $status;
                if ($attempt < self::MAX_ATTEMPTS) {
                    usleep(500000 * $attempt);
                    continue;
                }
            }

            $decoded = json_decode((string) $raw, true);
            if (!is_array($decoded)) {
                throw new RokiApiException('Non-JSON response from the API.', $status, array(), (string) $raw);
            }

            if (in_array($status, $acceptStatuses, true)) {
                return $decoded;
            }

            $this->throwForStatus($status, $decoded, (string) $raw);
        }

        throw new RokiApiException('Request failed after ' . self::MAX_ATTEMPTS . ' attempts: ' . $lastTransportError, 0);
    }

    /** @throws RokiApiException always */
    private function throwForStatus($status, array $decoded, $raw)
    {
        $message = isset($decoded['message']) ? (string) $decoded['message'] : 'Unexpected HTTP ' . $status;
        $errors  = array();

        if (isset($decoded['errors']) && is_array($decoded['errors'])) {
            foreach ($decoded['errors'] as $field => $messages) {
                $errors[(string) $field] = is_array($messages) ? array_values($messages) : array((string) $messages);
            }
        }

        // 401 - header missing or key invalid. The two cases carry distinct messages, which is worth
        // logging: "header missing" points at your code, "invalid key" points at roki_settings.
        // 404 - either the payment does not exist for this key, or the path does not exist (see
        //       RokiApiException::isRoutingError). Both are surfaced through the exception.
        // 422 - validation or a business rule (currency not enabled on the terminal, tip_max_amount
        //       above amount, sales tax percentage over 100, expires_at in the past). Branch on the
        //       keys of $errors, never on the localized text.
        roki_log('api error', array('status' => $status, 'message' => $message, 'fields' => array_keys($errors)));

        throw new RokiApiException($message, $status, $errors, $raw);
    }
}
```

**No reintentés una creación con una llave recién generada después de un timeout.** Así es como terminás con dos
enlaces de pago activos para una sola orden. Reintentá con la llave que ya calculaste: para eso sirve
exactamente.

---

#### 3. Flujo de checkout

Creá el pago **del lado del servidor, cuando la orden se confirma**, persistí los identificadores antes de
redirigir, verificá los campos calculados, y después mandá al cliente a `checkout_url`.

```sql
CREATE TABLE shop_payments (
  order_id           INT UNSIGNED    NOT NULL PRIMARY KEY,
  roki_payment_id    BIGINT UNSIGNED NULL,
  external_reference VARCHAR(191)    NOT NULL,
  idempotency_key    VARCHAR(191)    NOT NULL,
  status             VARCHAR(32)     NOT NULL DEFAULT 'pending',
  amount             DECIMAL(12,2)   NOT NULL,
  sales_tax_amount   DECIMAL(12,2)   NULL,
  service_fee_amount DECIMAL(12,2)   NULL,
  total              DECIMAL(12,2)   NULL,
  currency_iso       CHAR(3)         NULL,
  checkout_url       VARCHAR(255)    NULL,
  transaction_id     CHAR(36)        NULL,   -- UUID, never an integer
  refunded_amount    DECIMAL(12,2)   NULL,
  refund_status      VARCHAR(16)     NULL,
  paid_at            DATETIME        NULL,
  expires_at         DATETIME        NULL,
  poll_attempts      SMALLINT UNSIGNED NOT NULL DEFAULT 0,
  next_poll_at       DATETIME        NULL,
  created_at         DATETIME        NOT NULL,
  updated_at         DATETIME        NOT NULL,
  UNIQUE KEY uq_roki_payment_id (roki_payment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

```php
<?php
// roki/payments.php - payload building, verification and the shared state machine.
declare(strict_types=1);

/** Money as a fixed 2-decimal string: the API accepts numeric strings and this avoids float drift. */
function roki_money($value)
{
    return number_format(round((float) $value, 2), 2, '.', '');
}

function roki_money_equals($a, $b)
{
    return abs((float) $a - (float) $b) < 0.005;
}

/**
 * Build the create-payment body.
 *
 * Only the field names below exist. The API SILENTLY IGNORES anything else and still returns 201,
 * so a typo produces a misconfigured payment rather than an error. Do not invent fields.
 */
function roki_build_payload(array $order)
{
    $expiresAt = new DateTime('now', new DateTimeZone('America/Tegucigalpa')); // Honduras, UTC-6
    $expiresAt->modify('+2 hours');

    $payload = array(
        'amount'             => roki_money($order['amount']),      // decimal units, NOT cents
        'currency_code'      => '340',                             // ISO 4217 numeric, HNL
        'external_reference' => 'order-' . $order['id'],           // NOT unique on the API side
        'name'               => substr('Order #' . $order['id'], 0, 191),
        'description'        => substr($order['description'], 0, 120),
        'metadata'           => array(
            // Returned untouched on retrieval and in every webhook - this is your reconciliation
            // channel. It is not shown to the customer and does not prefill the checkout form.
            'order_id'       => (string) $order['id'],
            'customer_email' => $order['customer_email'],
        ),
        'success_url'        => 'https://yoursite.com/return.php?order=' . $order['id'],
        'cancel_url'         => 'https://yoursite.com/cart.php',
        'expires_at'         => $expiresAt->format('Y-m-d H:i:s'),
        'reusable'           => false, // a one-off order link must die with the first payment
    );

    // Sales tax, computed by ROKI on the subtotal. Percentage cannot exceed 100.
    if (!empty($order['apply_sales_tax'])) {
        $payload['sales_tax_type']  = 'percentage';
        $payload['sales_tax_value'] = 15;
    }

    // Pass processing costs to the customer. The exact field name is service_fee_enabled; variants
    // such as service_fee or pass_fees_to_customer are ignored in silence and produce no fee.
    // Never replicate the fee formula - read service_fee_amount and total from the response.
    if (!empty($order['pass_fees_to_customer'])) {
        $payload['service_fee_enabled'] = true;
    }

    return $payload;
}

/**
 * Verify the response actually reflects what we asked for.
 *
 * This is the only defence against the silent-ignore behaviour: a misspelled field returns 201 with
 * the feature missing. Compare before you redirect a customer to a link that charges the wrong total.
 *
 * @return string[] Problems found; empty means the payment is safe to use.
 */
function roki_verify_payment(array $payment, array $sent)
{
    $problems = array();

    foreach (array('id', 'status', 'checkout_url', 'subtotal', 'total', 'currency_iso') as $field) {
        if (!isset($payment[$field])) {
            $problems[] = 'response is missing ' . $field;
        }
    }
    if ($problems) {
        return $problems;
    }

    if (!roki_money_equals($payment['amount'], $sent['amount'])) {
        $problems[] = 'amount mismatch: sent ' . $sent['amount'] . ', got ' . $payment['amount'];
    }
    if ((string) $payment['external_reference'] !== (string) $sent['external_reference']) {
        $problems[] = 'external_reference mismatch';
    }
    if (isset($sent['currency_code']) && (string) $payment['currency'] !== (string) $sent['currency_code']) {
        $problems[] = 'currency mismatch: expected ' . $sent['currency_code'] . ', got ' . $payment['currency'];
    }
    if ($payment['status'] !== 'pending') {
        $problems[] = 'unexpected status on creation: ' . $payment['status'];
    }
    if (strpos((string) $payment['checkout_url'], 'https://') !== 0) {
        $problems[] = 'checkout_url is not https';
    }

    // Silent-ignore detectors: we asked for a feature, so its computed amount must be non-zero.
    if (!empty($sent['service_fee_enabled']) && (float) (isset($payment['service_fee_amount']) ? $payment['service_fee_amount'] : 0) <= 0.0) {
        $problems[] = 'service_fee_enabled was sent but service_fee_amount is 0 - field ignored?';
    }
    if (isset($sent['sales_tax_type']) && $sent['sales_tax_type'] !== 'none'
        && (float) (isset($payment['sales_tax_amount']) ? $payment['sales_tax_amount'] : 0) <= 0.0) {
        $problems[] = 'sales_tax_type was sent but sales_tax_amount is 0 - field ignored?';
    }

    // Internal consistency of the total. Skip it when a tip is configured: a fixed tip is included in
    // total but is not one of these three components. total is always the authoritative figure.
    if (empty($sent['tip_enabled'])) {
        $expected = (float) $payment['subtotal']
            + (float) (isset($payment['sales_tax_amount']) ? $payment['sales_tax_amount'] : 0)
            + (float) (isset($payment['service_fee_amount']) ? $payment['service_fee_amount'] : 0);
        if (!roki_money_equals($expected, $payment['total'])) {
            $problems[] = 'total ' . $payment['total'] . ' does not match subtotal + tax + fee (' . roki_money($expected) . ')';
        }
    }

    return $problems;
}

/** Terminal states stop all polling and all further status changes. */
function roki_is_terminal($status)
{
    return in_array($status, array('paid', 'partially_refunded', 'refunded', 'voided', 'expired', 'disabled'), true);
}

/** Monotonic ranking so an out-of-order redelivery cannot roll a refund back to paid. */
function roki_status_rank($status)
{
    $rank = array(
        'pending'            => 0,
        'expired'            => 1,
        'disabled'           => 1,
        'paid'               => 2,
        'partially_refunded' => 3,
        'refunded'           => 4,
        'voided'             => 4,
    );

    return isset($rank[$status]) ? $rank[$status] : -1;
}

/**
 * Apply a Payment object to local state. Shared by the webhook handler and the polling fallback,
 * so both paths converge on exactly the same result.
 *
 * @return string|null The new status if it changed, null otherwise.
 */
function roki_apply_payment_state(PDO $db, array $payment)
{
    if (!isset($payment['id'], $payment['status'])) {
        return null;
    }
    $newStatus = (string) $payment['status'];
    if (roki_status_rank($newStatus) < 0) {
        roki_log('unknown status, ignored', array('status' => $newStatus));

        return null;
    }

    $stmt = $db->prepare('SELECT order_id, status FROM shop_payments WHERE roki_payment_id = ? FOR UPDATE');
    $stmt->execute(array((int) $payment['id']));
    $row = $stmt->fetch();
    if (!$row) {
        roki_log('payment not found locally', array('roki_payment_id' => $payment['id']));

        return null;
    }
    if (roki_status_rank($newStatus) < roki_status_rank($row['status'])) {
        return null; // stale event, arrived out of order
    }

    $update = $db->prepare(
        'UPDATE shop_payments SET
            status = ?, total = ?, sales_tax_amount = ?, service_fee_amount = ?,
            currency_iso = ?, transaction_id = ?, refunded_amount = ?, refund_status = ?,
            paid_at = ?, updated_at = NOW()
         WHERE roki_payment_id = ?'
    );
    $update->execute(array(
        $newStatus,
        isset($payment['total']) ? roki_money($payment['total']) : null,
        isset($payment['sales_tax_amount']) ? roki_money($payment['sales_tax_amount']) : null,
        isset($payment['service_fee_amount']) ? roki_money($payment['service_fee_amount']) : null,
        isset($payment['currency_iso']) ? $payment['currency_iso'] : null,
        isset($payment['transaction_id']) && $payment['transaction_id'] !== null ? (int) $payment['transaction_id'] : null,
        isset($payment['refunded_amount']) ? roki_money($payment['refunded_amount']) : null,
        isset($payment['refund_status']) ? $payment['refund_status'] : null,
        isset($payment['paid_at']) ? $payment['paid_at'] : null,
        (int) $payment['id'],
    ));

    if ($row['status'] === $newStatus) {
        return null;
    }

    // Queue side effects (invoice, stock, notification email) for a cron worker so the webhook
    // response stays fast. Never send mail inline from the webhook endpoint.
    $job = $db->prepare('INSERT INTO shop_jobs (kind, order_id, payload, created_at) VALUES (?, ?, ?, NOW())');
    $job->execute(array('payment_status_changed', (int) $row['order_id'], json_encode(array(
        'from' => $row['status'],
        'to'   => $newStatus,
    ))));

    return $newStatus;
}
```

```php
<?php
// checkout.php - runs when the customer confirms the order.
declare(strict_types=1);
require __DIR__ . '/roki/bootstrap.php';

$order = load_confirmed_order((int) $_POST['order_id']); // your own code
$db    = roki_db();

// The API enforces no maximum amount - an 11-digit value is accepted. Enforce a sane ceiling here.
if ((float) $order['amount'] < 0.01 || (float) $order['amount'] > 500000.00) {
    render_error('The order total is outside the accepted range.');
    exit;
}

$payload        = roki_build_payload($order);
$idempotencyKey = RokiClient::idempotencyKey($payload);

// If we already created a link for this exact order content, reuse it instead of calling the API.
$stmt = $db->prepare('SELECT * FROM shop_payments WHERE order_id = ?');
$stmt->execute(array((int) $order['id']));
$existing = $stmt->fetch();

if ($existing && $existing['idempotency_key'] === $idempotencyKey
    && $existing['status'] === 'pending' && $existing['checkout_url']) {
    header('Location: ' . $existing['checkout_url'], true, 303);
    exit;
}

// Record the intent BEFORE the call, so a crash mid-request still leaves a trail to reconcile.
$db->prepare(
    'INSERT INTO shop_payments (order_id, external_reference, idempotency_key, status, amount, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, NOW(), NOW())
     ON DUPLICATE KEY UPDATE external_reference = VALUES(external_reference),
                             idempotency_key    = VALUES(idempotency_key),
                             amount             = VALUES(amount),
                             updated_at         = NOW()'
)->execute(array(
    (int) $order['id'],
    $payload['external_reference'],
    $idempotencyKey,
    'pending',
    roki_money($order['amount']),
));

try {
    $payment = roki_client()->createPayment($payload, $idempotencyKey);
} catch (RokiApiException $e) {
    if ($e->status() === 401) {
        // Missing header or invalid key: an operator problem, not a customer problem.
        roki_log('authentication failed', array('detail' => $e->getMessage()));
        render_error('Payments are temporarily unavailable. Please try again shortly.');
        exit;
    }
    if ($e->status() === 422) {
        // Branch on the stable keys of errors, never on the localized message text.
        foreach (array('amount', 'external_reference', 'name', 'currency_code', 'expires_at',
                       'sales_tax_value', 'tip_max_amount', 'metadata') as $field) {
            $detail = $e->errorFor($field);
            if ($detail !== null) {
                roki_log('validation error', array('field' => $field, 'detail' => $detail));
            }
        }
        render_error('We could not start the payment for this order.');
        exit;
    }
    if ($e->isRoutingError()) {
        roki_log('routing 404 - check the base URL, it must end in /api/connect/v1');
    }
    render_error('We could not reach the payment provider. Please try again.');
    exit;
}

// Verify BEFORE redirecting. A silently ignored field would otherwise charge the wrong total.
$problems = roki_verify_payment($payment, $payload);
if ($problems) {
    roki_log('payment verification failed', array('roki_payment_id' => $payment['id'], 'problems' => $problems));
    render_error('We could not start the payment for this order.');
    exit;
}

// Persist the authoritative figures. total and service_fee_amount come from the API - never recompute.
$db->prepare(
    'UPDATE shop_payments SET
        roki_payment_id = ?, status = ?, sales_tax_amount = ?, service_fee_amount = ?,
        total = ?, currency_iso = ?, checkout_url = ?, expires_at = ?,
        next_poll_at = DATE_ADD(NOW(), INTERVAL 5 MINUTE), updated_at = NOW()
     WHERE order_id = ?'
)->execute(array(
    (int) $payment['id'],
    (string) $payment['status'],
    roki_money(isset($payment['sales_tax_amount']) ? $payment['sales_tax_amount'] : 0),
    roki_money(isset($payment['service_fee_amount']) ? $payment['service_fee_amount'] : 0),
    roki_money($payment['total']),
    (string) $payment['currency_iso'],
    (string) $payment['checkout_url'],
    isset($payment['expires_at']) ? $payment['expires_at'] : null,
    (int) $order['id'],
));

header('Location: ' . $payment['checkout_url'], true, 303);
exit;
```

Mostrale al cliente `total`, no `amount`, cuando aplica una comisión de servicio o un impuesto: `total` es lo que se le cobra
a la tarjeta. `amount` es lo que le queda neto al comercio.

**Llegar a `success_url` no es una confirmación.** Cualquiera puede escribir esa URL. La página de retorno lee el estado
local, y solo llama a la API si el webhook todavía no ha llegado:

```php
<?php
// return.php - the success_url landing page.
declare(strict_types=1);
require __DIR__ . '/roki/bootstrap.php';

$stmt = roki_db()->prepare('SELECT * FROM shop_payments WHERE order_id = ?');
$stmt->execute(array((int) $_GET['order']));
$row = $stmt->fetch();

if ($row && $row['status'] === 'pending' && $row['roki_payment_id']) {
    try {
        $payment = roki_client()->getPayment((int) $row['roki_payment_id']);
        roki_apply_payment_state(roki_db(), $payment);
        $row['status'] = $payment['status'];
    } catch (RokiApiException $e) {
        roki_log('return page lookup failed', array('status' => $e->status()));
    }
}

if ($row && $row['status'] === 'paid') {
    render_thank_you($row);
} else {
    render_pending_notice(); // "We are confirming your payment" - never claim success here
}
```

---

#### 4. Manejador de webhooks

Reglas: verificá el HMAC sobre el cuerpo **crudo** antes de parsear nada, compará en tiempo constante, devolvé
**400** ante una firma inválida, devolvé **200** rápido ante una válida, y hacé que el procesamiento sea idempotente sobre
el `id` del evento, porque un evento puede reenviarse.

```sql
CREATE TABLE roki_webhook_events (
  event_id    VARCHAR(191)    NOT NULL PRIMARY KEY,
  type        VARCHAR(64)     NOT NULL,
  payment_id  BIGINT UNSIGNED NULL,
  payload     MEDIUMTEXT      NOT NULL,
  received_at DATETIME        NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

```php
<?php
// webhook.php - the URL registered at /merchant/connect/webhooks.
declare(strict_types=1);
require __DIR__ . '/roki/bootstrap.php';

/** Read a request header without any framework. Dashes become underscores in $_SERVER. */
function roki_header($name)
{
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    if (isset($_SERVER[$key])) {
        return (string) $_SERVER[$key];
    }
    if (function_exists('getallheaders')) {
        foreach (getallheaders() as $header => $value) {
            if (strcasecmp($header, $name) === 0) {
                return (string) $value;
            }
        }
    }

    return '';
}

/**
 * Verify ROKI-Signature: t={unix_timestamp},v1={hmac_sha256_hex}
 * over HMAC-SHA256(timestamp + "." + raw_body, signing_secret).
 *
 * $rawBody must be the untouched bytes. Do not trim it, do not re-encode a decoded array: any
 * change of a single byte invalidates the signature.
 */
function roki_verify_signature($rawBody, $signatureHeader, $secret, $toleranceSeconds = 300)
{
    $timestamp = null;
    $provided  = null;

    foreach (explode(',', $signatureHeader) as $part) {
        $part     = trim($part);
        $position = strpos($part, '=');
        if ($position === false) {
            continue;
        }
        $key   = substr($part, 0, $position);
        $value = substr($part, $position + 1);
        if ($key === 't') {
            $timestamp = $value;
        } elseif ($key === 'v1') {
            $provided = $value;
        }
    }

    if ($timestamp === null || $provided === null || !ctype_digit($timestamp) || !ctype_xdigit($provided)) {
        return false;
    }
    // Replay window (our own hardening, not an API requirement). Requires a correct server clock.
    if ($toleranceSeconds > 0 && abs(time() - (int) $timestamp) > $toleranceSeconds) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    return hash_equals($expected, strtolower($provided)); // constant time, length safe
}

/** Send the response and, where the SAPI allows it, release the connection before doing more work. */
function roki_respond($code, $body)
{
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Content-Length: ' . strlen($body));
    echo $body;
    if (function_exists('fastcgi_finish_request')) {
        fastcgi_finish_request();
    } elseif (function_exists('litespeed_finish_request')) {
        litespeed_finish_request();
    }
}

// ---------------------------------------------------------------------------

if (!isset($_SERVER['REQUEST_METHOD']) || $_SERVER['REQUEST_METHOD'] !== 'POST') {
    roki_respond(405, '{"error":"method_not_allowed"}');
    exit;
}

$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
    $rawBody = '';
}

$config = new RokiConfig(roki_db());
if (!roki_verify_signature($rawBody, roki_header('ROKI-Signature'), $config->webhookSecret())) {
    roki_log('webhook rejected: invalid signature');
    roki_respond(400, '{"error":"invalid_signature"}');
    exit;
}

// Only now is it safe to parse.
$event = json_decode($rawBody, true);
if (!is_array($event) || !isset($event['id'], $event['type'], $event['data'])
    || !is_array($event['data']) || !isset($event['data']['id'])) {
    roki_respond(400, '{"error":"malformed_event"}');
    exit;
}

$eventId   = (string) $event['id'];
$eventType = (string) $event['type'];
$payment   = $event['data']; // a full Payment object

$knownTypes = array(
    'payment.approved',
    'payment.failed',
    'payment.expired',
    'payment.voided',
    'payment.refunded',
    'payment.partially_refunded',
);
if (!in_array($eventType, $knownTypes, true)) {
    // Accept and ignore, so an unrecognised future type is not retried forever.
    roki_log('unknown event type, acknowledged', array('type' => $eventType));
    roki_respond(200, '{"received":true}');
    exit;
}

$db = roki_db();

// Idempotency keyed on the event id: the unique primary key is the lock. A redelivery of an event
// we already stored short-circuits here, so processing runs exactly once.
try {
    $insert = $db->prepare(
        'INSERT INTO roki_webhook_events (event_id, type, payment_id, payload, received_at)
         VALUES (?, ?, ?, ?, NOW())'
    );
    $insert->execute(array($eventId, $eventType, (int) $payment['id'], $rawBody));
} catch (PDOException $e) {
    if ($e->getCode() === '23000') { // duplicate primary key
        roki_respond(200, '{"received":true,"duplicate":true}');
        exit;
    }
    roki_log('could not record event', array('event_id' => $eventId));
    roki_respond(500, '{"error":"storage"}'); // 5xx so ROKI redelivers
    exit;
}

try {
    $db->beginTransaction();

    // payment.failed reports a declined attempt; 'failed' is NOT a payment status, so the link stays
    // pending and the customer can retry. Every other type carries the new state in data.status.
    if ($eventType !== 'payment.failed') {
        roki_apply_payment_state($db, $payment);
    } else {
        roki_log('payment attempt failed', array('roki_payment_id' => $payment['id']));
    }

    $db->commit();
} catch (Exception $e) {
    if ($db->inTransaction()) {
        $db->rollBack();
    }
    // Remove the dedupe row so the redelivery is actually reprocessed, then ask for a retry.
    $db->prepare('DELETE FROM roki_webhook_events WHERE event_id = ?')->execute(array($eventId));
    roki_log('event processing failed', array('event_id' => $eventId, 'error' => $e->getMessage()));
    roki_respond(500, '{"error":"processing"}');
    exit;
}

roki_respond(200, '{"received":true}');
```

Notas que deciden si esto funciona en producción:

- **El endpoint tiene que ser alcanzable sin sesión, sin token CSRF, sin muro de login y sin un redirect de "En
  construcción".** ROKI hace POST de máquina a máquina.
- Registrá una URL y un secreto de firma **por cada ambiente**. Un evento de sandbox firmado con el secreto de sandbox
  no va a validar contra el secreto de producción.
- `payment.voided`, `payment.refunded` y `payment.partially_refunded` llegan incluso cuando la acción se
  realizó a mano en el portal del comercio, no solo cuando vos llamás a la API. Un manejador que asume que
  "solo mis propias llamadas cambian el estado" va a estar equivocado: manejalos en ambos casos.
- Mantené el manejador pequeño. Los correos, las facturas en PDF y las llamadas al ERP van en el worker cron `shop_jobs`.

---

#### 5. Respaldo por consulta periódica

Los webhooks se pierden: un parpadeo de DNS, un certificado vencido, una regla de WAF, una ventana de mantenimiento. Consultá los
pagos que todavía estás esperando. **No hay endpoint de listado**, así que solo podés consultar los ids que guardaste
localmente al momento de crearlos.

```php
<?php
// cron/poll_pending.php - run every 5 minutes:
//   */5 * * * * /usr/bin/php /var/www/cron/poll_pending.php >> /var/log/roki-poll.log 2>&1
declare(strict_types=1);
require __DIR__ . '/../roki/bootstrap.php';

const POLL_MAX_ATTEMPTS = 14;
/** Minutes to wait before attempt N. The tail keeps a stale link cheap to watch. */
const POLL_BACKOFF = array(1, 2, 5, 10, 15, 30, 60, 120, 240, 360, 720, 720, 1440, 1440);

$db = roki_db();

$stmt = $db->prepare(
    "SELECT order_id, roki_payment_id, poll_attempts
       FROM shop_payments
      WHERE status = 'pending'
        AND roki_payment_id IS NOT NULL
        AND poll_attempts < ?
        AND (next_poll_at IS NULL OR next_poll_at <= NOW())
      ORDER BY next_poll_at ASC
      LIMIT 50"
);
$stmt->execute(array(POLL_MAX_ATTEMPTS));
$rows = $stmt->fetchAll();

$client = roki_client();

foreach ($rows as $row) {
    $attempts = (int) $row['poll_attempts'] + 1;
    $delay    = POLL_BACKOFF[min($attempts, count(POLL_BACKOFF)) - 1];

    try {
        $payment = $client->getPayment((int) $row['roki_payment_id']);
    } catch (RokiApiException $e) {
        if ($e->status() === 401) {
            // The key is wrong or was rotated badly. Stop: every remaining call would fail too.
            roki_log('polling aborted, authentication failed');
            break;
        }
        if ($e->isPaymentNotFound()) {
            // The id does not exist for this key - typically a payment created with the other
            // environment's key. It will never resolve; flag it for a human.
            $db->prepare("UPDATE shop_payments SET poll_attempts = ?, next_poll_at = NULL, updated_at = NOW() WHERE order_id = ?")
               ->execute(array(POLL_MAX_ATTEMPTS, (int) $row['order_id']));
            roki_log('payment id unknown to this key', array('order_id' => $row['order_id']));
            continue;
        }
        if ($e->isRoutingError()) {
            roki_log('routing 404 while polling - base URL misconfigured');
            break;
        }
        // Transport error or 5xx: back off and try again next run.
        $db->prepare('UPDATE shop_payments SET poll_attempts = ?, next_poll_at = DATE_ADD(NOW(), INTERVAL ? MINUTE), updated_at = NOW() WHERE order_id = ?')
           ->execute(array($attempts, $delay, (int) $row['order_id']));
        continue;
    }

    $db->beginTransaction();
    // Exactly the same state machine the webhook uses, so both paths agree and neither can
    // double-apply: the rank check makes a late webhook after a poll a no-op.
    $changed = roki_apply_payment_state($db, $payment);
    $db->commit();

    $status = (string) $payment['status'];
    if (roki_is_terminal($status)) {
        $db->prepare('UPDATE shop_payments SET next_poll_at = NULL, updated_at = NOW() WHERE order_id = ?')
           ->execute(array((int) $row['order_id']));
        if ($changed !== null) {
            roki_log('resolved by polling', array('order_id' => $row['order_id'], 'status' => $status));
        }
        continue;
    }

    $db->prepare('UPDATE shop_payments SET poll_attempts = ?, next_poll_at = DATE_ADD(NOW(), INTERVAL ? MINUTE), updated_at = NOW() WHERE order_id = ?')
       ->execute(array($attempts, $delay, (int) $row['order_id']));
}

// Anything still pending after the whole backoff schedule needs a human. An unpaid link eventually
// reports status 'expired' on its own, so a row stuck at 'pending' past expires_at means the polling
// itself is broken, not the payment.
$stuck = $db->prepare(
    "SELECT order_id, roki_payment_id, expires_at
       FROM shop_payments
      WHERE status = 'pending' AND poll_attempts >= ? AND created_at < DATE_SUB(NOW(), INTERVAL 1 DAY)"
);
$stuck->execute(array(POLL_MAX_ATTEMPTS));
foreach ($stuck->fetchAll() as $row) {
    roki_log('needs manual review in the merchant portal', array(
        'order_id'        => $row['order_id'],
        'roki_payment_id' => $row['roki_payment_id'],
        'expires_at'      => $row['expires_at'],
    ));
}
```

La consulta periódica y los webhooks no son alternativas: corré ambos. El webhook te da segundos de latencia; el
proceso de consulta periódica garantiza la consistencia eventual cuando el webhook nunca llega. Como ambos pasan por
`roki_apply_payment_state()` con la guarda de rango, gane quien gane la carrera, el resultado es el mismo y
el job de efecto secundario se encola una sola vez.

---

#### Checklist previo al lanzamiento

- [ ] `roki_secret_key` y `roki_webhook_secret` viven en `roki_settings`, no en ningún archivo `.php`.
- [ ] La llave secreta nunca aparece en HTML, JavaScript, logs ni páginas de error.
- [ ] Cada llamada de creación envía un `Idempotency-Key` derivado del hash del payload, no del id de la orden.
- [ ] `roki_verify_payment()` corre antes del redirect, y si falla bloquea el redirect.
- [ ] `total` y `service_fee_amount` se leen de la respuesta; la fórmula de la comisión no se replica.
- [ ] El webhook verifica el HMAC sobre el cuerpo crudo con `hash_equals`, y devuelve 400 cuando falla.
- [ ] El procesamiento de eventos está deduplicado sobre el `id` del evento.
- [ ] El cron de consulta periódica está instalado y su log está monitoreado.
- [ ] La anulación, el reembolso y los recibos se llaman con el UUID de la transacción, nunca con el id numérico del pago.
- [ ] Ningún camino de código llama a un endpoint de listado de pagos: no existe.

### Reversos, recibos y los modos más nuevos

Los ejemplos por stack de arriba cubren el modo 1 de punta a punta. Estos agregan las operaciones que se introdujeron después. La
forma se traslada a cualquier lenguaje: lo que importa es qué identificador pasás y qué credencial
firma la llamada.

#### Revertir un cobro: primero la anulación, el reembolso como respaldo

El error más común de todos es pasar el id del pago. **Cada llamada acá recibe el UUID de la
transacción**, que aparece en `transaction_id` una vez que el pago está pagado.

```php
/**
 * Reverse a charge in full. Void is immediate and pre-settlement; refund applies after.
 * Trying void first is deliberate: it is cheaper, instant for the customer, and avoids
 * a settled-then-refunded round trip.
 *
 * @param string $transactionId  UUID from the payment, NOT the numeric payment id.
 */
public function reverse(string $transactionId, float $amount, string $reason): array
{
    $void = $this->request('POST', "/payments/{$transactionId}/void", ['reason' => $reason]);
    if ($void['status'] === 200) {
        return ['method' => 'void', 'payment' => $void['body']];
    }

    // 422 with errors.void = not voidable (already settled, already voided, wrong state).
    // Anything else is a real failure worth surfacing.
    if ($void['status'] !== 422) {
        throw new RokiException("Void failed: HTTP {$void['status']}");
    }

    $refund = $this->request('POST', "/payments/{$transactionId}/refund", [
        'amount' => $amount,
        'reason' => $reason,
    ]);
    if ($refund['status'] === 200) {
        return ['method' => 'refund', 'payment' => $refund['body']];
    }

    // A processor rejection such as "Invalid transaction" means the transaction is not
    // refundable yet. It is not a bug in your payload - retry later.
    $why = $refund['body']['errors']['refund'][0] ?? 'unknown';
    throw new RokiException("Neither void nor refund succeeded: {$why}");
}
```

```js
// Node equivalent. Same rule: transactionId is a UUID.
async function reverse(transactionId, amount, reason) {
  const voided = await roki('POST', `/payments/${transactionId}/void`, { reason });
  if (voided.status === 200) return { method: 'void', payment: voided.body };
  if (voided.status !== 422) throw new Error(`Void failed: HTTP ${voided.status}`);

  const refunded = await roki('POST', `/payments/${transactionId}/refund`, { amount, reason });
  if (refunded.status === 200) return { method: 'refund', payment: refunded.body };

  throw new Error(`Neither void nor refund succeeded: ${refunded.body?.errors?.refund?.[0] ?? 'unknown'}`);
}
```

Si te sale `"The route ... could not be found."`, pasaste el id numérico del pago. El endpoint está
ahí; el identificador es el equivocado.

#### Recibo

```php
// Metadata: payment_id, transaction_id and a public receipt_url you can link to.
$receipt = $this->request('GET', "/payments/{$transactionId}/receipt");

// The PDF is a binary body - stream it, do not json_decode it.
$pdf = $this->requestRaw('GET', "/payments/{$transactionId}/receipt/download");
Storage::put("receipts/{$transactionId}.pdf", $pdf);
```

#### Modo 2: la mitad del backend de los componentes embebidos

El navegador produce un `tok_*` y lo manda por POST a **tu** servidor. Tu servidor agrega el monto y la
llave secreta. El monto nunca viene del navegador.

```php
// POST /your-backend/confirm-payment   (called by your own frontend)
public function confirmEmbedded(Request $request)
{
    $order = Order::findOrFail($request->input('order_id'));

    // Authoritative amount: from YOUR order, never from the client payload.
    $res = Http::withToken($this->secretKey())
        ->acceptJson()
        ->post('https://aura.roki.systems/api/connect/embed/confirm', [
            'amount'               => $order->total,
            'currency_code'        => 'HNL',
            'external_reference'   => (string) $order->id,
            'payment_token'        => $request->input('payment_token'),   // tok_*
            'publishable_key'      => $this->publishableKey(),
            'success_redirect_url' => route('orders.success', $order),
            'failed_redirect_url'  => route('orders.failed', $order),
        ]);

    $body = $res->json();

    switch ($body['status'] ?? null) {
        case 'approved':
            // transaction_details carries roki_commission / isv / expected_settlement.
            // Commercially sensitive: store if you need it, never show it to the cardholder.
            $order->markPaid($body['transaction_id']);
            return response()->json(['status' => 'approved', 'transaction_id' => $body['transaction_id']]);

        case 'pending':
            // 3-D Secure. Send the customer to authentication_url - redirect, or a VISIBLE
            // modal iframe. A hidden 1x1 iframe silently breaks every challenge flow.
            // The outcome arrives by webhook. Do NOT treat this as a failure.
            $order->markAwaitingAuthentication($body['transaction_id'] ?? null);
            return response()->json([
                'status'             => 'pending',
                'authentication_url' => $body['authentication_url'],
            ]);

        default:
            // Processor decline: branch on Errors[0].Code, never on the message text.
            $code = $body['Errors'][0]['Code'] ?? $body['IsoResponseCode'] ?? 'declined';
            Log::warning('ROKI embedded decline', ['order' => $order->id, 'code' => $code]);
            return response()->json(['status' => 'declined', 'code' => $code], 402);
    }
}
```

#### Modo 3A: guardar una tarjeta y cobrarla después

El primer paso es un webhook. La tarjeta se guarda solo cuando el cliente marca la casilla, así que tratá el evento como
el disparador, no como algo que vos pedís.

```php
// Inside your webhook handler, alongside payment.approved etc.
case 'payment_method.saved':
    $pm = $event['data'];
    SavedCard::updateOrCreate(
        ['roki_payment_method_id' => $pm['id']],          // pm_*  - opaque, never a PAN
        [
            'customer_id' => $this->resolveCustomer($event),
            'brand'       => $pm['card_brand'],
            'last_four'   => $pm['last_four'],
            'exp_month'   => $pm['exp_month'],
            'exp_year'    => $pm['exp_year'],
            'is_default'  => $pm['is_default'] ?? false,
        ],
    );
    break;
```

Cobrarla después:

```php
/**
 * Charge a saved card. Idempotency-Key is MANDATORY here - unlike payment creation,
 * where it is only recommended. Omitting it returns 422.
 *
 * Derive the key from what makes this charge unique so a retry is safe but a genuinely
 * new charge is not swallowed.
 */
public function chargeSavedCard(SavedCard $card, Order $order): array
{
    $idempotencyKey = 'charge-' . $order->id . '-' . substr(
        hash('sha256', $order->id . '|' . $order->total . '|' . $card->roki_payment_method_id), 0, 32
    );

    $res = Http::withToken($this->secretKey())
        ->withHeaders(['Idempotency-Key' => $idempotencyKey])
        ->acceptJson()
        ->post("https://aura.roki.systems/api/connect/v1/payment-methods/{$card->roki_payment_method_id}/charge", [
            'amount'             => $order->total,
            'currency_code'      => 'HNL',        // alphabetic here; payment creation uses "340"
            'external_reference' => (string) $order->id,
        ]);

    $body = $res->json();

    if (($body['status'] ?? null) === 'approved') {
        // The returned id IS the transaction UUID: use it for void/refund/receipt.
        $order->markPaid($body['id']);
        return $body;
    }

    // 404 = the card was revoked or never belonged to you. Stop retrying and ask for a new card.
    if ($res->status() === 404) {
        $card->markUnusable();
        throw new RokiException('Saved card is no longer usable');
    }

    $code = $body['Errors'][0]['Code'] ?? $body['IsoResponseCode'] ?? 'declined';
    throw new RokiException("Charge declined: {$code}");
}
```

Listar las tarjetas de un cliente, y revocar una:

```php
$cards = $this->request('GET', '/payment-methods', query: [
    'customer' => ['identity_number' => $customer->identity_number],   // preferred identifier
]);
// A customer with no saved cards is 200 with {"data": []}, not a 404.

$this->request('DELETE', "/payment-methods/{$card->roki_payment_method_id}");
```

**Nunca llamés a ninguna de estas desde un navegador.** Todas requieren `sk_*`. Un botón de "pagar con tarjeta guardada"
llama a tu propio servidor, que luego llama a ROKI.

## 20. Conciliación y runbook de operaciones

La anulación, el reembolso, los recibos y el listado ya existen, así que recuperarse es posible - pero
solo si decidiste de antemano qué hacer. Definí estos caminos antes de lanzar.

**Persistí lo suficiente para poder recuperarte.** Guardá `roki_payment_id`, `external_reference`, `status`, `total`,
`checkout_url`, `expires_at` y `last_event_id` en tu orden. Copiá también el id de tu orden dentro de `metadata`,
así un pago que se inspeccione en el portal se puede rastrear sin tu base de datos.

**Barré las órdenes pendientes de forma programada.** Volvé a consultar los ids guardados de las órdenes que sigan en
`pending` pasado cierto umbral, y de cualquier orden que ya pasó su `expires_at`. Con el endpoint de listado también
podés barrer al revés - `GET /payments?status=paid&from=...` - y agarrar pagos que ya están liquidados en ROKI pero
siguen abiertos en tu sistema porque se perdió un webhook.

**Manejá los estados terminales que llegan tarde.** `expired`, `voided`, `refunded` y `partially_refunded`
pueden aparecer en cualquier momento - incluso desde una persona actuando en el portal, sin ninguna llamada a la API de tu parte.
Un manejador que asuma que "solo mis propias llamadas cambian el estado" va a estar equivocado.

**Recuperate de una creación que se cayó por timeout.** Si una petición de creación no devuelve respuesta, no crees un
segundo pago. Reintentá una vez con la **misma** `Idempotency-Key`: si el primer intento llegó a ROKI,
te devuelve ese pago; si no, se crea ahora.

**Alertá sobre esto.** 401 repetidos (una llave rotada o rota), picos de 422 (un deploy mandando un payload malo),
fallas de firma de webhook (secreto equivocado, o el manejo del cuerpo crudo roto por un cambio de middleware), y
órdenes pagadas en ROKI pero todavía pendientes localmente (webhooks que no llegan).

**Automatizá las reversiones, pero respetá el orden.** La anulación aplica antes de la liquidación y devuelve el monto
completo de inmediato; el reembolso aplica después, y puede ser parcial. Implementá la reversión así: probá anular, y
si te la rechazan porque ya está liquidada o porque no es anulable, reembolsá en su lugar. Las dos también están disponibles para una
persona en el portal, y las dos emiten webhooks en cualquier caso.

## 21. Checklist previo a producción

- [ ] Las credenciales se leen en tiempo de ejecución desde la configuración o la base de datos, no desde el código.
- [ ] La llave secreta no aparece en el repositorio, ni en el navegador, ni en los logs.
- [ ] Se manda `Idempotency-Key` en cada creación, **derivada del contenido de la orden**, no solo de su id.
- [ ] El `id` del pago se guarda localmente al crearlo (no hay búsqueda por `external_reference`).
- [ ] La respuesta de la creación se **verifica**: `total`, `sales_tax_amount` y `service_fee_amount` son los esperados.
- [ ] El endpoint de webhook verifica la firma HMAC **sobre el cuerpo crudo**, con comparación de tiempo constante.
- [ ] El webhook responde 200 rápido y procesa de forma asíncrona e idempotente por el `id` del evento.
- [ ] El endpoint de webhook está registrado en el portal, **en el entorno que le corresponde a la llave**.
- [ ] Llegar a `success_url` **no** marca la orden como pagada.
- [ ] Existe un respaldo de consulta directa para las órdenes que quedaron en `pending`.
- [ ] Las llamadas HTTP tienen timeouts (~30 s), con reintentos solo en la creación y con la misma llave de idempotencia.
- [ ] La lógica de reversión intenta primero la anulación y cae al reembolso cuando la transacción ya está liquidada.
- [ ] Se probó de punta a punta al menos un pago real con el monto mínimo antes de salir a producción.
- [ ] Solo móvil: la llave secreta no está en el binario de la app, el checkout se abre en el navegador del sistema,
      las URLs de retorno son Universal/App Links con https, y la app confirma a través de su propio backend.

## 22. Modo 2 - Componentes embebidos

Campos de tarjeta renderizados por ROKI dentro de un **iframe seguro en tu propio sitio**. El cliente
nunca sale de tu página, y el número de tarjeta nunca toca tu código.

### 22.1 El flujo

```
1. Browser loads the ROKI SDK and mounts the component with pk_* only
2. Customer fills the card fields (inside ROKI's iframe)
3. Your "pay" button calls payment.submit()
4. The SDK returns a single-use tok_* to your page
5. Your page posts that tok_* to YOUR OWN backend
6. Your backend calls POST /api/connect/embed/confirm with sk_* + tok_* + amount
7. You get approved / declined / pending (3-D Secure)
```

**El monto lo define tu backend en el paso 6, no el navegador.** Montar el componente no necesita que
exista un pago de antemano.

### 22.2 Frontend

```html
<script src="https://aura.roki.systems/connect/components/v1/roki.js"></script>

<form id="order-form">
  <div id="roki-payment"></div>
  <input type="hidden" name="roki_payment_token" id="roki_payment_token">
  <button type="submit" id="buy-now" disabled>Buy now</button>
</form>

<script>
  const roki = RokiConnect({ publishableKey: 'pk_test_...', locale: 'en' });

  const payment = roki.createPaymentComponent({
    customer: { name: 'John Doe', email: 'john@example.com' },
    appearance: { theme: 'light', accentColor: '#F97316' }
  });
  payment.mount('#roki-payment');

  // Keep the button disabled until the card fields are complete.
  payment.on('form_complete', (data) => { buyBtn.disabled = !(data && data.complete); });

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    buyBtn.disabled = true;          // also disable while confirming
    payment.submit();
  });

  payment.on('payment_token_ready', async (data) => {
    // Send the token to YOUR backend. Never call ROKI's secret-key endpoints from here.
    const res = await fetch('/your-backend/confirm-payment', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ payment_token: data.payment_token, order_id: 123 })
    });
    showResult(await res.json());
  });

  payment.on('payment_failed', (data) => {
    buyBtn.disabled = false;
    showResult({ status: 'declined', IsoResponseCode: data.IsoResponseCode, Errors: data.Errors });
  });
</script>
```

Opciones del SDK: `paymentId` (opcional), `customer`, `description`, `metadata`, `locale`, `saveCard`,
`appearance.theme` (light/dark), `appearance.accentColor`. No podés reemplazar el HTML del iframe.

### 22.3 Confirmación en el backend

```http
POST https://aura.roki.systems/api/connect/embed/confirm
Authorization: Bearer sk_test_...

{
  "amount": 500.00,
  "currency_code": "HNL",
  "external_reference": "order-123",
  "payment_token": "tok_xxxx",
  "publishable_key": "pk_test_...",
  "success_redirect_url": "https://merchant.example/success",
  "failed_redirect_url": "https://merchant.example/failed"
}
```

Fijate en la ruta base: `/api/connect/embed`, **no** `/api/connect/v1`.

**Las dos URLs de redirección tienen que ser HTTPS. No hay excepción para desarrollo local**, a pesar
de lo que diga el mensaje de error. Cuando rechaza una devuelve:

```
422 success_redirect_url_invalid
"success_redirect_url debe ser una URL HTTPS valida (http solo se permite en desarrollo local)."
```

Ese paréntesis está mal. Verificado el 2026-08-14 contra nueve variantes:

| URL | Se acepta |
|---|---|
| `http://localhost:4000/` | no |
| `http://127.0.0.1/` | no |
| `http://anything.test/ok` | no |
| `https://localhost:4000/` | **sí** |
| `https://your-site.com/gracias?order=1` | **sí** |

Así que `http` se rechaza en todas partes, incluido loopback, y las cadenas de consulta no son
problema. Para desarrollar localmente necesitás TLS en localhost, un túnel o - lo más simple -
apuntar las dos URLs a cualquier página HTTPS que ya controles. Solo importan cuando un challenge de
3-D Secure se lleva al cliente y lo trae de vuelta; la respuesta común de aprobado/rechazado llega en
la respuesta misma de `/confirm`.

El orden de validación ayuda a la hora de depurar: el cuerpo se valida antes que el token, así que un
`success_redirect_url_invalid` significa que la petición nunca llegó a mirar tu `tok_*`.

### 22.3.1 Una respuesta pendiente de 3-D Secure, transcrita de una real

**Lo que vimos fue autenticación sin fricción, no un
challenge**: llegó el `202` de abajo, y el pago ya estaba `paid` en el mismo segundo, con
`payment.approved` unos tres segundos después - la `authentication_url` nunca se cargó. Así que la
forma de la respuesta es real y las trampas que trae son reales; un challenge que de verdad detenga
al cliente todavía no se ha observado acá.

Tres cosas de esta única respuesta van a confundir a un integrador:

```json
HTTP 202
{
  "status": "pending",
  "code": "authentication_required",
  "error_code": "authentication_required",
  "error_message": "La autenticacion del pago aun esta en progreso. Consulte GET /payments/{id} o espere el webhook.",
  "transaction_id": "21e41243-4214-46b5-a29b-ff7966adb629",
  "amount": 25,
  "currency": "340",
  "authentication_url": "https://aura.roki.systems/connect/components/v1/confirm-challenge/21e41243-...?expires=1786751541&sig=a0d14de9..."
}
```

1. **El código de estado es `202`, no `200`.** Código escrito como `if (res.status !== 200) fail()`
   rechaza un pago que apenas está esperando autenticación - y el cliente todavía puede completarlo,
   dejando el pedido como fallido mientras el dinero se mueve.
2. **Trae `error_code` y `error_message` aunque no falló nada.** `if (body.error_code)` es la
   comprobación natural de escribir y acá está mal. Ramificá según `status` en su lugar: `approved`,
   `declined`, `pending`.
3. **La `authentication_url` va firmada y vence.** La ventana observada fue de **15 minutos**
   (`expires` es un timestamp Unix, con un `sig` que lo sella). No la guardes, no la mandes por
   correo ni la renderices después - mandá al cliente ahí de inmediato.

Cargá esa URL como **redirección de página completa o un popup de verdad**. El ejemplo de la
documentación oficial la mete en un iframe invisible de 1x1, donde el challenge no se puede completar
y el pago simplemente nunca avanza.

El `transaction_id` de esta respuesta es el que vas a volver a ver en el webhook y el que reciben la
anulación y el reembolso. Guardalo acá, antes de que el cliente desaparezca dentro del challenge.

### 22.4 Los tres resultados

**`approved`** trae `transaction_id` más `transaction_details` con el desglose financiero completo
- incluidos `roki_commission`, `isv` y `expected_settlement`. Esos datos son comercialmente
sensibles: registralos si hace falta, pero nunca se los muestres al tarjetahabiente.

**`declined`** trae los campos propios del procesador, con las mayúsculas tal como las manda el
procesador:

```json
{ "status": "declined", "IsoResponseCode": "05", "Errors": [{ "Code": "201", "Message": "..." }] }
```

Ramificá según `Errors[0].Code`, no según el texto del mensaje.

**`pending`** significa que se requiere 3-D Secure:

```json
{ "status": "pending", "authentication_url": "https://...", "transaction_id": "9e2b..." }
```

El resultado final llega por los webhooks de siempre, `payment.approved` / `payment.failed` - no
trates `pending` como una falla.

**Cómo presentes la `authentication_url` decide si funcionan los pagos de monto alto.** El flujo que
ROKI probó **redirige al cliente** a esa URL. Algunos ejemplos de código en cambio la agregan como un
iframe invisible de 1x1:

```js
// DO NOT ship this as your only 3DS path.
frame.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;border:0;';
```

Eso solo sirve para autenticación *sin fricción*, donde el emisor aprueba en silencio. En el momento
en que el emisor exige un **challenge** - un código por SMS, la app del banco, una clave - el cliente
no ve absolutamente nada y el pago se queda colgado para siempre. Los challenges son más comunes
justo en las transacciones de monto alto que menos querés perder.

Usá una de estas opciones en su lugar:

- **Redirección de página completa** a `authentication_url`, volviendo a tu `success_redirect_url`.
  Este es el flujo que ROKI probó de punta a punta y el valor por defecto más seguro.
- **Un iframe modal visible** con tamaño suficiente para el challenge (unos 400x600), si querés
  mantener al cliente en tu página. Contemplá el caso en que lo cierre sin terminar.

Elijas la que elijas, el webhook sigue siendo la fuente de verdad: un cliente que completa el
challenge y después cierra la pestaña igual produce `payment.approved`, y tu pedido se tiene que
cumplir a partir de ese evento, no de la redirección.

### 22.5 Qué hay que hacer bien

Dejá el botón de pago deshabilitado hasta que `form_complete` reporte que los campos son válidos, y
deshabilitalo de nuevo mientras confirmás - si no, un doble clic produce dos tokens. Tratá `tok_*`
como de un solo uso: ante un rechazo, o creás un pago nuevo o volvés a montar el componente (un pago
con `reusable: true` hace más limpio el remontaje). Y nunca pongas `sk_*` en la página: el token va a
tu servidor, y tu servidor llama a ROKI.

## 23. Modo 3A - Tarjetas guardadas (pagos tokenizados)

Cobra a una tarjeta que el cliente ya guardó, sin que vuelva a ingresar los datos. Esto es lo que
hace posibles las suscripciones y las recompras con un clic.

### 23.1 El flujo

```
1. Customer pays once through mode 1 or mode 2
2. Customer explicitly ticks "save my card for next time"
3. ROKI stores the processor's own token - never the card number
4. Your backend receives the payment_method.saved webhook with an opaque pm_*
   (or looks it up later with GET /payment-methods)
5. Later, your backend charges that pm_* with sk_*
6. ROKI verifies the method belongs to you, in the right environment, and is
   still usable, then charges the stored token
7. You get the same payment.approved / payment.failed webhooks as any other payment
```

La tarjeta se guarda **solo** cuando el cliente acepta. No hay forma de guardar una en silencio.

### 23.1.0 Primero hay que autorizar las tarjetas guardadas en la cuenta

**Antes de escribir cualquier código para este modo, confirma que la cuenta del comercio lo tenga
habilitado.** La tarjeta guardada no viene activada por defecto: ROKI la autoriza comercio por
comercio, a mano. Eso es a propósito - guardar una credencial que se puede cobrar sin el cliente
presente es un asunto de fraude y contracargos, no una bandera de funcionalidad - y otras pasarelas
lo controlan igual.

Lo que importa para una integración es cómo llega la negativa, porque no es un error:

| Situación | Qué obtenés |
|---|---|
| Sandbox sin aprovisionar | `422 sandbox_terminal_unavailable`, que lo dice sin rodeos (16.3) |
| Guardado de tarjeta no autorizado | **Nada.** El pago devuelve `201`/`202`, el cobro se aprueba y no se guarda ninguna tarjeta |

`saveCard: true` fue aceptado, el SDK lo llevó
hasta el iframe como `save_card=1`, el pago se aprobó, `GET /payment-methods` siguió vacío y no llegó
ningún evento `payment_method.saved`. Nada reportó una negativa en ningún momento.

Sé preciso con lo que eso demuestra: nunca se confirmó de forma independiente que la cuenta tuviera
habilitada la tarjeta guardada, así que "no autorizado" es la explicación más probable del resultado
nulo, no una causa comprobada. Ojo también con que en `POST /confirm` no se manda ninguna intención
de guardado - solo el iframe la lleva - así que si el flujo también exige algo ahí, esta prueba se
vería idéntica.

Así que no te pongas a depurar tu código. Revisa primero:

```bash
# Pay once with saveCard, then immediately:
curl "https://aura.roki.systems/api/connect/v1/payment-methods?customer[identity_number]=0801199000000" \
  -H "Authorization: Bearer sk_test_..."
```

Un `data` vacío después de un intento de guardado exitoso significa que la cuenta no está autorizada,
no que tu petición estuviera mal. Pedile a ROKI que habilite la tarjeta guardada para el comercio, y
recién entonces construí el modo 3A.

### 23.1.1 Cómo se habilita ese acuerdo en la práctica - verificado 2026-08-14

El flujo de arriba dice "el cliente marca una casilla" sin decir de dónde sale la casilla. Eso
importa, porque los dos modos difieren y hoy solo uno funciona.

**Modo 2 (componentes embebidos): lo habilita el comercio.** El SDK acepta una opción `saveCard` y la
traduce a `save_card=1` en la URL del iframe. La casilla que ve el cliente es **tuya**; ROKI solo
recibe la intención:

```js
const payment = roki.createPaymentComponent({
  customer: { identity_number: '0801199000000', email: 'buyer@example.com' },
  saveCard: true,          // el comercio habilita el guardado; la casilla la dibujas vos
});
```

Manda `customer.identity_number`: esa es la llave por la que `GET /payment-methods` busca la tarjeta.

**Modo 1 (checkout alojado): nada de lo que mandes lo activa.** En una cuenta sin el módulo
autorizado, la página de checkout no dibuja **ninguna opción de guardado**. Se probaron siete campos
plausibles en la petición de creación - `save_card` (booleano y entero), `allow_save_card`,
`tokenize`, `save_payment_method`, `reusable`, y ninguno - y ninguno hizo aparecer la opción. La hoja
de estilos de la página sí contiene una regla `.rp-co2-save`, así que el checkout sabe cómo dibujar
esa fila cuando la cuenta tiene derecho a ella. Es una autorización, no un parámetro (23.1.0).

**Qué significa esto para una integración:** si necesitas tarjetas guardadas, originalas desde el
modo 2. No le prometas a un cliente que pagar por un enlace de pago va a guardar su tarjeta - en las
cuentas verificadas acá, no lo hace.

### 23.2 Cómo conseguir el `pm_*`

Desde el webhook:

```json
{
  "id": "evt_01HPM01",
  "type": "payment_method.saved",
  "created_at": "2026-08-12 12:05:00",
  "data": {
    "id": "pm_7k2n9xqf31ab",
    "card_brand": "Visa",
    "last_four": "4242",
    "exp_month": 11,
    "exp_year": 2027,
    "is_default": true
  }
}
```

O bajo demanda:

```bash
curl -G https://aura.roki.systems/api/connect/v1/payment-methods \
  -H "Authorization: Bearer sk_test_..." \
  --data-urlencode "customer[identity_number]=0801199012345"
```

Identifica al cliente con `customer[identity_number]` (preferido) o `customer[email]` - los mismos
identificadores que mandas al crear pagos. **Un cliente sin tarjetas guardadas es un `200` normal con
`{"data": []}`, no un 404.**

### 23.3 Cobrar

```http
POST /api/connect/v1/payment-methods/pm_7k2n9xqf31ab/charge
Authorization: Bearer sk_test_...
Idempotency-Key: order-2002-charge

{ "amount": 500.00, "currency_code": "HNL", "external_reference": "order-2002" }
```

**Acá `Idempotency-Key` es obligatorio**, no apenas recomendado como en la creación de pagos. Si lo
omitís devuelve `422`. Usa una llave única por cada intento de cobro para que un reintento de red
nunca cobre dos veces.

Ojo que en este endpoint `currency_code` lleva el código alfabético (`"HNL"`), mientras que la
creación de pagos lleva el numérico (`"340"`).

`POST /payments/token-charge` es un alias equivalente que recibe la tarjeta guardada en el cuerpo como
`payment_token`, lo que le queda bien a las renovaciones de suscripción:

```json
{ "payment_token": "pm_7k2n9xqf31ab", "amount": 100.00,
  "currency_code": "HNL", "external_reference": "sub-aug-2026" }
```

**Nunca llames a ninguno de los dos endpoints desde un navegador.** Los dos necesitan `sk_*`.

### 23.4 Reversar y revocar

La respuesta del cobro devuelve un `id` - ese es el **UUID de la transacción**. Usalo con los
endpoints normales `/payments/{transaction_id}/void` y `/refund` de la sección 13. No hay una ruta de
reverso aparte para los cobros con token.

Para eliminar una tarjeta guardada:

```bash
curl -X DELETE https://aura.roki.systems/api/connect/v1/payment-methods/pm_7k2n9xqf31ab \
  -H "Authorization: Bearer sk_test_..."
```

Un método revocado hace fallar los cobros posteriores con un error propio, en vez de aprobarlos en
silencio.

### 23.5 Qué pensar antes de habilitarlo

Una tarjeta guardada que se cobra sin el cliente presente tiene un perfil de riesgo distinto al de un
checkout que el cliente acaba de completar. Antes de sacar a producción la facturación recurrente:
definí qué pasa cuando una tarjeta vence o se reemite, decí cuántas veces reintentas una renovación
rechazada y con qué códigos de rechazo dejas de reintentar, y dale al cliente una forma de ver y
eliminar sus tarjetas guardadas. Nada de eso lo obliga la API.

## 24. Resolución de problemas

| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| 401 en todas las llamadas | Llave mal escrita, regenerada, o con espacios en blanco de más | Regenerala en el portal y actualizá la configuración |
| `The route ... could not be found` | Falta `/v1` en la base, o se usó un id numérico de pago donde va un UUID de transacción | Arreglá la URL; anulación/reembolso/recibo se identifican por el UUID de transacción (13.1) |
| 404 `Pago no encontrado` | Id del otro entorno (creado con `sk_test_`, consultado con `sk_live_` o al revés) | Usá la llave del mismo entorno |
| 422 sobre `currency_code` en sandbox | El sandbox no está aprovisionado para el comercio | 16.2 - pedile a ROKI que lo habilite |
| Pago creado pero sin impuesto/propina/comisión | Nombre de campo mal escrito e ignorado en silencio | 12.1 - compará contra `openapi.yaml` e inspeccioná la respuesta |
| No llegan webhooks | Endpoint registrado en el entorno equivocado, URL no pública, o sin HTTPS | Revisá el selector sandbox/producción y "Entregas recientes" en el portal |
| Llegan webhooks pero falla la firma | Se firmó el JSON re-serializado en vez del cuerpo crudo | 14.4, regla 1 |
| Órdenes pagadas que quedan en pendiente | Confiar en `success_url` para confirmar | 9.3 - confirmá por webhook o por consulta |
| Pagos duplicados | Falta `Idempotency-Key` (`external_reference` no es único) | 11 |
| El enlace de checkout muere después de un pago | `reusable` es false por defecto y el enlace se consume | 8.4 |
| El vencimiento sale corrido seis horas | Timestamps interpretados como UTC en vez de UTC-6 | 12.4 |

