Reference
Delivery & webhooks
Delivery is confirmed from the mail server's own log rather than assumed, which is why there are two statuses where most services report one.
What each status means
| Class | Status | What has happened |
|---|---|---|
| — | queued | Accepted by us. Nothing said on the wire yet. |
| — | processing | Being handed to the mail server. |
| 2xx | sent | Our mail server accepted it and passed it on. |
| 2xx | delivered | The recipient's server accepted it. |
| 4xx | deferred | Transient — greylisting, a rate limit. Still being retried. |
| 5xx | bounced | Permanently rejected. The address is suppressed. |
| 5xx | failed | Could not be sent at all — no delivery was attempted. |
| 5xx | complained | The recipient marked it as spam. |
sent and delivered are different claims
Plenty of services report the first and call it the second. sent means we handed the message on successfully; delivered means the far end took it. Both are shown, and the class column is the SMTP reply class — which is how anyone reading a mail log already thinks.
A message that defers and then delivers ends as delivered. Deferral is transient and common — greylisting alone accounts for most of it — so it is not a final state.
Reading it
/v1/emails/{id}API keyThe message with its full event timeline, per-recipient outcomes and the raw SMTP response.
/v1/emailsAPI key?status=, ?recipient=, ?limit=, ?cursor=.
{
"id": "email_9f3k2m1x8b7c4d5e6a0z",
"status": "delivered",
"smtpResponse": "250 2.0.0 OK 1786969756 6a1803df08f44 - gsmtp",
"recipients": [
{ "email": "customer@gmail.com", "status": "delivered", "deliveredAt": "…" }
],
"events": [
{ "type": "queued", "message": "Accepted by the API" },
{ "type": "sent", "message": "250 2.0.0 Ok: queued as 42B441982D7" },
{ "type": "delivered", "message": "250 2.0.0 OK … - gsmtp" }
]
}Bounces and suppression
Bounce reports are parsed to RFC 3464 and complaint reports to RFC 5965, then classified:
- Hard — the address does not exist. Suppressed immediately, permanently.
- Soft — a full mailbox, a temporary refusal. Retried; suppressed only if it keeps happening.
- Complaint — marked as spam. Suppressed, because mailing them again is how you lose a domain.
A suppressed address is refused at send time with SUPPRESSED_RECIPIENT, and checked again in the worker in case the list changed while the message sat in the queue. You can view and remove entries under Suppressions — though removing a hard bounce usually just produces another one.
Webhooks
Six events. Add an endpoint under Webhooks, choose which ones you want, and each is delivered independently with retries and exponential backoff.
| Event | When |
|---|---|
| email.sent | Our mail server accepted the message. |
| email.delivered | The recipient's server accepted it. |
| email.bounced | Permanently rejected. |
| email.failed | Could not be sent. |
| email.complained | Marked as spam. |
| email.received | Inbound mail arrived for one of your domains. |
Verifying the signature
Three headers arrive with every delivery:
X-Mail-Signature: t=1786969756,v1=<hmac-sha256-hex>
X-Mail-Event: email.delivered
X-Mail-Delivery-Id: <delivery id>The signature is HMAC-SHA256 over `${timestamp}.${rawBody}` using your endpoint's secret, with a 300-second replay window.
import crypto from "node:crypto";
// The RAW body, not a re-serialised object — JSON.stringify will not
// reproduce the bytes that were signed.
app.post("/webhooks/email", express.raw({ type: "*/*" }), (req, res) => {
const header = req.get("X-Mail-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const ok =
expected.length === (parts.v1 ?? "").length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
if (!ok) return res.sendStatus(401);
// Respond before doing the work — a slow handler looks like a dead endpoint.
res.sendStatus(200);
void handle(JSON.parse(req.body.toString("utf8")));
});Two things that break signature checks
Use the raw body. Parsing the JSON and re-stringifying it changes the bytes, and the signature will never match.
Compare in constant time. A plain === on an HMAC leaks timing information — that is what timingSafeEqual is for.
Delivery guarantees
- Any
2xxis success. Anything else is retried with backoff. - An endpoint that keeps failing is disabled automatically, and every attempt is kept in its delivery history so you can see why.
- Delivery is at-least-once. Make your handler idempotent — key on the message id.