DEV Community

kevindev
kevindev

Posted on

Replay-Safe Email Verification Jobs

Email verification looks like a small feature until a worker retries at the same moment that a user clicks the link. Then the system has to answer some less small questions: which request owns the token, whether the message was already sent, and what a second click should do.

The first version usually look like this: create a token, send an email, and mark the account as verified when the token comes back. It works in a happy-path demo. In a service with timeouts, duplicate jobs, and multiple API instances, it can produce expired links, duplicate messages, or a verification endpoint that changes state twice.

This post describes a pattern I use for keeping email verification replay-safe. The email provider is only a delivery mechanism. PostgreSQL owns the state, while the REST API and worker agree on the same transitions.

The failure mode: delivery is not completion

An HTTP request that asks for verification normally has at least two independent outcomes:

  1. The application accepted a verification request.
  2. A message was handed to an email provider.

Neither outcome proves that the user has verified the address. Treating them as one operation is where many retry bugs start. A provider call can timeout after accepting a message. A queue can redeliver a job. A browser can submit the same form twice. The application must be able to replay each operation without creating a new business result.

It is also useful to separate test vocabulary from production behavior. Teams may search for terms such as temp mail so or generate disposable email while checking signup flows, but those test addresses still need a controlled lifecycle. They should never become a reason to weaken token ownership or retention rules.

Model verification as explicit state

Start with a small state machine instead of a boolean called email_sent:

created -> queued -> sending -> sent
                     \-> failed
sent -> verified
created, queued, sending, sent -> expired
Enter fullscreen mode Exit fullscreen mode

The exact names are less important than making illegal transitions visible. A verified record must not move back to sending. An expired token must not become verified just because an old request arrived late. A failed delivery can be retried only if the token is still usable.

A verification record can contain:

  • id and user_id
  • a hash of the token, never the raw token
  • status
  • idempotency_key
  • expires_at, created_at, and verified_at
  • attempt_count and last_error

The raw token belongs only in the link sent to the user. Store a digest so a database read does not become a usable credential. This boundary is easy to miss when the feature is first built, but it makes incident response much safer.

Use PostgreSQL as the idempotency boundary

The API should assign an idempotency key to each logical request. A client can send one, or the service can derive a request identity at the edge. The important part is that retries for the same action resolve to the same database row.

For example, a uniqueness constraint can make duplicate creation harmless:

CREATE UNIQUE INDEX verification_request_once
ON email_verifications (user_id, idempotency_key);
Enter fullscreen mode Exit fullscreen mode

The transaction inserts the verification row and an outbox event together. The worker later claims the event. If the transaction commits, the event exists; if it rolls back, there is no orphaned delivery instruction. This is more reliable than sending mail inside the request transaction, because external delivery cannot be rolled back.

When a worker claims a row, use a short transaction and a lease. FOR UPDATE SKIP LOCKED can prevent several workers from taking the same pending job, while lease_until gives another worker a recovery path when the first one disappears. The lease is not proof that the first worker stopped, so the final update still needs a compare-and-set condition on the version or claim token.

That last check is the part people skip most often. It do matter when a slow provider response arrives after the lease has been reclaimed. Without it, an old worker can overwrite a newer failure or mark a different attempt as sent.

Make the REST API and worker agree

The request endpoint should return a stable representation for repeated calls. A simplified response could look like this:

POST /v1/email-verifications
Idempotency-Key: signup-7d2c
Enter fullscreen mode Exit fullscreen mode
{
  "verification_id": "ver_123",
  "status": "queued",
  "expires_at": "2026-11-11T03:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

If the same key arrives again, return the existing record instead of creating another token. If the client asks for a new verification after the old one expires, it should use a new logical key and receive a new record. This makes the difference between retrying a request and intentionally starting a new flow clear.

The confirmation endpoint should also be idempotent:

POST /v1/email-verifications/{id}/confirm
Enter fullscreen mode Exit fullscreen mode

It can return verified for a repeated valid confirmation, expired for an old token, and already_verified when the account was completed by another request. Those responses are more useful than a generic success message, specially when a frontend needs to explain what happened.

For debugging, carry a request ID through the API, outbox row, worker logs, and provider metadata. The request IDs for signup checks pattern is helpful here: a trace that survives the browser-to-backend boundary is much easier to follow than a timestamp alone. In CI, run tokens for isolated email checks provide the same kind of ownership boundary for test data.

Operational checklist

Before shipping an email verification worker, check these cases:

  • A client repeats the same POST after a timeout.
  • Two workers see the same pending event.
  • The provider accepts a message but the response is lost.
  • A lease expires while the first worker is still running.
  • A user clicks an old link after requesting a newer one.
  • The confirmation request is repeated by the browser.
  • Cleanup removes expired tokens without removing audit evidence.
  • A test address such as temp org mail follows the same expiry and privacy rules as any other test fixture.

The most simple implementation is rarely the safest one here. Keep the state machine small, let PostgreSQL enforce uniqueness, and make external delivery a replayable side effect. With those boundaries in place, retries become normal control flow instead of an emergency path, and the verification feature is much easier to maintain.

Top comments (0)