Email verification looks like a simple two-request flow: create an account, send a message, then accept a token. In a production REST API, it is closer to a distributed workflow. The send can be retried, delivered twice, or consumed twice.
The failure mode I see most often is not a bad token. It is an ownership problem. Two requests believe they are allowed to consume the same verification message, or a delayed message arrives after the user has already requested a replacement. A lease gives the backend a clear answer to a useful question: which attempt currently owns this verification step?
Why verification retries need ownership
Suppose a mobile client calls POST /verification/email and times out. The server may have committed the request even though the client never saw the response. The client retries, and the API creates a second message. Later, both links are valid, or the older link unexpectedly wins.
The same issue appears in automated environments that use a temp mailbox or a free temporary email address for test accounts. A mailbox can contain several plausible messages, but plausibility is not identity. The test needs to know which request created which message and which attempt is allowed to claim it.
An email lease is a short-lived ownership record tied to a user, a verification purpose, and an idempotency key. It should have an owner, an expiry time, and a state. The state makes the workflow visible instead of hiding it inside a polling loop.
Model the email as a leased resource
I use four states for a verification delivery:
requested -> leased -> consumed
\-> expired
requested means the API accepted the intent but has not assigned a delivery attempt. leased means one worker owns the attempt for a bounded period. consumed means the token was accepted successfully. expired means another attempt may replace it, subject to the product's rate limits.
The lease should not be the token itself. Keep the token hash separate, and store only enough metadata to correlate the delivery:
create table verification_deliveries (
id bigserial primary key,
user_id bigint not null,
purpose text not null,
idempotency_key text not null,
token_hash text not null,
state text not null default 'requested',
lease_owner text,
lease_until timestamptz,
consumed_at timestamptz,
created_at timestamptz not null default now(),
unique (user_id, purpose, idempotency_key)
);
The unique constraint is important. It lets a retry find the original delivery instead of creating another one. The lease_owner can be a worker ID or request trace ID; it should be safe to log, unlike the raw verification token.
Implement the lease with PostgreSQL
Claiming a delivery must be atomic. A worker can select an unleased row, verify that its lease is available, and update it in one transaction. FOR UPDATE SKIP LOCKED is useful when several delivery workers share a queue:
begin;
select id
from verification_deliveries
where state = 'requested'
or (state = 'leased' and lease_until < now())
order by created_at
for update skip locked
limit 1;
update verification_deliveries
set state = 'leased',
lease_owner = $1,
lease_until = now() + interval '2 minutes'
where id = $2;
commit;
The worker should renew only while it is making progress. A lease that never expires becomes a stuck job. A lease that is too short causes duplicate delivery when a slow provider is still processing the first attempt. The right duration depends on provider latency, but the decision should be explicit and observable.
The send operation also needs a receipt. Record the provider message ID, response status, and attempt timestamp after the provider accepts the message. If the provider call times out, the result is unknown; dont immediately release the lease and send again. Mark the attempt as uncertain, then reconcile it with a provider lookup or a controlled retry policy.
Keep the REST API retry-safe
The API endpoint should accept an idempotency key from the client or generate one at the boundary. Repeating the same key should return the existing delivery status, not create a new token. A different key can represent a deliberate new request, subject to a cooldown.
For token consumption, use a conditional update:
update verification_deliveries
set state = 'consumed', consumed_at = now()
where id = $1
and state = 'leased'
and lease_until > now()
and token_hash = $2
returning id;
If no row is returned, the API should return a stable error such as verification_expired or verification_already_used. Do not leak whether a token hash was close to matching. Authentication responses are easier to operate when their error vocabulary is small and predictable.
Keep token consumption and the account activation update in the same transaction when possible. That way, a successful response means both facts were committed. If they must be separate, use an explicit state transition and a repair job; a vague 200 OK thats emitted before activation is durable will create support cases.
Testing expiry and cleanup
Test the boundary cases as first-class scenarios:
- Two workers try to claim the same delivery. Exactly one gets the lease.
- A worker pauses past
lease_until. A replacement can claim the row. - The same idempotency key is submitted repeatedly. The delivery count stays one.
- The provider times out after accepting the message. Reconciliation does not create an uncontrolled duplicate.
- A consumed token is submitted again. The response is stable and no account state changes.
For browser tests, keep the correlation ID and provider message ID in the test receipt. Proving temporary inbox deletion is part of cleanup, but deletion should happen after the receipt is stored. A test value like tempail or dummy e mail can cover input validation; it should never be accepted as a real delivery address.
It is also worth testing the cleanup worker. Expired leases need a clear owner, and old token hashes should have a retention policy. The cleanup job doesnt need to delete every row immediately; it does need metrics for expired, reconciled, and permanently failed deliveries. The receipt is durable evidence; logs alone isnt enough.
Final takeaways
Email verification becomes more predictable when delivery attempts have ownership, expiry, and an idempotent identity. PostgreSQL can enforce the important parts: one request key maps to one delivery, one transaction claims a lease, and one conditional update consumes a token.
The lease is not a replacement for provider reliability or client-side retries. It is the boundary that makes those behaviors safe to reason about. When a request fails, the system can say whether it was still leased, expired, delivered, consumed, or awaiting reconciliation. Thats much more useful than simply sending another email.
Top comments (0)