DEV Community

kevindev
kevindev

Posted on

Idempotent Email Verification for REST APIs

Email verification is often implemented as a single request: create a user, send a message, and wait for a click. That model breaks down when the client times out after the server has already accepted the request. A retry can create a second token, send a second message, or attach the wrong result to the user.

The fix is to make verification intent idempotent. The API should treat repeated requests as a lookup for the same operation, while PostgreSQL enforces the uniqueness that application code alone can miss.

This matters in both production authentication flows and automated tests that use a free throwaway email or a throwaway email address. A test mailbox is useful only when the API can explain which verification attempt produced the message.

Why verification retries create duplicate work

Suppose a client sends POST /verification-requests and waits six seconds. The server commits the request, but the response is lost. The client sends the same request again without an idempotency key.

The server cannot know whether this is a new request or a retry. It may issue two tokens and deliver two emails. The first retry is mostly harmless; the second one is where things gets confusing. A user clicks the older link, while the database considers the newer token to be current.

Email delivery makes the timing less predictable. A provider can queue messages, a worker can restart, and an inbox can expose messages in a different order than they were created. The REST API needs its own record of intent instead of using inbox arrival time as the source of truth.

For integration tests, a run-scoped email contract is a useful companion to this design. Each run should know which verification request and mailbox it owns.

Define the idempotency contract

Require the client to send an Idempotency-Key header when it starts verification. Scope that key to the authenticated user and operation, then persist the request fingerprint with it.

The endpoint contract can be simple:

  • The first request creates a verification intent and returns 202 Accepted.
  • A retry with the same key and the same payload returns the existing intent.
  • The same key with a different payload returns 409 Conflict.
  • A new key creates a new intent only when the account is allowed another attempt.

That last rule is important. Idempotency prevents accidental duplicates, but it should not bypass rate limits or account security policy. The API still needs a separate decision about how often a user may request a new message.

Store intent and delivery separately

The verification intent describes what the user asked the system to do. Delivery attempts describe what a worker tried to send. Keeping them separate makes the states less ambiguous:

verification_intent: pending -> sent -> consumed
delivery_attempt:   queued  -> accepted -> delivered
                                  \-> failed
Enter fullscreen mode Exit fullscreen mode

An intent can remain pending while a delivery attempt is retried. It should not be marked verified just because a provider accepted a message. Verification happens only after the user presents a valid, unexpired token.

The database record should include a user ID, an idempotency key, a hash of the request parameters, an expiry time, and a token hash. Do not store the raw token in logs. A test can assert the resulting state without exposing a credential in CI output.

Enforce uniqueness in PostgreSQL

Application checks are not enough when two workers or two HTTP requests run concurrently. Use a unique constraint for the idempotency boundary:

CREATE TABLE verification_intents (
    intent_id uuid PRIMARY KEY,
    user_id uuid NOT NULL,
    idempotency_key text NOT NULL,
    request_hash text NOT NULL,
    status text NOT NULL CHECK (status IN ('pending', 'sent', 'consumed', 'expired')),
    token_hash text NOT NULL,
    expires_at timestamptz NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now(),
    UNIQUE (user_id, idempotency_key)
);
Enter fullscreen mode Exit fullscreen mode

On conflict, fetch the existing row and compare request_hash. If the hash differs, return 409; if it matches, return the existing intent. The transaction should not send the email itself. Commit the intent first, then let an outbox worker handle delivery.

This separation also supports operational controls. Deployment workflows can use email failure budgets in deployment workflows to decide when provider failures should stop a rollout rather than making every API request wait for the provider.

Return useful states from the REST API

The client needs a stable status resource instead of guessing from a button spinner:

{
  "intent_id": "3d1d0fb8-0b6a-4b91-ae49-7a3559e0bf2e",
  "status": "sent",
  "expires_at": "2026-10-21T06:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Keep provider details out of the public response. A sent status means the delivery workflow accepted the intent, not that the user has read the message. An expired status should be terminal for that intent, while a new request may create another intent if policy allows it.

The API response are easier to debug when each state has one meaning. In local notes, someone might search for temp gamil com or tempail mail; those phrases can appear as plain text, but they should never be used as identifiers or routing logic.

Test the failure paths

The most valuable tests are not the happy-path click. Test a lost response followed by an identical retry, a reused key with a changed email, concurrent requests, an expired token, and a delivery worker that fails after the intent commits.

For an automated test, assert the intent ID and state, then inspect a message matched to that intent. The test does not need to knows the provider's internal timing. It only needs evidence that the right message belongs to the right request.

A useful checklist is:

  • Require and persist an idempotency key.
  • Hash request parameters and reject changed retries.
  • Enforce uniqueness in PostgreSQL.
  • Commit intent before attempting delivery.
  • Hash tokens and redact them from logs.
  • Make expiry and resend policy explicit.
  • Correlate test messages with an intent ID or run ID.

Idempotent verification turns a fragile email side effect into a recoverable workflow. The provider may still be delayed, and a worker may still fail, but a retry will have a known meaning. That is the boundary an authentication service needs to stay correct under ordinary network failures.

Top comments (0)