Verify, retry and replay email webhooks safely
A webhook receiver needs three things: check the signature and its timestamp before trusting a request, return a 2xx only after the event is safely stored, and process each event id at most once. With those in place, Email Digit’s retries and its replay button can never hurt you, and replay can fix the failure retries cannot: an event you accepted and then handled wrongly.
Four ways a webhook goes wrong
- Forged. Your endpoint is a public URL. Anyone who finds it can post a convincing “reply received” event to it.
- Replayed. Someone who captures one genuine request can send it again later.
- Missed. Your endpoint was down for a deploy at 3am, and the event that arrived then is gone unless the sender tries again.
- Mishandled. Your endpoint said 200, then a bug wrote the wrong thing to your database. From the sender’s side, everything worked.
Signatures deal with the first two, retries with the third. The fourth needs replay, and replay is only safe if your handler is idempotent.
Verify the signature and the timestamp
Every Email Digit delivery carries an X-Email-Digit-Signature header that looks like this:
X-Email-Digit-Signature: sha256=5f2b...9c1e,t=1763380800t is the Unix time the request was signed. The signature is an HMAC-SHA256, keyed with your subscription’s signing secret, over the timestamp, a dot, and the raw request body. To verify:
- Split the header into the signature and
t. - Reject the request if
tis more than five minutes from your clock. This is what stops an old captured request from being replayed by someone else. - Compute the HMAC over
t, then., then the body bytes exactly as received. - Compare with a constant-time comparison, and reject on any mismatch.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const [sigPart, tsPart] = header.split(",");
const sig = sigPart.slice("sha256=".length);
const t = tsPart.slice("t=".length);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto.createHmac("sha256", secret)
.update(t + ".").update(rawBody).digest("hex");
return sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}The most common mistake is verifying a body your framework has already parsed and re-serialised. Different whitespace or key order means different bytes, and every signature fails. Read the raw body for the check, and parse it afterwards.
Each attempt is signed at the moment it is sent, so a retry two hours later and a replay next week both carry a fresh timestamp and pass the five-minute check.
How retries work
A delivery succeeds when your endpoint returns any 2xx within 15 seconds. Otherwise:
| Your endpoint returns | What happens |
|---|---|
| A timeout, a 5xx or a 429 | Retried after 1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours. After the sixth attempt the delivery is marked dead. |
| Any other 4xx | Treated as permanent and not retried. A 4xx says the request itself is wrong, so sending it again will not help. |
That second row matters when you write the handler. If verification fails, return 401 and the delivery stops. If your database is briefly unavailable, return a 5xx, not a 400, so the event comes back later.

Endpoints that keep failing are treated differently. After 20 failures in a row, a subscription is attempted less often, so a dead endpoint does not take time from working ones. After 25 in a row, it is paused and your workspace is told. A URL that can never be a valid public destination, such as a private address, pauses the subscription on the first attempt instead of failing 25 times.
Replay, and why idempotency comes first
In the dashboard’s delivery log, any finished delivery can be replayed: one that died after six attempts, one that failed permanently, and one that was delivered successfully. The last case is the important one. If your handler returned 200 and then processed the event wrongly, fix the handler and replay the delivery. A delivery that is still queued or mid-attempt cannot be replayed; it will be attempted on its own.
A replay sends the same event again, with the same event id in the envelope and in the X-Email-Digit-Event-Id header. That id is what makes replay safe. Store the ids you have processed, and skip any you have seen:
app.post("/webhooks/email-digit", express.raw({ type: "application/json" }), async (req, res) => {
if (!verify(req.body, req.get("X-Email-Digit-Signature"), SECRET)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
if (!(await db.wasProcessed(event.id))) {
await handle(event);
await db.markProcessed(event.id); // only after handle() succeeded
}
res.sendStatus(200);
});The same check protects you from ordinary retries too: if your 200 was lost on the way back, the retry arrives with an event id you already stored. When the fix you shipped is the reason you are replaying, clear that event id from your store first, or the handler will skip it as already seen.
Rotating the signing secret
Rotate the secret when someone with access leaves, or when it turns up somewhere it should not, such as a log. Rotation is one click; the new secret is shown once, and the old one stops being used immediately. Deliveries sent after the rotation are signed with the new secret straight away, so let your handler accept either secret while you deploy the new one, then drop the old one. A delivery your handler rejects with a 401 in that gap is not retried, so replay it from the log afterwards.
The developer docs have the same verification in Python, Go and Ruby, and an example of every header a delivery carries.