Webhooks — Resumen
Los webhooks son la señal autoritativa. Los callbacks del
navegador (onSuccess) y las vistas del dashboard son conveniencia;
los webhooks son la verdad de base.
Garantías de entrega
- Al menos una vez. Un solo evento puede entregarse hasta
6 veces si tu servidor no devuelve 2xx dentro del timeout.
Haz tu handler idempotente: deduplica por
X-Delivery(el payload no tiene un campoevent_id; el UUID estable de la entrega es la clave de idempotencia). - Un evento por petición HTTP. Sin batching.
- Aislamiento por endpoint. Si tienes múltiples endpoints registrados, cada uno obtiene su propia pista de entrega + reintento. Una URL de comerciante lenta no puede hambrear a las otras: cada host tiene su propio circuit breaker.
- Firmado. Cada payload lleva una cabecera
X-Signature(y durante la ventana de 24 horas tras una rotación, también unaX-Signature-Prev). Verifica antes de hacer nada con el body. Consulta Verificación de firma.
Tipos de evento suscribibles
| Evento | Se dispara cuando… |
|---|---|
payment.settled | La transferencia on-chain alcanzó el conteo de confirmaciones de la cadena. Usa esto para marcar órdenes como pagadas. |
payment.failed | Un pago fiat fue explícitamente rechazado por el proveedor (actualmente: webhook de Stripe señalando fallo). No se dispara para timeouts cripto: esos aparecen como checkout.expired, y los pagos cripto cortos aparecen como payment.underpaid. |
payment.underpaid | Los fondos llegaron pero por debajo del total de la orden (típico: tarifa de transferencia de stablecoin tomada del importe). |
payment.overpaid | Los fondos llegaron en exceso del total de la orden. El sobrante se registra pero no se auto-reembolsa. |
order.created | Se abrió una nueva orden: o por tu llamada a la API B2B o por una conversión de sesión de checkout. |
order.canceled | Una orden se movió a cancelada. El data.reason del payload distingue cancelación manual de payment_timeout (orden no pagada obsoleta barrida por el worker). |
order.resolved | Una orden PARTIAL_PAID se resolvió a PAID: el comerciante aceptó el déficit. |
order.reopened | Una orden previamente autocancelada (canceled_reason=payment_timeout) fue reabierta por el comerciante. |
checkout.created | Un comprador abrió el checkout para una orden. |
checkout.completed | El flujo del lado comprador terminó (no implica liquidación on-chain: usa payment.settled para eso). |
checkout.expired | El comprador abandonó y el TTL de la sesión se agotó. |
payment.refund.requested | Se creó un registro de reembolso: o desde una llamada a la API iniciada por el comerciante o desde un formulario de solicitud de reembolso enviado por el cliente. |
payment.refund.approved | Un reembolso pendiente pasó tu flujo de aprobación. |
payment.refund.rejected | Un reembolso pendiente fue denegado. |
payment.refund.executed | La transferencia on-chain del reembolso se confirmó y el registro se movió a executed terminal. |
refund_request.created | Se emitió un token de solicitud de reembolso. data.source es b2b / dashboard / renewal. Opcional para suscribirse: útil para pipelines de auditoría que rastrean qué token está actualmente activo por orden. |
refund_request.renewal_requested | Un comprador hizo clic en “Solicitar nuevo enlace” tras expirar su token. Fuertemente recomendado para suscribirse: esta es la señal para el comerciante de que el widget de renovaciones tiene un nuevo item sobre el que actuar. |
refund_request.renewed | Una renovación fue aprobada y un nuevo token reemplazó al antiguo. data.old_token / data.new_token forman la cadena de auditoría. |
refund_request.canceled | Un comerciante cambió un token a CANCELED desde el dashboard (p. ej. denegó una solicitud de renovación, mató un enlace activo). Idempotente: solo la primera transición emite. data.reason es la nota opcional del comerciante. |
El dashboard obtiene esta lista de GET /v1/webhooks/event-types
para que el formulario de creación / edición de endpoints siempre
coincida con lo que la plataforma realmente emite. Suscribirse a un
evento que no enviamos será rechazado en el momento de la creación
con un error claro.
Los eventos de prueba no son suscribibles. El botón
Enviar prueba por endpoint del dashboard hace POST de un
envelope webhook.test.ping síncronamente a ese único endpoint
(saltándose el pipeline de reintentos), y la ruta heredada
“Enviar evento de prueba” a nivel de comerciante hace fan-out de
un envelope webhook.test a cada endpoint activo
independientemente de su filtro. Ninguno aparece en el catálogo
de arriba: los recibes por tener un endpoint registrado, no por
suscribirte.
Suscríbete solo a los eventos que manejas. Cada endpoint tiene su
propio filtro de eventos; el wildcard "*" significa “cada
evento, incluyendo los añadidos en el futuro”. Suscribirse a menos
eventos mantiene tu handler más limpio y reduce la superficie
sobre la que tenemos que reintentar en errores.
Payload + cabeceras
El body HTTP es el objeto data específico del evento directamente.
Sin envelope externo estilo Stripe: campos como el tipo de evento,
delivery ID y timestamp de emisión viven en cabeceras en su
lugar. Para payment.settled el body se ve así:
{
"receipt_id": "rcp_…",
"order_id": "ord_…",
"payment_intent_id": "pin_…",
"checkout_session_id": "cst_…",
"merchant_id": "mer_…",
"customer_id": "cus_…",
"total": "49.00",
"currency": "USD",
"payment_method": "crypto",
"token": "USDC",
"network": "polygon",
"tx_hash": "0x…",
"deposit_address": "0x…",
"treasury_address": "0x…",
"amount_received": "49.00",
"confirmations": 5,
"metadata": { /* per-event */ }
}Otros eventos llevan su propio set de campos: consulta los structs
de publisher en payment-service/internal/domain/events.go para la
forma canónica hasta que aterricen los docs por evento. Los nombres
de campo son estables (lower snake_case); el tx hash on-chain es
siempre tx_hash (no transaction_hash).
Cabeceras en la petición entrante
Content-Type: application/json
X-Event: payment.settled
X-Delivery: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp: 1729536000
X-Signature: sha256=9a8b7c…
X-Signature-Prev: sha256=fa31b2… (solo durante la ventana de gracia de rotación)| Cabecera | Qué es |
|---|---|
X-Event | El tipo de evento (p. ej. payment.settled). Enruta sobre esto en la capa proxy si quieres saltarte el parsing JSON. |
X-Delivery | UUID que identifica la fila de entrega. Estable a través de todos los reintentos del mismo par (evento, endpoint): úsalo como tu clave de idempotencia. |
Idempotency-Key | Refleja X-Delivery (mismo valor). Se establece en cada entrega: toma convención de Stripe / GitHub. |
X-Timestamp | Unix-segundos cuando se envió el intento. Firmado en el payload para que un par (body, X-Signature) capturado no pueda repetirse indefinidamente: rechaza entregas cuyo timestamp esté fuera de tu ventana de tolerancia. |
X-Signature | sha256=<hex> de HMAC-SHA256(secret, X-Timestamp + "." + raw_body). Consulta Verificación de firma. |
X-Signature-Prev | Mismo algoritmo con el secreto anterior. Presente solo en la ventana de 24 horas tras rotar: deja que los verificadores corriendo cualquiera de las claves sigan aceptando entregas durante el corte. Tras cerrar la ventana, la cabecera deja de enviarse. |
Calendario de reintentos
Si tu endpoint no devuelve 2xx dentro del timeout, reintentamos
en este calendario (timestamps relativos al primer intento):
| Intento | Retraso | Acumulado |
|---|---|---|
| 1 | 0s | 0s |
| 2 | +1 min | 1m |
| 3 | +5 min | 6m |
| 4 | +15 min | 21m |
| 5 | +1 hora | 1h 21m |
| 6 | +6 horas | 7h 21m |
Tras fallar el intento 6, la entrega se mueve a dead letter y
se notifica al email de la cuenta del comerciante. Los eventos
dead-lettered pueden reproducirse desde el panel Developers →
Webhooks → Delivery history del dashboard, o directamente vía
POST /v1/webhooks/deliveries/:id/replay. Cada replay crea una
nueva fila de entrega con su propio X-Delivery: la cadena de
auditoría se vincula de vuelta al original vía
parent_delivery_id para que los reintentos-de-replays no
ensombrezcan el evento fuente.
Registra un endpoint
Desde el dashboard del comerciante :
- Developers → Webhooks → + Add endpoint
- Pega tu URL: solo
https://…(HTTP plano se rechaza; el formulario de creación también bloquealocalhost, rangos de IP privadas y URLs que lleven userinfo) - Elige eventos a los que suscribirte (o
*para todos) - Elige entorno: test o live (cada uno tiene su propio secreto; nunca se cruzan)
- Guarda → el dashboard muestra el secreto de firma (
whsec_…) una vez. Guárdalo en el lado del servidor; lo necesitarás para las próximas dos funciones.
Puedes registrar hasta 10 endpoints por entorno por comerciante (p. ej., uno para fulfillment de producción, uno para staging mirroring, uno para un notificador de Slack). Cada uno mantiene su propio estado de reintento, secreto y circuit breaker por host.
Acciones de ciclo de vida en cada endpoint
El menú ⋮ en cada tarjeta de endpoint muestra:
- Edit: cambia la URL, descripción o lista de suscripciones.
La nueva URL se revalida con las mismas reglas de
https:///SSRF que en la creación. - Send Test: POSTea síncronamente un envelope
webhook.test.pingfirmado con tu secreto actual. El dashboard muestra el status HTTP, latencia y un fragmento de 512 bytes de tu respuesta. Salta el pipeline de RMQ para que la respuesta sea inmediata. - Rotate Secret: genera un nuevo secreto. El anterior sigue
válido por 24 horas (las entregas llevan tanto
X-SignaturecomoX-Signature-Prevdurante la ventana para que los verificadores corriendo cualquiera de las claves sigan aceptando eventos mientras redespliegas). - Reveal Secret: muestra de nuevo el secreto existente. Protegido por verificación 2FA reciente y registrado en el log de auditoría; úsalo solo cuando hayas perdido tu copia y Rotate no sea aceptable.
- Enable / Disable: cambia
is_activesin perder el historial de entregas. Los endpoints deshabilitados permanecen en el dashboard pero no reciben nuevas entregas. - Delete: permanente. Usa Disable si podrías re-habilitar más tarde.
Consejos para handlers
- Devuelve 2xx rápido. Confirma con
200 OKantes de hacer trabajo pesado: lanza el fulfillment a una cola en background. El timeout por intento es de 10 segundos; retener la respuesta más allá de eso dispara un reintento. El timeout es del lado de la plataforma y no es configurable por comerciante: contacta con soporte si tu handler genuinamente necesita más tiempo. - Deduplica por
X-Delivery(oIdempotency-Key: mismo valor). Aunque devuelvas 2xx, un proxy aguas arriba podría soltar la conexión y disparar un reintento; el delivery ID es estable a través de cada reintento de la misma fila de entrega, así que es la clave correcta. - Tolera tipos de evento desconocidos. Pueden aparecer nuevos eventos; devuelve 200 y no-op en lugar de 4xx, o llenarás la cola de reintentos.
- Loguea
X-Deliveryjunto a tu lógica de negocio. Cuando algo va mal, esa es la clave de join entre nuestro lado y el tuyo.
Qué sigue
- Verificación de firma: el algoritmo exacto + patrones de protección contra replay.
- Conceptos → Sesiones: en qué estado está una sesión cuando cada evento se dispara.