DEV Community

kevindev
kevindev

Posted on

Idempotency Keys for Signup APIs

Signup endpoints look simple until a client retries. A mobile connection can drop after the server commits, a reverse proxy can retry a request, or a user can press the button twice. If the endpoint sends verification mail and creates account state on every attempt, one logical signup becomes several pieces of work.

The fix is not “disable retries.” It is to make the operation idempotent: the same client intent should produce one durable result, even when the HTTP request arrives more than once. This is a small backend decision with a big effect on authentication reliability.

Why signup retries create duplicate work

Consider POST /signup. The handler validates the address, inserts a user, creates a verification token, and queues an email. A timeout happens after the insert but before the response reaches the client. The client retries with no knowledge of the first attempt.

Without a boundary for the original intent, the second request may:

  • return a confusing unique-email error;
  • create multiple verification records;
  • send duplicate messages;
  • or, worse, expose whether an account exists.

This is also why a disposable or temporary test inbox is useful during development: it lets you inspect the actual number and state of messages. Keep test data isolated, though; a test mailbox is not a substitute for authorization rules.

The idempotency contract

Ask the client to send an Idempotency-Key header for each logical signup attempt. The key should be stable across retries but different for a new attempt. The server stores the key with the operation result and binds it to the relevant request identity, such as a normalized email or tenant.

The contract should answer three cases:

  1. First request: process the signup and store the response.
  2. Same key, same input: return the stored response without repeating side effects.
  3. Same key, different input: reject it as a conflict. Reusing a key for another email is a client bug.

Do not use the email itself as the idempotency key. An email can be retried for one operation and used again later for a password reset or recovery flow.

A PostgreSQL-backed implementation

A table gives the key a durable owner and lets multiple Node.js instances coordinate:

CREATE TABLE signup_requests (
  idempotency_key text PRIMARY KEY,
  email_hash text NOT NULL,
  status text NOT NULL CHECK (status IN ('processing', 'completed', 'failed')),
  response_code integer,
  response_body jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

Hashing the normalized email avoids storing it in this coordination table. The user table should still enforce its own invariant:

CREATE UNIQUE INDEX users_email_normalized_idx
  ON users (lower(email));
Enter fullscreen mode Exit fullscreen mode

In Node.js, acquire the key inside a short transaction. An insert succeeds for the first request; a conflict means another request owns the operation. If the row is completed, replay its stored response. If it is processing, return a deliberate status such as 202 Accepted or ask the client to retry after a bounded delay.

The important detail is scope: do not hold a database transaction open while waiting for an external email provider. Commit the account and outbox record first, then let a worker deliver the message.

State transitions and failure handling

An outbox makes the database state and the email side effect easier to reason about. In one transaction, create the user, create the verification record, and insert an outbox event. A worker claims the event and sends the message with its own delivery idempotency key.

If the worker crashes after sending but before marking the event complete, a retry can still happen. Provider-level deduplication, or a delivery record with a provider message id, is needed when duplicate mail is unacceptable. This is one of those details that gets missed in a quick implementation.

For the HTTP layer, keep responses stable. A completed replay should have the same status and body shape as the original response, but never include a token that should only be shown once. For security-sensitive flows, a generic response such as “If the request can be completed, we will send instructions” also reduces account enumeration.

For related thinking, compare this with type-safe signup email states and email verification threat models. The same state discipline helps even when the transport changes.

Operational checklist

  • Normalize input before deriving the request fingerprint.
  • Expire old idempotency rows with a retention policy.
  • Add a unique constraint to the business invariant, not only the request key.
  • Record whether a response was created or replayed.
  • Put email delivery behind an outbox and worker.
  • Bound processing recovery with leases or timestamps.
  • Never log raw verification tokens or full email addresses.
  • Test timeout-after-commit and concurrent identical requests.

Two deliberately imperfect notes from real maintenance work: a retry window that is too short feels random, and a cleanup job that runs too agressively can remove evidence while an incident is still open. Also, search terms like “tepm mail com” and “tempail” may appear in noisy test data; keep them out of production decisions.

Common questions

Should every POST use an idempotency key?

No. Use it for operations where a retry could duplicate a meaningful side effect, such as account creation, payment, or email delivery. A read-only endpoint does not need this mechanism.

Is a unique email index enough?

No. It prevents duplicate users, but it does not prevent duplicate work before the insert fails, such as token creation or message enqueueing. The idempotency record and outbox cover those effects.

What should happen when a request is still processing?

Return a documented temporary response and let the client retry with the same key. Avoid taking an unbounded lock; stalled requests need a lease, an owner, and a recovery path.

Idempotency is ultimately a promise about intent. Once that promise is represented in PostgreSQL, enforced by constraints, and separated from email delivery, signup retries become a normal distributed-systems case instead of a source of mysterious duplicate accounts.

Top comments (0)