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=…"
}
}ides el identificador del evento en la cadena de evidencia; llega también en la cabeceraNotificado-Event-Id. Úselo para no procesar dos veces el mismo evento.evidence.hashyevidence.seqson 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.deliveredv1 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:
- Lea
ty todos losv1de la cabecera. - Rechace si
tdifiere de su reloj en más de 300 segundos (evita repeticiones). - 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
faileden 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.seqsi 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.