DEV Community

kevindev
kevindev

Posted on

REST Idempotency for Signup Email Jobs

REST Idempotency for Signup Email Jobs

When a signup request times out, most clients retry before a human even notices. If your API creates a user row on the first attempt and enqueues a verification email on the second, you now have duplicate mail and noisy support threads. I have seen this bug show up in otherwise clean systems because the API path looked deterministic, but the side effects were not.

The fix is not "retry less." The fix is making the signup email workflow idempotent end to end, from the HTTP contract to the worker that actually talks to your provider.

Why signup emails duplicate so easily

A typical failure path looks simple:

  1. POST /signup validates the payload.
  2. The app creates the user record.
  3. The app writes a message into a queue.
  4. The HTTP connection drops before the client gets 201 Created.
  5. The client retries with the same intent.

If step 2 committed and step 3 partially succeeded, the second request can produce a second email job. In practice, these duplicates are common when mobile clients retry aggressively or when upstream gateways hide the first timeout. The app logic may still seem "correct", but the workflow isnt stable.

The API contract that makes retries safe

For signup-triggered email, I prefer an explicit idempotency key on the request. The contract is boring on purpose:

POST /signup
Idempotency-Key: 0e5d9d48-5f74-4c1b-a87a-d9160b1a4d71
Enter fullscreen mode Exit fullscreen mode

Then store one durable record keyed by:

  • tenant or app scope
  • normalized email
  • idempotency key
  • request hash

The important rule is that the first accepted request owns the outcome. Retries with the same key return the same result shape, including the same signup status and the same email job reference. Retries with the same key but a different body should fail loudly with 409 Conflict, because that is almost always a client bug.

In Node.js, the write path can stay pretty small:

await db.tx(async (trx) => {
  const signup = await trx.oneOrNone(
    `
    insert into signup_requests (scope_id, email, idem_key, request_hash, status)
    values ($1, $2, $3, $4, 'accepted')
    on conflict (scope_id, email, idem_key)
    do nothing
    returning id
    `,
    [scopeId, email, idemKey, requestHash]
  );

  if (!signup) {
    return loadExistingResult(trx, scopeId, email, idemKey, requestHash);
  }

  const user = await upsertUser(trx, email);
  await enqueueVerificationOutbox(trx, signup.id, user.id);
  return { created: true, userId: user.id };
});
Enter fullscreen mode Exit fullscreen mode

That signup_requests row becomes the source of truth for retries. It also gives you a clean audit trail for auth and support teams, which saves time later, trust me.

A queue model that avoids double sends

The API boundary alone is not enough. Workers retry too, providers timeout too, and operators rerun stuck jobs at 2 AM. I usually combine three guards:

  1. An outbox table written in the same transaction as the signup acceptance.
  2. A unique constraint on the logical email intent, such as (template, user_id, signup_request_id).
  3. A provider delivery key carried through the worker so downstream retries are also deduplicated.

This works better than trying to infer duplicates from send timestamps. Timestamps drift, jobs replay, and somebody will eventually reschedule a batch manualy.

If you need separate resend behavior, model it as a new intent with a reason code. Do not overload the original signup intent. That is the same lesson behind lease-based resend coordination: once operational retries mix with product retries, the state machine gets muddy fast.

Where temporary inboxes still help

I would not use a temporary inbox to decide whether a signup request is valid. That belongs in API validation and abuse controls. But temp inboxes are still useful in integration testing, staging review, and support reproduction.

For example, when QA needs to generate disposable email accounts during smoke tests, the important backend rule is that mailbox churn must not change your idempotency behavior. Whether the address came from a temp mail generator, a fake e mail com style test alias, or an internal seed account, the retry contract should behave the same. A temp mailid in a bug report should not require special code paths.

For rollout verification, I also like isolated inboxes during rollout checks because they make it easier to prove which deploy emitted which message.

A short review checklist

When I review this kind of endpoint, I ask these questions first:

  1. Does the API require an idempotency key for every signup email intent?
  2. Is the request hash stored so mismatched retries fail deterministically?
  3. Is the outbox insert in the same database transaction as the user/signup write?
  4. Can a worker replay send the same logical email twice?
  5. Can support or ops tell the difference between "already sent" and "never queued"?

If any of those answers are fuzzy, the system is probably one network blip away from duplicate mail.

Q&A

Should I deduplicate only by email address?

No. A single email address can legitimately trigger different intents over time. Deduplicate the specific intent, not the person.

What if the provider does not support idempotency keys?

Keep your own delivery intent identifier and persist provider response metadata. You can still make your worker retry-safe even if the provider API is a bit old-school.

Is eventual consistency a problem here?

Not if you make the accepted request record durable before returning success. The exact send time can be eventual; the intent record cannot.

Reliable signup email flows are mostly about choosing one canonical intent record and refusing to let retries create a second one. The pattern is not flashy, but it keeps auth systems calmer, support inboxes quieter, and deploy nights a lot less weird.

Top comments (0)