Webhooks

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.

Get API key

Create your api.email account

What do you need?

Early access: accounts open in batches. By continuing you agree to the Terms and Privacy Policy.