Receiving webhooks
A webhook endpoint is a URL of yours that mailstein posts to when something happens. It is the only way to learn about a bounce or a complaint without polling.
Creating one
curl -X POST https://app.mailstein.com/api/v1/webhooks \
-H "Authorization: Bearer $MAILSTEIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/hooks/mailstein",
"eventTypes": ["email.bounced", "email.complained", "email.delivered"]
}'
The response contains a secret. It is shown once, here and on rotation, and
cannot be read back. Store it before you close the connection.
Subscribe to what you will act on. email.bounced and email.complained are
the two that matter — everything else is telemetry.
Verifying a delivery
Every delivery carries two headers:
X-Mailstein-Timestamp: 1786313285123
X-Mailstein-Signature: v1=6a3f...c92
The signature is HMAC-SHA256 over "<timestamp>.<raw body>", keyed with the
endpoint secret, hex-encoded.
import { createHmac, timingSafeEqual } from "crypto";
function verify(rawBody: string, headers: Record<string, string>, secret: string) {
const timestamp = headers["x-mailstein-timestamp"];
const signature = headers["x-mailstein-signature"];
if (!timestamp || !signature) return false;
// Reject anything older than five minutes: without this, a delivery captured
// once can be replayed at you forever, and it stays validly signed.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60 * 1000) return false;
const expected = "v1=" + createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
// Constant-time: a plain === leaks how much of the signature was right.
return a.length === b.length && timingSafeEqual(a, b);
}
Two things people get wrong here:
Verify the raw bytes. If your framework parses JSON before you see it, re-serialising produces different bytes and the signature will not match. Capture the body as a string first.
Check the timestamp. The signature alone proves the message came from us, not that it came from us just now.
Retries
A delivery is attempted 6 times with exponential backoff, then given up on. Anything other than a 2xx counts as a failure, including a timeout.
Return 2xx as soon as you have stored the event, and do the work afterwards. An endpoint that does its processing inline will eventually be slow enough to time out, and then you get the same event again.
Handle duplicates. At-least-once delivery is the guarantee, so make your handler idempotent on the event id.
Inspect what happened:
curl "https://app.mailstein.com/api/v1/webhooks/$ID/attempts?limit=25" \
-H "Authorization: Bearer $MAILSTEIN_API_KEY"
Testing locally
Your endpoint has to be reachable from the internet. Use a tunnel — ngrok http 3000 or cloudflared tunnel --url http://localhost:3000 — and point a
throwaway webhook at it. Delete it afterwards.