Notificación
Onboarding puede avisarte por webhook cuando un proceso termina, en lugar de que tengas que consultarlo. La notificación llega firmada, para que puedas comprobar que salió realmente de Onboarding y que nadie la alteró.
La notificación es una conveniencia, no una garantía. La fuente de verdad siempre es GET /api/processes/{processId}. Diseña tu integración para que funcione aunque una notificación no llegue nunca.
Cómo se configura
Envía notification.url en el cuerpo de la petición que crea o actualiza el comercio:
{
"name": "Comercializadora Acme S.A.S.",
"brand": "Acme",
"notification": {
"url": "https://acme.example.com/hooks/onboarding"
}
}
La URL debe ser pública, usar https y no llevar credenciales embebidas. Se registra por proceso, así que puedes usar direcciones distintas para operaciones distintas.
Qué recibes
Onboarding envía un POST a tu URL con tres headers propios:
El cuerpo es la misma representación que devuelve GET /api/processes/{processId}, más un objeto event que describe la entrega.
- Name
event.id- Type
- string
- is Required
- REQUERIDO
- Description
Identificador del evento. Repite el header
X-Event-Id, para que puedas deduplicar aunque solo guardes el cuerpo.
- Name
event.type- Type
- string
- is Required
- REQUERIDO
- Description
Tipo de evento. Ver la tabla siguiente.
- Name
event.time- Type
- string
- is Required
- REQUERIDO
- Description
Instante del envío. Es distinto de
status.date, que marca cuándo cambió el proceso de estado.
- Name
event.attempt- Type
- integer
- is Required
- REQUERIDO
- Description
Número de intento. Repite el header
X-Delivery-Attempt.
La clave notification no viaja en el cuerpo del webhook: describiría el estado de entrega del propio mensaje que la transporta, que en ese instante siempre sería PENDING.
Cuerpo recibido
{
"status": {
"status": "OK",
"reason": null,
"message": "The resource was created successfully.",
"date": "2026-08-17T10:14:09-05:00"
},
"processId": "01kz0609xy2hd666mnx6b0jxpc",
"action": "merchant.create",
"result": {
"merchantId": 18
},
"event": {
"id": "01kz0609xy2hd666mnx6b0jxpb",
"type": "merchant.create.succeeded",
"time": "2026-08-17T10:14:10-05:00",
"attempt": 1
}
}
El esquema completo
Cabecera
- Name
X-Signature- Type
- X-Signature
- is Required
- REQUERIDO
- Description
Firma con el formato
t=<unix>,kid=<id>,v1=<hex>, dondev1eshmac_sha256(t + "." + cuerpoCrudo, secreto).Compárala en tiempo constante, y rechaza las firmas cuyo
tquede fuera de una ventana razonable —±300 segundos es la referencia habitual—: esa comprobación anti-replay vive solo en tu lado.Ejemplo:t=1785527021,kid=k1,v1=8b0c3f9d1e...af
- Name
X-Event-Id- Type
- X-Event-Id
- is Required
- REQUERIDO
- Description
Identificador del evento, estable entre reintentos. Es la clave de deduplicación.
Ejemplo:01kz0609xy2hd666mnx6b0jxpb
- Name
X-Delivery-Attempt- Type
- X-Delivery-Attempt
- is Required
- REQUERIDO
- Description
Número de intento.
Ejemplo:1
Solicitud
Cuerpo que Onboarding envía a tu endpoint. Es la misma representación que devuelve
GET /api/processes/{processId}, más el objeto event.
- Name
status- Type
- Status
- is Required
- REQUERIDO
- Description
Estado de la petición o del proceso. Presente en todas las respuestas.
El código HTTP dice si la llamada funcionó;
status.statusdice qué pasó con el proceso.
- Name
processId- Type
- string
- is Required
- REQUERIDO
- Description
- Ejemplo:
01kz0609xy2hd666mnx6b0jxpc
- Name
action- Type
- string
- is Required
- REQUERIDO
- Description
La acción del proceso, idéntica a la que devuelve la consulta.
Valores permitidos:merchant.createmerchant.update
- Name
result- Type
- is Required
- REQUERIDO
- Description
Lo que quedó registrado. Es un objeto extensible: pueden aparecer claves nuevas sin que se considere un cambio incompatible, así que ignora las que no conozcas.
- Name
errors- Type
- object
- is optional
- Description
Presente solo en algunos fallos. Hermano de
status, nunca dentro.
- Name
event- Type
- object
- is Required
- REQUERIDO
- Description
Describe la entrega, no el proceso.
Tipos de evento
La forma del cuerpo es idéntica en los cuatro casos. Si solo te interesa una operación, filtra por familia: merchant.create.* o merchant.update.*.
No confundas action con event.type. action describe la operación del proceso (merchant.create) y es la misma que ves al consultarlo; event.type describe la entrega e incluye el desenlace. Filtra tus suscripciones por event.type.
Verificar la firma
El header X-Signature trae tres campos separados por comas:
X-Signature: t=1785527021,kid=k1,v1=8b0c3f9d1e...af
La firma se calcula sobre la concatenación del timestamp, un punto y el cuerpo crudo:
hmac_sha256(secreto, "{t}" + "." + cuerpo_crudo)
El secreto es el secreto de firma que recibiste junto con tu token. Es distinto para cada integrador, de modo que la filtración del secreto de otro no permite falsificar entregas dirigidas a ti.
Verificación
$raw = $request->getContent(); // CRUDO, nunca el cuerpo ya parseado
$header = $request->header('X-Signature', '');
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', $piece, 2), 2, null);
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
$v1 = (string) ($parts['v1'] ?? '');
// 1. ventana de tolerancia: es lo que impide el replay
if (abs(time() - $t) > 300) {
abort(400, 'stale signature');
}
// 2. recalcular sobre los mismos bytes
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
// 3. comparar en tiempo constante
if (! hash_equals($expected, $v1)) {
abort(401, 'bad signature');
}
Los tres errores que rompen la validación
- Reserializar el cuerpo. Si decodificas el JSON y lo vuelves a codificar antes de firmar, el resultado difiere por espacios, orden de claves o escapado de caracteres, y el hash deja de coincidir. Captura los bytes antes de que ningún middleware los toque. En la mayoría de frameworks esto exige desactivar el parseo automático de JSON para esa ruta.
- Firmar solo el cuerpo. Sin el prefijo
{t}., el HMAC no coincide nunca. - Comparar con
==. Una comparación normal termina antes en cuanto encuentra una diferencia, lo que filtra información por tiempo de ejecución. Usahash_equals,timingSafeEqualocompare_digest.
La ventana anti-replay
Verificar la firma demuestra dos cosas: que el mensaje salió de Onboarding y que no fue alterado en tránsito. No demuestra que sea la primera vez que llega.
Sin una comprobación adicional, una entrega capturada seguiría siendo válida indefinidamente. Por eso el timestamp t forma parte de los bytes firmados: rechazando las firmas cuyo t quede fuera de una ventana razonable —±300 segundos es una referencia habitual— una entrega antigua deja de servir.
Esa comprobación vive solo en tu lado. Onboarding emite el t firmado y no impone ninguna tolerancia, porque solo tú sabes qué desfase admite tu infraestructura. Si no la implementas, no la implementa nadie.
Que t entre en los bytes firmados no es un detalle: si viajara solo en el header sin formar parte del HMAC, cualquiera podría capturar una entrega, reescribir el timestamp y reenviarla con la firma intacta.
Rotación del secreto
El campo kid del header identifica qué secreto se usó para firmar. Sirve para que una rotación no obligue a un corte: durante la ventana de cambio, tu receptor puede aceptar dos secretos y elegir cuál usar según el kid que llegue.
Si hoy solo manejas un secreto, basta con que leas el kid y no asumas que su valor es fijo.
Qué responder
Responde 2xx en cuanto hayas verificado la firma y encolado el trabajo, no cuando termines de procesarlo. La petición corta a los 5 segundos, con 2 segundos adicionales para establecer la conexión.
Devolver un 4xx ante un problema transitorio tuyo —una base de datos momentáneamente caída, por ejemplo— es la forma más rápida de perder la notificación para siempre, porque se clasifica como rechazo definitivo. Ante una duda, responde 5xx: eso pide un reintento.
Reintentos
Son 6 intentos repartidos en unas 2 horas y 40 minutos, con esperas crecientes de 10 segundos, 1 minuto, 5 minutos, 30 minutos y 2 horas.
Agotados los seis intentos, la notificación queda marcada como fallida y no se vuelve a intentar nunca. No se emite ninguna alerta: el estado queda registrado en notification.state del proceso y en ningún otro sitio.
Deduplicación
La entrega es at-least-once: un mismo evento puede llegarte más de una vez. Ocurre, por ejemplo, si tu endpoint procesó el mensaje pero la respuesta se perdió en el camino.
Deduplica por X-Event-Id, que es estable entre reintentos del mismo evento y viene repetido dentro del cuerpo como event.id. Guarda los identificadores ya procesados y descarta los repetidos.
Si necesitas ordenar eventos de un mismo proceso, usa status.date, que marca el instante de la transición, y no event.time, que marca el del envío.
Si la notificación no llega
El comercio sigue registrado. Que la notificación falle no revierte nada: lo único que se perdió es el aviso. result.merchantId sigue disponible consultando el proceso.
Tu integración necesita una consulta de respaldo: si esperabas una notificación y no llegó en un plazo razonable, consulta GET /api/processes/{processId}. Esa responsabilidad es del integrador por diseño, y es la razón por la que el webhook se describe como una conveniencia.