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:
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 onceLeave out events for every event. Live endpoints must use HTTPS. Herald calls them from its own egress, which refuses private and internal addresses.
Events
| Event | When |
|---|---|
email.sent | Handed to the mail servers |
email.delivered | The recipient’s server accepted it |
email.delivery_delayed | A temporary failure; Herald is still trying |
email.bounced | It can’t be delivered (the address is then suppressed) |
email.complained | The recipient reported it as spam (also suppressed) |
email.opened | Opened, where open tracking is on |
email.clicked | A link was clicked, where click tracking is on |
email.unsubscribed | The recipient unsubscribed from a Proclamation |
email.suppressed | Not sent: the recipient was suppressed already |
email.failed | Herald couldn’t send it |
email.received | Mail arrived at one of your inbound routes |
broadcast.started, .paused, .resumed, .sent, .cancelled | A broadcast’s progress |
automation.webhook | An automation reached a webhook step |
{
"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-id | The event’s id: the same on every retry, so you can drop duplicates |
webhook-timestamp | When this attempt was signed, in seconds |
webhook-signature | v1, and a base64 HMAC-SHA256 of id.timestamp.body, under your secret |
With the SDK:
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:
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.