Webhooks

Verify avisa a tu backend cuando una verificación termina, y cómo fue cada paso de canal. Cada evento va firmado con Standard Webhooks y se reintenta hasta dos días.

Para qué sirven

La respuesta de comprobación te dice si el código es correcto. Pero hay finales que ocurren sin una llamada tuya: la verificación vence o se agotan los canales. El webhook te avisa de esos finales. Si quieres, también te cuenta cómo fue cada paso de canal por el camino.

Eventos

EventoCuándo
verification.approvedEl usuario escribió el código correcto.
verification.expiredVenció sin una comprobación correcta.
verification.failedTerminó sin aprobarse. data.reason dice por qué: max_attempts_reached o cascade_exhausted.
verification.channel.sentUn canal aceptó el mensaje.
verification.channel.deliveredEl operador confirmó la entrega. Hoy solo lo reporta SMS.
verification.channel.failedUn canal rechazó el envío, o el operador reportó que no pudo entregarlo.

Recibes exactamente uno de los tres primeros por verificación, cuando llega a su estado final. Los eventos de canal llegan mientras está en curso, uno por envío; un endpoint suscrito a todos los tipos de evento los recibe todos.

Qué recibes

Un POST a tu URL con este cuerpo:

{
  "id": "evt_01M2K81GC7W06ZRKDS9K21QNDJ",
  "type": "verification.failed",
  "occurred_at": "2026-09-29T14:37:05.000Z",
  "sequence": null,
  "data": {
    "verification_id": "ver_01J9XQ4M7K2VBN3P",
    "application_id": "app_01J9XQ2B8Y6T4R1M0K9H7G5F3D",
    "environment": "live",
    "status": "max_attempts_reached",
    "reason": "max_attempts_reached",
    "client_reference": "signup-8812"
  }
}

reason solo aparece en verification.failed. client_reference es el valor que enviaste al crear la verificación.

En los eventos de canal, data describe el envío:

{
  "id": "evt_01M2K81GC7W06ZRKDS9K21QNDK",
  "type": "verification.channel.failed",
  "occurred_at": "2026-09-29T14:32:06.000Z",
  "sequence": null,
  "data": {
    "verification_id": "ver_01J9XQ4M7K2VBN3P",
    "application_id": "app_01J9XQ2B8Y6T4R1M0K9H7G5F3D",
    "environment": "live",
    "attempt_id": "atm_01J9XQ4M8A1B2C3D4E5F6G7H8J",
    "channel": "whatsapp",
    "step": 0,
    "attempt": 0,
    "send_state": "rejected_definitive"
  }
}
CampoQué significa
channel, stepEl canal y su posición en la cascada, empezando en 0.
attempt0 en el primer envío de ese paso; cada reenvío suma uno.
send_stateEn channel.sent, siempre accepted. En channel.failed, por qué falló el envío: rejected_definitive, unavailable_transient, channel_unavailable o denied.
delivery_stateSolo cuando el operador reportó: delivered en channel.delivered; failed_definitive o failed_transient en channel.failed.

Un channel.failed no significa que la verificación falló: la cascada puede seguir por el siguiente canal. Por correo, un envío incierto también llega como unavailable_transient mientras la verificación sigue pending. client_reference solo viaja en los eventos finales.

Con estos encabezados:

content-type: application/json
webhook-id: evt_01M2K81GC7W06ZRKDS9K21QNDJ
webhook-timestamp: 1790693825
webhook-signature: v1,g0hM9SsE+OTHF9QDLvhRAmMOCQ8Qm9ZQnJwoUCP0+Tk=

Verificar la firma

La firma sigue Standard Webhooks: un HMAC-SHA256 en base64 sobre webhook-id.webhook-timestamp.cuerpo. La clave es el secreto sin el prefijo whsec_, decodificado de base64.

import { createHmac, timingSafeEqual } from 'node:crypto';

export function isValidWebhook(rawBody: string, headers: Headers, secret: string): boolean {
  const id = headers.get('webhook-id');
  const timestamp = headers.get('webhook-timestamp');
  const signatures = headers.get('webhook-signature');
  if (!id || !timestamp || !signatures) return false;

  // Rechaza eventos de hace más de 5 minutos: frena reenvíos maliciosos.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();

  // Durante una rotación llegan dos firmas separadas por un espacio.
  return signatures.split(' ').some((candidate) => {
    const [version, signature] = candidate.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
}
# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["VERIFY_WEBHOOK_SECRET"])  # whsec_…

# Lanza una excepción si la firma o la marca de tiempo no son válidas.
event = webhook.verify(raw_body, request.headers)
🚧

Atención

Verifica la firma sobre el cuerpo crudo, tal como llegó. Si tu framework ya lo convirtió a JSON y lo vuelves a serializar, la firma no coincidirá.

Responder y reintentos

  • Responde con un 2xx en menos de 5 segundos. Si tienes trabajo pesado, encólalo y responde enseguida.
  • Un 408, 425, 429, un 5xx, un timeout o un error de red, DNS o TLS se reintenta hasta 7 veces: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h después del intento anterior, con una variación de ±20 %, unas 45 horas en total. Después, el evento se descarta. Si respondes Retry-After, podemos adelantar el reintento, nunca atrasarlo.
  • Cualquier otro 3xx o 4xx detiene los reintentos. Las redirecciones no se siguen.
  • Un mismo evento puede llegar más de una vez: descarta los repetidos por su webhook-id.
  • Los eventos pueden llegar desordenados: usa occurred_at. sequence está reservado y por ahora es null.
  • Si tu endpoint sigue fallando, las entregas hacia él se pausan un rato, hasta una hora, y se reanudan solas.
  • Tu URL debe resolver a una dirección pública; una dirección privada o de loopback se rechaza sin reintentos.

Registrar tu endpoint

Hoy el equipo de Inalambria registra tu URL: debe ser https, y se registra para un solo entorno, live o test. La misma URL puede registrarse una vez por entorno, cada una con su propio secreto. Un endpoint test solo recibe eventos del entorno de pruebas, y cada evento dice su entorno en data.environment. También puedes elegir qué tipos de evento recibe; si no eliges, recibe todos, eventos de canal incluidos.

Al registrarla recibes el secreto whsec_… una sola vez; guárdalo en tu gestor de secretos.

Cuando el secreto rota, durante 24 horas cada evento llega firmado con el secreto nuevo y con el anterior. El código de arriba acepta cualquiera de las dos.


Did this page help you?