Verification email bugs are often retry bugs wearing a different hat. The API times out, the client retries, the worker sees two jobs, and suddenly one user gets multiple links with slightly different token state. The app may still "work", but the delivery contract is fuzzy and that usually comes back later in support or incident review.
The pattern I trust most is simple: treat email dispatch as an idempotent backend side effect with a receipt in PostgreSQL. That gives the REST API a stable answer on retries, keeps token state aligned with delivery intent, and makes debugging much less painful. I still borrow a few ideas from broader delivery drift checks and from auth-focused magic link guardrails, but the main win is keeping request identity and email identity tied together.
Why verification email retries create duplicate state
This failure mode shows up in a lot of stacks:
-
POST /verification-emailcreates or rotates a token. - The API publishes a send job.
- The client retries because it never saw the first 202 response.
- The second request rotates the token again or enqueues a second send.
Now you have ambiguity. Which link is current? Which email should the test click? Which request should your logs point to? If the system does not answer those questions deterministicaly, the retries are not safe even if the status codes look fine.
What makes this bug annoying is that local testing can miss it. A single request path looks clean. The trouble starts when mobile networks, frontend retries, or queue lag enter the picture. That is why I like making idempotency an explicit API contract instead of a best-effort worker behavior.
The PostgreSQL contract I use for idempotent sends
My baseline is one idempotency key per logical send attempt, stored with a durable receipt row:
create table verification_send_receipts (
user_id bigint not null,
idempotency_key text not null,
token_version integer not null,
outbox_event_id bigint not null,
created_at timestamptz not null default now(),
primary key (user_id, idempotency_key)
);
The request transaction does three things:
- Resolve the user and current verification state.
- Reuse an existing receipt if the same idempotency key already exists.
- Otherwise rotate the token once, insert one outbox event, and insert one receipt row.
That third step is the part worth protecting. If the token rotates but the receipt insert is not part of the same transaction, you can still drift. If the outbox write sits outside the transaction, you can still send twice. PostgreSQL is very good at giving you one committed truth, so I try not to get clever around it.
I also like returning the same response body for a repeated key. A repeated request should feel boring. Same status, same receipt identifier, same cooldown metadata, done. Fancy retry handling is where systems start to get weird, and not in a fun way.
A REST API flow that stays deterministic under retries
Here is the behavior I aim for:
- The client sends
Idempotency-Key. - The API validates the caller and rate limits before touching email state.
- Inside one transaction, the API either reuses the receipt or creates one new token version and one new outbox event.
- The worker sends only the event referenced by the committed receipt.
- Observability points back to the receipt row first, then to the mail provider log.
This gives me a stable path for incident review. If a user says they got two messages, I can answer whether the duplicate came from the API layer, the worker, or the provider. Without that receipt row, people end up inferring from logs that were never meant to prove identity.
There is also a practical testing benefit. In CI, I can assert that one idempotency key yields one receipt and one accepted email for that recipient alias. That is a much stronger signal than "an email arrived eventually". Teams sometimes patch over this with shared inbox filters or notes about tamp mail com in test docs, but those workarounds usually hide the actual contract gap.
A Node.js example with request receipts
This is the compact version of the handler shape:
app.post("/verification-email", async (req, res) => {
const userId = req.auth.userId;
const idempotencyKey = req.get("Idempotency-Key");
if (!idempotencyKey) {
return res.status(400).json({ error: "Idempotency-Key is required" });
}
const result = await db.tx(async (trx) => {
const existing = await trx.oneOrNone(
`select token_version, outbox_event_id
from verification_send_receipts
where user_id = $1 and idempotency_key = $2`,
[userId, idempotencyKey]
);
if (existing) {
return { reused: true, ...existing };
}
const tokenVersion = await rotateVerificationToken(trx, userId);
const outboxEventId = await insertVerificationOutbox(trx, {
userId,
tokenVersion,
});
await trx.none(
`insert into verification_send_receipts
(user_id, idempotency_key, token_version, outbox_event_id)
values ($1, $2, $3, $4)`,
[userId, idempotencyKey, tokenVersion, outboxEventId]
);
return { reused: false, tokenVersion, outboxEventId };
});
return res.status(202).json(result);
});
This is not the only valid design, but it is easy to reason about. I can replay the same request and know whether the system reused work or created new work. I can inspect PostgreSQL and see the exact token version attached to the send. If a queue consumer retries, I still have a durable reference point.
One small detail that helps a lot: keep a short retention window for idempotency receipts, but not so short that normal mobile retries fall out of it. Twenty four hours is often enough for verification flows. Less than that can be okay, but you should decide it on purpose, not accidentaley.
Where inbox-based checks still help
I still use a temporary inbox in non-production tests, but only as evidence of final delivery. It should not be the system of record for request identity. The record lives in PostgreSQL; the inbox confirms the rendered message matched that record.
That distinction matters when you are debugging flaky automation. A temp email generator can isolate recipients and reduce noise. Searchers may land here for terms like temp mail so, temp email generator, or even tempail, and fair enough. The useful lesson, though, is that inbox isolation helps only after the API contract is solid. Otherwise you are just watching duplicate sends more cleanly.
Q&A
Should the idempotency key belong to the frontend or backend?
Usually the client should generate it for retriable user actions. That preserves identity across network retries. If your backend invents the key after receiving the request, it is already too late for some failure cases.
Do I still need rate limits?
Yes. Idempotency prevents duplicate work for the same logical request. It does not replace abuse protection, cooldowns, or account-level verification policies.
What if I already have an outbox table?
Great, keep it. Add a receipt layer that maps request identity to one outbox event. The outbox proves delivery intent; the receipt proves request deduplication. Those are related, but they are not quite the same thing.
Top comments (0)