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:
- 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. - Pegá la URL pública HTTPS de tu endpoint.
- Guardar endpoint.
- 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
{
"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
iddel evento es un UUID simple, sin prefijoevt_. El código que buscaevt_no encuentra nada. (El mismo tipo de error que el prefijoplink_en 9.1.) Confirmado en cinco entregas reales, tanto en el cuerpo como en el encabezadoROKI-Webhook-Event-Id. data.transaction_ides la cadena UUID, no un número, y esnullhasta que el pago se cobra. La documentación v2 actual de ROKI ya lo muestra bien; era la versión anterior la que mostraba9001, que es la confusión que hace que la anulación y el reembolso contesten 404transaction_id_expected(13.1). Guardarlo en una columna entera es el mismo error por otro camino - ver los esquemas en 19.2.datalleva el objeto de pago completo, idéntico en forma aGET /payments/{id}- no el subconjunto que sugiere el ejemplo.metadatallega 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:
- 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.
- Comparación en tiempo constante (
hash_equals,crypto.timingSafeEqual,hmac.compare_digest). Nunca==. - 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, confirmada por el equipo del API el 25/08/2026:
| Tiempo de espera por intento | 15 segundos |
| Intentos en total | 5 |
| Espera entre intentos | 1 minuto, después 5 minutos, después 15 minutos, después 1 hora |
| Después del quinto | no se manda nada más |
| Identificador del evento en los reintentos | el mismo, siempre |
Tres consecuencias que conviene tener en cuenta al diseñar:
- Contestá dentro de los 15 segundos. Verificá la firma, guardá el id del evento y devolvé 200. El trabajo de verdad hacelo después, fuera de la petición. Un manejador que despacha el pedido ahí mismo —mandando correos, llamando a la bodega— se pasa del tiempo cuando hay carga, y ROKI reenvía un evento que ya procesaste.
- La ventana completa de reintentos es de una hora y veintiún minutos. Pasado eso, una entrega que nunca llegó se perdió, y lo único que la recupera es tu propio barrido. Eso es lo que hace que el respaldo de 14.6 sea obligatorio y no un lujo.
- El id del evento no cambia entre intentos, así que descartar por id es exactamente la guarda correcta: un reintento es indistinguible del original salvo porque llega después.
El orden de entrega sigue sin estar garantizado. Un evento que necesitó cuatro reintentos llega después de otros que salieron a la primera, así que un manejador no puede asumir que el último evento recibido es el estado más reciente. La sección 14.4 explica cómo decidir por tipo de evento y no por orden de llegada.
Rotar el secreto de firma: no hay solapamiento. El secreto nuevo reemplaza al viejo de inmediato; el viejo deja de verificar en el momento en que rotás. Actualizá tu servidor primero, o en el mismo movimiento. Un hueco entre las dos cosas es un hueco donde toda entrega falla la verificación de firma y quema sus cinco intentos. Si no podés desplegar y rotar a la vez, aceptá los dos secretos en tu manejador mientras dure el despliegue: verificá contra el nuevo y, si no valida, contra el viejo; después quitá el respaldo.
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 el redireccionamiento no es prueba de pago. Para conciliación masiva, GET /payments
acepta un rango de fechas from/to.
