Password Reset APIs Need Send Receipts

# backend# postgres# authentication# restapi
Password Reset APIs Need Send Receiptskevindev

Use PostgreSQL send receipts to keep password reset APIs replay-safe when workers retry, crash, or acknowledge late.

Password reset flows usually break in a very boring place: the API says "accepted", the worker says "sent", and nobody can prove whether those two events refer to the same email attempt. When retries arrive, teams often add more flags or more logs. That helps a bit, but it still leaves an ugly gap between request intent and delivery evidence.

For Authentication systems, I trust a different pattern more. Instead of storing only reset_email_sent=true, store a send receipt row that belongs to the reset attempt. The API creates the attempt, the worker claims it, and the provider ack is reconciled back onto the same record. It sounds slightly heavier, but it makes resend behavior much less spooky under load.

Why password reset retries get confusing fast

The failure pattern is common:

  1. User requests a reset link.
  2. API inserts a reset token and enqueues an email job.
  3. Provider is slow, so the client retries.
  4. Another worker or request path creates a second email intent.
  5. Support now sees two links, one expired token, and logs that look mostly fine.

The weak point is usually not SMTP. It is the missing contract between the REST API and the async worker. If the only durable signal is sent_at, you cannot answer simple questions very well:

  • which reset attempt produced this message?
  • did the provider ack this attempt or some earlier one?
  • is a resend reusing the current token or creating a newer one?

That ambiguity makes incident review slower than it should be, and honestly a bit irritating.

Model a send receipt, not just a sent flag

The receipt row I like has these fields:

  • reset_request_id
  • user_id
  • token_version
  • delivery_state in pending | claimed | provider_acked | delivered | failed
  • provider_message_id
  • claim_owner
  • claimed_at
  • provider_acked_at
  • last_error

The important idea is that the worker does not "send an email job" in the abstract. It advances one receipt row through state transitions. That gives the API a stable place to reason about retries. If the same user hits reset again within a short cooldown, the API can decide whether to reuse the current active receipt or create a newer reset attempt and invalidate the old token version.

PostgreSQL is good at this because it can make the claim step atomic. The worker either owns one receipt or it does not. There is less hand-wavy middle state, which is where duplicate email behavior loves to hide.

A PostgreSQL pattern that reconciles worker acks

This is the shape I use when a worker claims pending reset receipts:

with next_receipt as (
  select id
  from password_reset_receipts
  where delivery_state = 'pending'
  order by created_at
  for update skip locked
  limit 1
)
update password_reset_receipts pr
set delivery_state = 'claimed',
    claim_owner = $1,
    claimed_at = now()
from next_receipt
where pr.id = next_receipt.id
returning pr.id, pr.reset_request_id, pr.token_version, pr.user_id;
Enter fullscreen mode Exit fullscreen mode

After the provider accepts the send, I do a second update that records the ack on the same row:

update password_reset_receipts
set delivery_state = 'provider_acked',
    provider_message_id = $2,
    provider_acked_at = now()
where id = $1
  and delivery_state = 'claimed';
Enter fullscreen mode Exit fullscreen mode

That extra provider_acked state matters more than it first appears. If the worker crashes after the provider call but before your final "sent" update, you can reconcile from the provider message id instead of blasting out another reset email. Stripe has written well about idempotent API design and why replay boundaries must be explicit (https://stripe.com/blog/idempotency). Same principle here, just at the email edge of the backend.

In Node.js services, I keep the resend endpoint boring:

if (activeReceipt && activeReceipt.delivery_state !== "failed") {
  return { status: 202, resetRequestId: activeReceipt.reset_request_id };
}

await createResetAttempt(db, userId);
return { status: 202 };
Enter fullscreen mode Exit fullscreen mode

It is not fancy, but it is reviewable. Reviewable systems tend to fail more gracefuly.

Where throwaway inbox tests still help

I would not center the architecture around inbox tools, but they are still useful for proving that one reset attempt becomes one human-visible email. For backend regression tests I like combining DB assertions with a throwaway email inbox, so I can verify both the receipt state and the actual delivered message.

That pairs well with isolated inbox checks and queue safe reset email checks, because both encourage evidence you can trace back to one run instead of shared mailbox noise.

When I need a disposable inbox for manual verification, I keep it in a narrow test section and move on. One contextual option is a fake emails generator, but the real control point is still your receipt table. Even if someone writes tempail in an ops note or test case, the backend contract should stay deterministic.

I also like keeping a tiny checklist for reset-email incidents:

  • confirm one active token version per user
  • confirm one claimed or acked receipt per reset request
  • confirm provider message id is attached before retrying manually
  • confirm old receipts are closed before issuing a fresh link

That checklist saves time becuase the team stops arguing with logs and starts reading the same durable facts.

Q&A

Why not store receipts on the user row?

Because the user row answers identity questions, not delivery lifecycle questions. Mixing both usually gets messy once retries and multiple reset attempts exist.

Do I still need provider-side idempotency?

Yes. Database claims protect your worker coordination. Provider idempotency protects the outbound API call. They cover different failure windows, and together they work much better.

Should provider_acked be treated as fully sent?

Not always. It means the provider accepted the request. If you track webhooks or bounces later, keep delivered as a separate state. That separation is maybe a little more work, but it pays off later.