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 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.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 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.
