Webhooks tell your app what happened after a request: an email was delivered, bounced or marked as spam. Each event is an HTTPS POST with a JSON body, signed with your endpoint’s secret. Incoming email works the same way but is delivered by routes, each with its own secret: see Receiving email.
Create an endpoint
curl https://api.email/v1/webhooks \
-H "Authorization: Bearer $API_EMAIL_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://acme.com/hooks/email", "events": ["email.delivered", "email.bounced", "email.complained"]}'
The response includes the endpoint id and its secret, such as whsec_…. Store the secret like a password and use the whole string, prefix included, as the HMAC key.
Events
| Event | When |
|---|---|
email.sent |
The message left api.email for the recipient’s server. |
email.delivered |
The recipient’s server accepted it. |
email.bounced |
Delivery failed permanently; the address is added to the suppression list. |
email.complained |
The recipient marked the message as spam. |
email.opened, email.clicked |
Opens and link clicks, for emails sent with track_opens or track_clicks. |
The event payload
Every sending event has the same shape:
{
"type": "email.bounced",
"id": "evt_9a7c2e",
"email_id": "em_8f2c1a",
"to": "[email protected]",
"at": "2026-10-25T09:12:04Z",
"tags": ["password-reset"],
"reason": "550 5.1.1 mailbox does not exist"
}
id identifies the event, so use it to skip duplicates. email_id is the id returned when you sent the email; it is the same in every event about that email. reason is present on bounces and complaints.
Verify the signature
The api-email-signature header has the form t=<unix time>,v1=<hex signature>, with no spaces. The signature is HMAC-SHA256 of t + "." + raw body, keyed with your secret. For 24 hours after you rotate a secret, the header carries two signatures, t=…,v1=<new>,v1=<old>; accept the request if any of them matches. Reject timestamps older than five minutes to stop replays.
import crypto from 'node:crypto';
// header: "t=1761383643,v1=<hex>"; after a secret rotation it carries two v1 values for 24 hours.
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = String(header ?? '').split(',').map((p) => p.trim().split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const sigs = parts.filter(([k, v]) => k === 'v1' && /^[0-9a-f]{64}$/.test(v ?? '')).map(([, v]) => v);
if (!/^\d+$/.test(t ?? '') || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected));
}
import hashlib, hmac, re, time
def verify(raw: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool:
# header: "t=1761383643,v1=<hex>"; after a secret rotation it carries two v1 values for 24 hours.
t, sigs = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
sigs.append(value)
if t is None or not sigs or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
Pass the raw request bytes, before any JSON parsing: re-serialising the body changes it and breaks the signature. In Express, register express.raw() on the webhook route before any global express.json(); in Flask, use request.get_data().
Retries and replay
Respond with any 2xx status within 10 seconds. Other responses and timeouts are retried with exponential backoff for up to 24 hours. Every attempt is logged in the dashboard with its status code, and you can replay any event on every plan once your endpoint is fixed. Because an event can arrive more than once, store each event id and skip ids you have already processed.
Rotating the secret
Rotate a secret from the dashboard if it leaks. For the next 24 hours, requests carry signatures made with both the new and the old secret, so you can deploy the new secret without dropping events. The verify() functions above already accept either.