Skip to content

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=…"
  }
}
  • id is the event's id on the evidence chain; it also arrives in the Notificado-Event-Id header. Use it to never process the same event twice.
  • evidence.hash and evidence.seq are 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.delivered

v1 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:

  1. Read t and every v1 from the header.
  2. Refuse when t is more than 300 seconds from your clock (it stops replays).
  3. 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 failed in 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.seq when 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.