DEV Community

kevindev
kevindev

Posted on

Idempotent Verification Emails in Node APIs

Verification emails look simple until retries, worker restarts, and impatient users all hit the same POST /verify-email flow. In most systems the real bug is not SMTP delivery. It is missing idempotency rules between your REST API, job table, and auth state. I have seen teams patch this with boolean flags, and it works... right until traffic spikes a bit.

The failure mode is usually a race, not SMTP

The common sequence is boring:

  1. User signs up.
  2. API inserts the account row.
  3. API or worker enqueues a verification email.
  4. User clicks resend because the first message is slow.
  5. Two workers now think they should send the same intent.

If your system stores only sent=true, you cannot tell whether a send is in progress, already committed, or safe to retry. That gap creates duplicate emails, stale tokens, and support noise. It also makes debugging kinda annoying because logs often show "success" for both attempts.

What helped me most was treating verification email delivery as a state machine owned by the backend, not as a side effect hidden behind a mail client wrapper.

A small state model fixes most duplicate sends

For verification flows, I like one row per delivery intent with these fields:

  • user_id
  • purpose such as signup_verification
  • idempotency_key
  • state in pending | claimed | sent | consumed | failed
  • token_hash
  • claimed_at, sent_at, failure_code

The rule is simple: the API creates or reuses the current intent, and the worker may only send after it atomically claims the row. That removes the "did two workers race?" question from app code and pushes it into data consistency, where PostgreSQL is much better equiped to help.

I also keep the idempotency key stable for a short replay window. For example, if the same authenticated user hits resend within five minutes, the API can return 202 Accepted and point to the existing intent instead of minting a new token each time. That makes auth behavior easier to reason about, and users get a more predictable flow.

Node.js example: claim work before sending

Here is the shape I prefer in a Node.js service:

async function claimVerificationIntent(db, userId) {
  const result = await db.query(`
    update email_delivery_intents
       set state = 'claimed',
           claimed_at = now()
     where id = (
       select id
         from email_delivery_intents
        where user_id = $1
          and purpose = 'signup_verification'
          and state = 'pending'
        order by created_at asc
        for update skip locked
        limit 1
     )
    returning id, token_hash, idempotency_key
  `, [userId]);

  return result.rows[0] ?? null;
}
Enter fullscreen mode Exit fullscreen mode

Two details matter more than the SQL syntax:

  1. for update skip locked prevents workers from claiming the same pending row.
  2. The state transition happens before the email provider call, so your retry logic knows whether it is replaying a claim or creating new work.

After a successful send, update the same row to sent and persist the provider message id if you have one. If the provider times out, do not blindly insert a second intent. First inspect whether the original claim may still finish. This is where many APIs get a little messy and start spraying duplicates.

On the API side, I keep resend logic explicit:

if (existingIntent && existingIntent.state !== 'failed') {
  return { status: 202, intentId: existingIntent.id };
}

await createPendingIntent({ userId, purpose: 'signup_verification' });
return { status: 202 };
Enter fullscreen mode Exit fullscreen mode

This approach fits nicely with other backend patterns too. If you already use an outbox table for domain events, the verification intent can either become the outbox record or reference it directly. The point is to keep one source of truth for "should an email be sent?".

Where a temporary email address helps in testing

I do not use email sandboxes only for UI tests. They are useful for backend contract checks, especially when you need to prove that one resend request still maps to one effective email intent. A temporary email address is handy when you want fresh inbox state per test case without leaking messages into shared accounts.

For example, one practical pattern is:

  • create a user
  • trigger signup verification twice with the same auth context
  • assert one sent transition in the database
  • inspect one inbox for one token

That kind of workflow pairs well with isolated inbox checks and safer invite email debugging, because both push teams toward cleaner evidence when they debug email behavior.

If you need a disposable inbox during local development, keep it a tiny part of the system design rather than the center of it. I sometimes use tools like temp mail so for quick manual verification, but the stronger win is still your database contract. Even if someone types tempail mail into a note or test case, the backend should remain deterministic.

Q&A

Should I create a new token on every resend?

Not always. If the previous token is still valid and the user is within a short replay window, reusing the same intent is often simpler and less error-prone. Rotate only when your security model or abuse controls actually require it.

What should the API return to clients?

I prefer 202 Accepted for resend operations because the actual delivery is asynchronous. The response can include a stable intent identifier, which is more useful for tracing than a vague "email sent" message that maybe was not sent yet.

Does this pattern only help authentication flows?

No. Password reset, invite acceptance, magic links, and billing notifications all benefit from the same model. Once you separate intent creation from send execution, the rest of the system gets easier to test and reason about, even if the first version feels a bit more work.

The boring conclusion is the useful one: model email delivery as backend state, claim work before sending, and keep resend semantics explicit. Do that, and a lot of auth-email flakiness just... stops happening.

Top comments (0)