Skip to content
All docs pages

Webhooks

Herald tells your app what happened to each email as it happens: delivered, bounced, complained, opened, clicked. Each event is a signed POST to an endpoint you choose.

Add an endpoint

In Console, open Webhooks, or:

TypeScript
const { data } = await herald.webhooks.create({
  url: 'https://mentor.app/api/webhooks/herald',
  events: ['email.delivered', 'email.bounced', 'email.complained'],
})
console.log(data.signing_secret) // whsec_…: shown once

Leave out events for every event. Live endpoints must use HTTPS. Herald calls them from its own egress, which refuses private and internal addresses.

Events

EventWhen
email.sentHanded to the mail servers
email.deliveredThe recipient’s server accepted it
email.delivery_delayedA temporary failure; Herald is still trying
email.bouncedIt can’t be delivered (the address is then suppressed)
email.complainedThe recipient reported it as spam (also suppressed)
email.openedOpened, where open tracking is on
email.clickedA link was clicked, where click tracking is on
email.unsubscribedThe recipient unsubscribed from a Proclamation
email.suppressedNot sent: the recipient was suppressed already
email.failedHerald couldn’t send it
email.receivedMail arrived at one of your inbound routes
broadcast.started, .paused, .resumed, .sent, .cancelledA broadcast’s progress
automation.webhookAn automation reached a webhook step
JSON
{
  "type": "email.bounced",
  "created_at": "2026-11-02T20:31:53.204Z",
  "data": {
    "email_id": "01a19dcd-361c-7342-ab15-8ef7fe4529a1",
    "from": "Mentor <hello@mentor.app>",
    "to": ["ada@example.com"],
    "subject": "Your sign-in link",
    "created_at": "2026-11-02T20:31:50.118Z",
    "tags": {},
    "recipient": "ada@example.com",
    "bounce": {
      "kind": "hard",
      "classification": "InvalidRecipient",
      "response": "550 5.1.1 The email account that you tried to reach does not exist."
    }
  }
}

Verify every request

Each request carries three headers (and the same values under Svix’s svix-* names):

Header
webhook-idThe event’s id: the same on every retry, so you can drop duplicates
webhook-timestampWhen this attempt was signed, in seconds
webhook-signaturev1, and a base64 HMAC-SHA256 of id.timestamp.body, under your secret

With the SDK:

TypeScript
import { verifyWebhook } from '@heraldmail/sdk'

const event = await verifyWebhook(rawBody, request.headers, process.env.HERALD_WEBHOOK_SECRET)

Anywhere else, it’s a few lines. Use the raw body exactly as it arrived, and refuse timestamps more than five minutes old:

Python
import base64, hashlib, hmac, time

def verify(body: bytes, headers, secret: str) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    mac = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    expected = base64.b64encode(mac).decode()
    return any(
        hmac.compare_digest(expected, sig.split(",", 1)[1])
        for sig in headers["webhook-signature"].split()
        if sig.startswith("v1,")
    )

Retries

Answer with any 2xx within 10 seconds. Anything else, or no answer, is tried again: after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5, 10, 14, 20 and 24 hours (about three days in all). An endpoint that has failed every attempt for five days is turned off; turn it back on in Console when it’s fixed, and replay what it missed. Console shows every attempt, and can send a test event.

Rotating the secret

Make a new secret in Console, or with herald.webhooks.rotateSecret(id). For 24 hours, Herald signs with both, so the header carries two signatures: deploy the new secret, and either verifies.