Inbound email arrives as a signed webhook — the scheme, and the two ways people get it wrong #2
bucabay
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Shipped June 21, 2026.
Your webhook URL is public. Anyone who learns it can
POSTto it. So every inbound message MailKite delivers is signed, and verifying it takes no network call.The scheme
Each delivery carries an
x-mailkite-signatureheader with two comma-separated parts:t— when the event was signed, in milliseconds since the epoch.v1—HMAC-SHA256(secret, "${t}.${rawBody}"), lowercase hex.The key is the route's own signing secret (
whsec_…), shown on the route/webhook detail in the dashboard or fetched withgetWebhookSecret(domainId). Routes without their own secret fall back to the account-wide one, so older integrations keep working.Verifying it
Three rules, and two of them are where people get burned:
"${t}." + rawBodyand compare in constant time. Not===— a timing-safe compare.t. That's the replay window.Every SDK ships a helper so you don't hand-roll it, and it runs locally — no callback, no API call to us. Agents get the same thing as
mailkite_verify_webhookover MCP.What the payload looks like
Parsed, not raw MIME. Body, headers, attachments, all as clean JSON:
{ "from": "user@example.com", "to": "agent@myapp.ai", "subject": "Re: your verify link", "text": "this didn't work — try again?", "attachments": [] }You do not write a MIME parser. That's the whole point of the product.
When your endpoint is down
Every attempt is recorded — status, timing, and the response facts — in a delivery ledger you can read in the dashboard or over the API. The same message is also kept in queryable storage, so a webhook blip doesn't lose the mail: list it, fetch it in full, and re-deliver it to the same destination with one click (or
POST /api/deliveries/:id/retry, ormailkite_retry_deliveryfrom an agent). The retry appends to the timeline as attempt n+1 rather than overwriting history.To be straight with you about the current state: replay is something you trigger, not an automatic exponential backoff. Automatic retry with backoff is designed but not shipped — the delivery-status model landed first, deliberately, because we'd rather show you the truth about a failing endpoint than silently paper over it. If automatic backoff is blocking you, say so in Feature requests and it moves up.
Docs
If you hit a signature that won't verify, post the
tvalue, your language, and how you're reading the body in Bug reports — nine times out of ten it's rule 2 above, and we'd like to know which framework caused it so we can put it in the docs.All reactions