Webhooks
Receive each send, delivery, bounce, acknowledgement and constancia as a signed request, and verify it in Node, Bun or Python.
Updated:
A webhook is a POST request Notificado makes to a URL of yours each time a notification moves forward: it left, it was delivered, it bounced, the recipient acknowledged receipt, the constancia was issued. Your ERP, your collections CRM or your tracking sheet stays current without polling the API.
Only the firm's owners manage webhooks, at Account → API → Webhooks (/cuenta/api/webhooks), or an agent holding an owner's token with the webhooks:manage scope (tools createWebhookEndpoint, updateWebhookEndpoint, rotateWebhookSecret, deleteWebhookEndpoint, sendTestWebhook, listWebhookEndpoints and listWebhookDeliveries).
Which URLs are accepted
| Rule | Why |
|---|---|
https:// only |
The body and the signature travel encrypted. |
| Port 443 only (https's own, left unwritten) | It is the only port that leaves our network: any other port would never arrive. |
| A public domain | We refuse localhost, *.local, *.internal, names without a dot, and any domain that resolves to a private address (10/8, 172.16/12, 192.168/16), loopback, link-local (169.254/16), CGNAT (100.64/10) or non-public IPv6. |
| No user name or password in the URL | Authenticate with the signature, not the URL. |
We check the domain when you register it and before every delivery, against every address it resolves to. If your DNS moves to a private address, the delivery is marked url_refused and retried on the schedule below.
Events
| Event | When |
|---|---|
notification.sent |
Our mail delivery provider accepted a recipient's message for sending. |
delivery.failed |
It could not be handed to the outgoing mail server. |
delivery.delivered |
The recipient's mail server accepted it. |
delivery.bounced |
The recipient's server refused it (bounce or reject). |
delivery.complained |
The recipient marked it as unwanted mail. |
recipient.opened |
An open or a click: an indicio, never proof of reading (data.indicio is true). |
recipient.downloaded |
The recipient downloaded a document from their link. |
recipient.acknowledged |
The recipient acknowledged receipt. |
recipient.replied |
The recipient answered from their address. |
constancia.issued |
The constancia de entrega was signed. |
The date a notification takes effect (surte efectos) is not an event: it is a computed term (business days, court closures) that can be recomputed. Read it with listDeadlines after recipient.acknowledged or recipient.downloaded.
A new endpoint receives the events that happen from its registration on; history is not replayed.
The body
{
"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=…"
}
}idis the event's id on the evidence chain; it also arrives in theNotificado-Event-Idheader. Use it to never process the same event twice.evidence.hashandevidence.seqare the chain record's own: you can check them against the evidence package or/verificar.- The body carries no addresses, names or message text. The details are one authenticated call away:
links.notification. - Times are UTC (ISO 8601). Show them in
America/Bogota.
The signature
Every request carries three headers:
Notificado-Signature: t=1790604191,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Notificado-Event-Id: 0192f0aa-5b1e-7c3d-9e2f-0a1b2c3d4e5f
Notificado-Event-Type: delivery.deliveredv1 is the hex HMAC-SHA256 of the text t + . + the body exactly as received (the bytes, never a re-serialized JSON), keyed with your whsec_… secret. To verify:
- Read
tand everyv1from the header. - Refuse when
tis more than 300 seconds from your clock (it stops replays). - Compute the HMAC and compare it in constant time with each
v1. One match is enough.
Node or 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)),
);
}Read the body as text before parsing it as JSON (in Express, express.raw({ type: 'application/json' }); in 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)Answers and retries
- Answer with any 2xx code within 10 seconds. Do the processing afterwards, in the background, if you need to.
- A 3xx, 4xx or 5xx answer, a timeout or a failed connection is a failed attempt. We do not follow redirects.
- Each event is tried up to 8 times: first within the minute after the event, then after 1 min, 5 min, 30 min, 1 h, 3 h, 7 h and 12 h (about 24 hours in all). Then it stays
failedin the delivery log. - The same event can arrive more than once (for instance when your answer was lost): deduplicate on
Notificado-Event-Id. - Arrival order is not guaranteed. Sort by
evidence.seqwhen you need order.
Automatic pause
When every delivery to an endpoint fails for a day (and at least 10 attempts in a row), we stop it and write to the owners. Nothing is lost: events keep being recorded, and when you fix the endpoint and resume it you receive the ones that waited. Before resuming, use Send test: it posts a webhook.test event (with no chain reference) and shows the answer.
Changing the secret
Change secret shows a new secret once. For 24 hours every request carries two v1 signatures, one with the new secret and one with the previous, so you can update your system without losing a delivery. After that, only the new one.
What a webhook is not
A webhook is a notice. The proof of delivery is the evidence chain, the signed constancia and the evidence package, which a perito verifies without depending on us. If your system missed a notice, the evidence is still complete.