Email verification often looks like a simple three-step flow: create a token, send a message, and wait for the user to click it. In a real REST API, the hard part is proving that the click belongs to the request that created it. Retries, delayed delivery, reused addresses, and parallel test runs can make a valid-looking message belong to the wrong operation.
The fix is to treat correlation as a backend contract. An email address identifies a destination, but a correlation ID identifies one verification attempt. This distinction matters in production and in tests that use a throwaway email or even a temp mail for facebook scenario to exercise registration boundaries.
Why an email address is not enough correlation
Suppose a user requests verification twice. The first message is delayed, while the second message arrives quickly. If the inbox consumer accepts the first message matching only the recipient and subject, it may verify an old request. A retry can create the same ambiguity when a worker sends again after a timeout but the original provider call actually succeeded.
The API should therefore create an immutable verification_id before it queues delivery. Every later operation uses that value, not just the address:
POST /v1/email-verifications
-> verification_id: ver_8f2c
-> outbox event: email.verification.requested
-> message metadata: ver_8f2c
The ID is not a secret. The verification token still needs cryptographic randomness and an expiry. Correlation answers “which attempt is this?” while the token answers “is this request authorized?” Mixing those jobs makes the design more difficult to review.
The request and delivery contract
A useful request record has a small, explicit state machine: created, queued, sent, confirmed, expired, and cancelled. The transition rules should be more clear than a collection of booleans such as email_sent and verified. The state machine are intentionally small so operators can reason about it.
For example, the create endpoint can return 202 Accepted when the request is stored and queued, rather than pretending that the provider has delivered the email. The response might look like this:
{
"verification_id": "ver_8f2c",
"status": "queued",
"expires_at": "2026-10-30T21:00:00Z"
}
The worker includes the ID in provider metadata, a signed link claim, or both. If the provider supports a message ID, store it as delivery evidence. If it does not, generate an internal attempt ID for every send. A message may be retried, so verification_id identifies the business request and attempt_id identifies one delivery attempt.
When a test harness reads the inbox, it should filter by recipient, request time, and correlation data. Do not accept the first subject match. A phrase like fake e mail com may appear in noisy test data, but it should never become the matching rule for a verification event.
Persisting evidence with PostgreSQL
PostgreSQL can enforce the uniqueness rules that application code tends to implement inconsistently. A minimal model separates the verification request from its delivery attempts:
create table email_verifications (
id text primary key,
user_id bigint not null,
recipient text not null,
token_digest bytea not null,
status text not null,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create table email_delivery_attempts (
id text primary key,
verification_id text not null references email_verifications(id),
provider_message_id text,
status text not null,
sent_at timestamptz,
unique (verification_id, id)
);
The outbox row should be inserted in the same transaction as the verification. That prevents the database from saying an attempt exists when no worker can discover it. A worker claims pending rows with a lease, then records the provider result. This is safer than holding a database transaction open while waiting on an external service.
For a useful audit trail, retain the request ID, attempt ID, provider message ID, and timestamps. Do not store the raw token or the full email body. The receipt can still show whether the expected signal arrived without turning operational logs into a copy of sensitive data. I prefer this boundary because it keeps debugging practical and privacy risk smaller.
Implementing the REST API in Node.js
The confirmation endpoint should make the correlation check part of the transaction:
POST /v1/email-verifications/{verification_id}/confirm
1. Lock the request row.
2. Reject an unknown or expired ID.
3. Compare a digest of the submitted token.
4. Mark the request confirmed only once.
5. Commit and emit a confirmation event.
In Node.js, keep provider calls outside the confirmation transaction. A network call inside the transaction raises lock time and can create a pile-up during a provider incident. The service boundary also should return the same success shape for a repeated confirmation of an already confirmed request, when the caller is authorized. That makes client retries safe without hiding an invalid token.
For a test endpoint that checks delivery, accept verification_id explicitly and return structured states such as found, timeout, or ambiguous. A boolean response doesnt give an engineer enough evidence to tell a slow provider from a bad filter. You can review an email API run with a receipt and use concurrency keys for email API checks as related patterns for making the result replayable.
Retry and cleanup rules
Retries should operate on the outbox and delivery attempt records, not on a new verification request by default. Use a bounded retry policy with a visible reason. If an attempt is uncertain, mark it unknown and reconcile it rather than immediately creating another message; otherwise the user may recieve duplicates and the test can accept the wrong one.
Expired requests need cleanup, but cleanup should preserve a compact outcome record for a reasonable retention period. Deleting every trace makes support investigations depend of provider logs that may already be gone. A scheduled job can remove token digests and recipient data earlier while keeping aggregate status and IDs. The retained informations are still enough for a support check.
Questions engineers usually ask
Should the correlation ID be the verification token?
No. Keep them separate. The ID can be logged and passed between internal services; the token must be treated as a secret and compared by digest.
Do I need an outbox for a small service?
Not for every prototype, but you need an equivalent durable handoff. If a restart between the database commit and the provider call would lose the email, the service has an outbox problem regardless of its size.
What should an inbox test assert?
Assert the correlation ID, recipient, freshness window, expected event, and one-time consumption. Subject text alone is a weak signal.
Reliable email verification is less about sending harder and more about preserving identity across asynchronous boundaries. Once the request ID, delivery attempt, token, and evidence have separate jobs, retries become observable and the API can fail without guessing.
Top comments (0)