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

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:

Header
Contenido
X-Signature
La firma, en el formato t=…,kid=…,v1=…
X-Event-Id
Identificador del evento. Estable entre reintentos: es tu clave de deduplicación
X-Delivery-Attempt
Número de intento, empezando en 1

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

POST
/tu-endpoint
{
  "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>, donde v1 es hmac_sha256(t + "." + cuerpoCrudo, secreto).

    Compárala en tiempo constante, y rechaza las firmas cuyo t quede 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.status dice 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

event.type
Cuándo se emite
merchant.create.succeeded
Un comercio quedó registrado
merchant.create.failed
El registro de un comercio no pudo completarse
merchant.update.succeeded
Una actualización se aplicó
merchant.update.failed
Una actualización no pudo completarse

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

Verificar la firma

El header X-Signature trae tres campos separados por comas:

X-Signature: t=1785527021,kid=k1,v1=8b0c3f9d1e...af
Campo
Qué es
t
Instante del envío, en segundos desde época Unix
kid
Identificador del secreto usado, para permitir rotaciones
v1
El HMAC-SHA256 en hexadecimal

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

  1. 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.
  2. Firmar solo el cuerpo. Sin el prefijo {t}., el HMAC no coincide nunca.
  3. Comparar con ==. Una comparación normal termina antes en cuanto encuentra una diferencia, lo que filtra información por tiempo de ejecución. Usa hash_equals, timingSafeEqual o compare_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.

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

Tu respuesta
Qué hace Onboarding
2xx
Da la entrega por hecha y no vuelve a intentar
5xx, 408, 425, 429
Reintenta con el siguiente intervalo de espera
Cualquier otro 4xx
Se detiene: interpreta que rechazaste el mensaje de forma definitiva
Timeout, error de DNS o de TLS
Reintenta

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.

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

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.

¿Qué sigue?