Saltar al contenido
Notificado

Webhooks

Reciba cada envío, entrega, rebote, acuse y constancia como una petición firmada, y verifique la firma en Node, Bun o Python.

Actualizado:

Un webhook es una petición POST que Notificado hace a una URL suya cada vez que una notificación avanza: salió, fue entregada, rebotó, el destinatario acusó recibo, se emitió la constancia. Así su ERP, su CRM de cobranza o su hoja de control se actualizan sin consultar la API a cada rato.

Solo los propietarios de la firma administran los webhooks, en Cuenta → API → Webhooks (/cuenta/api/webhooks), o un agente con el token de un propietario y el permiso webhooks:manage (herramientas createWebhookEndpoint, updateWebhookEndpoint, rotateWebhookSecret, deleteWebhookEndpoint, sendTestWebhook, listWebhookEndpoints y listWebhookDeliveries).

Qué URL se acepta

Regla Por qué
Solo https:// El cuerpo y la firma viajan cifrados.
Solo el puerto 443 (el de https, sin escribirlo) Es el único puerto de salida abierto en nuestra red: otro puerto nunca llegaría.
Un dominio público Rechazamos localhost, *.local, *.internal, nombres sin punto y cualquier dominio que resuelva a una dirección privada (10/8, 172.16/12, 192.168/16), de bucle local, de enlace local (169.254/16), CGNAT (100.64/10) o IPv6 no pública.
Sin usuario ni contraseña en la URL Autentique con la firma, no con la URL.

Revisamos el dominio al registrarlo y antes de cada entrega, contra todas las direcciones que resuelve. Si su DNS cambia a una dirección privada, la entrega se marca url_refused y se reintenta según el calendario de abajo.

Eventos

Evento Cuándo
notification.sent Nuestro proveedor de envío de correo aceptó el mensaje de un destinatario para enviarlo.
delivery.failed No fue posible entregarlo al servidor de salida.
delivery.delivered El servidor de correo del destinatario lo aceptó.
delivery.bounced El servidor del destinatario lo rechazó (rebote o rechazo).
delivery.complained El destinatario lo marcó como no deseado.
recipient.opened Una apertura o un clic: un indicio, nunca prueba de lectura (data.indicio es true).
recipient.downloaded El destinatario descargó un documento desde su enlace.
recipient.acknowledged El destinatario acusó recibo.
recipient.replied El destinatario respondió desde su dirección.
constancia.issued Se firmó la constancia de entrega.

La fecha en que la notificación surte efectos no es un evento: es un término calculado (días hábiles, cierres del juzgado) que puede recalcularse. Consúltela con listDeadlines después de recipient.acknowledged o recipient.downloaded.

Un endpoint nuevo recibe los eventos que ocurren desde su registro; el historial no se reenvía.

El cuerpo

{
  "id": "0192f0aa-5b1e-7c3d-9e2f-0a1b2c3d4e5f",
  "type": "delivery.delivered",
  "apiVersion": "2026-09-28",
  "occurredAt": "2026-09-28T14:03:11.412Z",
  "recordedAt": "2026-09-28T14:03:12.087Z",
  "orgId": "…",
  "data": {
    "notificationId": "…",
    "recipientId": "…",
    "caseId": "…",
    "indicio": false
  },
  "evidence": {
    "eventId": "0192f0aa-5b1e-7c3d-9e2f-0a1b2c3d4e5f",
    "seq": "48213",
    "type": "mail.delivery",
    "hash": "9f2c…",
    "prevHash": "41ab…"
  },
  "links": {
    "notification": "https://www.notificado.co/v1/get-notification?notificationId=…&orgId=…"
  }
}
  • id es el identificador del evento en la cadena de evidencia; llega también en la cabecera Notificado-Event-Id. Úselo para no procesar dos veces el mismo evento.
  • evidence.hash y evidence.seq son los del registro en la cadena: puede contrastarlos con el paquete de evidencia o con /verificar.
  • El cuerpo no trae direcciones, nombres ni el texto del mensaje. Los detalles están a una consulta autenticada: links.notification.
  • Las fechas van en UTC (ISO 8601). Muéstrelas en America/Bogota.

La firma

Cada petición trae tres cabeceras:

Notificado-Signature: t=1790604191,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Notificado-Event-Id: 0192f0aa-5b1e-7c3d-9e2f-0a1b2c3d4e5f
Notificado-Event-Type: delivery.delivered

v1 es el HMAC-SHA256, en hexadecimal, del texto t + . + el cuerpo tal como llegó (los bytes, sin volver a serializar el JSON), con su secreto whsec_… como clave. Para verificar:

  1. Lea t y todos los v1 de la cabecera.
  2. Rechace si t difiere de su reloj en más de 300 segundos (evita repeticiones).
  3. Calcule el HMAC y compárelo en tiempo constante con cada v1. Basta con que uno coincida.

Node o Bun

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

export function verify(header: string, rawBody: string, secret: string, now = Date.now()): boolean {
  const parts = header.split(',').map((p) => p.trim().split('='));
  const t = Number(parts.find(([k]) => k === 't')?.[1]);
  const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v ?? '');
  if (!Number.isInteger(t) || Math.abs(now / 1000 - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return signatures.some(
    (s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
}

Lea el cuerpo como texto antes de convertirlo en JSON (en Express, express.raw({ type: 'application/json' }); en Bun, await request.text()).

Python

import hashlib, hmac, time

def verify(header: str, raw_body: bytes, secret: str) -> bool:
    parts = [p.strip().split("=", 1) for p in header.split(",")]
    t = next((v for k, v in parts if k == "t"), "")
    signatures = [v for k, v in parts if k == "v1"]
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s, expected) for s in signatures)

Respuestas y reintentos

  • Responda con cualquier código 2xx en menos de 10 segundos. Procese después, en segundo plano, si lo necesita.
  • Una respuesta 3xx, 4xx o 5xx, un tiempo agotado o una conexión fallida es un intento fallido. No seguimos redirecciones.
  • Cada evento se intenta hasta 8 veces: la primera en el minuto siguiente al evento y luego tras 1 min, 5 min, 30 min, 1 h, 3 h, 7 h y 12 h (unas 24 horas en total). Después queda como failed en el registro de entregas.
  • El mismo evento puede llegar más de una vez (por ejemplo, si su respuesta se perdió): deduplique por Notificado-Event-Id.
  • El orden de llegada no está garantizado. Ordene por evidence.seq si lo necesita.

Pausa automática

Si todas las entregas a un endpoint fallan durante un día (y al menos 10 intentos seguidos), lo detenemos y escribimos a los propietarios. Nada se pierde: los eventos siguen registrándose y, cuando corrija el endpoint y lo reanude, recibirá los que quedaron pendientes. Antes de reanudar, use Enviar prueba: manda un evento webhook.test (sin referencia a la cadena) y muestra la respuesta.

Cambiar el secreto

Cambiar secreto muestra un secreto nuevo una sola vez. Durante 24 horas cada petición lleva dos firmas v1, con el secreto nuevo y con el anterior, para que actualice su sistema sin perder entregas. Después, solo el nuevo.

Lo que un webhook no es

Un webhook es un aviso. La prueba de la entrega es la cadena de evidencia, la constancia firmada y el paquete de evidencia, que un perito verifica sin depender de nosotros. Si su sistema no recibió un aviso, la evidencia sigue completa.