ScanMyPassKhatdocs
HomeSign in

Get started

  • Setup
  • DNS records

Reference

  • Sending mail
  • Receiving mail
  • Delivery & webhooks
  • Errors & limits
  • Security

For agents

  • llms.txt

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#

ClassStatusWhat has happened
—queuedAccepted by us. Nothing said on the wire yet.
—processingBeing handed to the mail server.
2xxsentOur mail server accepted it and passed it on.
2xxdeliveredThe recipient's server accepted it.
4xxdeferredTransient — greylisting, a rate limit. Still being retried.
5xxbouncedPermanently rejected. The address is suppressed.
5xxfailedCould not be sent at all — no delivery was attempted.
5xxcomplainedThe 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#

GET/v1/emails/{id}API key

The message with its full event timeline, per-recipient outcomes and the raw SMTP response.

GET/v1/emailsAPI key

?status=, ?recipient=, ?limit=, ?cursor=.

response
{
  "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.

EventWhen
email.sentOur mail server accepted the message.
email.deliveredThe recipient's server accepted it.
email.bouncedPermanently rejected.
email.failedCould not be sent.
email.complainedMarked as spam.
email.receivedInbound mail arrived for one of your domains.

Verifying the signature

Three headers arrive with every delivery:

http
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.

Express
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 2xx is 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.
NextErrors & limitsEvery error code, and how the allowance is counted.