DEV Community

kevindev
kevindev

Posted on

Receipt Tables for Resend Email APIs

Resend verification endpoints look simple right up until traffic, retries, and background workers all get a vote. One client taps twice, another request times out, a worker wakes up late, and now your team is debating which email was the "real" one. I keep seeing this in auth systems that behave fine in happy-path demos but get fuzzy once real networks show up.

The pattern I trust most is a small PostgreSQL receipt table attached to the resend decision itself. It gives the REST API one durable answer for each logical resend attempt, keeps cooldown rules consistent, and makes support debugging way less dramatic. It also pairs nicely with frontend flows that already try to keep aligned onboarding email state, because the backend stops inventing new outcomes on every retry.

Why resend endpoints drift so easily

Most resend APIs start with good intentions:

  1. Check whether the account is already verified.
  2. Enforce a cooldown like 60 seconds.
  3. Generate or reuse a token.
  4. Push a send event to a queue.

The bug appears when those steps are spread across separate reads and writes. Two app nodes can both decide the cooldown expired. A late retry can reuse stale state. A worker can send a message tied to an older token version. None of this is exotic, it is just normal distributed-system mess sneaking into an endpoint that looked "tiny".

What makes it extra annoying is that inbox-based testing can hide the problem. If one message eventually arrives, the test may pass even though the resend contract is inconsistent. That is why I like keeping the contract in the database first and treating delivery as downstream evidence second.

The receipt table I keep in PostgreSQL

For resend flows, I want one row that answers three questions:

  • which user asked for the resend
  • which cooldown window decided the outcome
  • which outbox event, if any, represents the accepted send

My table usually looks something like this:

create table verification_resend_receipts (
  user_id bigint not null,
  request_key text not null,
  cooldown_bucket timestamptz not null,
  token_version integer,
  outbox_event_id bigint,
  decision text not null check (decision in ('sent', 'suppressed')),
  created_at timestamptz not null default now(),
  primary key (user_id, request_key)
);
Enter fullscreen mode Exit fullscreen mode

The important bit is not the exact column list. The important bit is that the resend decision becomes a first-class record instead of an implied side effect buried in logs. If a request was suppressed by cooldown, I store that too. Engineers often only record successful sends, then later wonder why the API returned 202 without any matching delivery record. That gap gets ugly fast.

I also prefer a request key generated by the client for retriable UI actions. It can be as simple as one UUID per button press. Repeated network attempts then land on the same receipt row, which is honestly the boring outcome you want.

Keep cooldown decisions in the same transaction

The mistake I see most often is reading the last send time outside the transaction that writes the new event. That opens the door for two concurrent requests to both decide "cooldown passed" and each enqueue mail. PostgreSQL can save you from that, but only if you let it.

My usual transaction does this:

  1. Lock or otherwise serialize the user verification state.
  2. Look up an existing receipt for the same request_key.
  3. Compute whether the current cooldown bucket allows a send.
  4. If allowed, create one token version and one outbox event.
  5. Insert exactly one receipt row that records the final decision.

That means the API can return one stable payload for retries: same decision, same cooldown information, same event reference. It feels a bit stricter up front, but it makes later ops work much calmer. If support asks why the user got no second email, you can point at a suppressed receipt instead of squinting at scattered app logs.

A Node.js handler shape that stays understandable

This is the compact shape I like in Node.js:

app.post("/verification-email/resend", async (req, res) => {
  const userId = req.auth.userId;
  const requestKey = req.get("X-Request-Key");

  if (!requestKey) {
    return res.status(400).json({ error: "X-Request-Key is required" });
  }

  const result = await db.tx(async (trx) => {
    const existing = await trx.oneOrNone(
      `select decision, token_version, outbox_event_id, cooldown_bucket
         from verification_resend_receipts
        where user_id = $1 and request_key = $2`,
      [userId, requestKey]
    );

    if (existing) return existing;

    const state = await loadAndLockVerificationState(trx, userId);
    const decision = canResendNow(state) ? "sent" : "suppressed";

    let tokenVersion = null;
    let outboxEventId = null;

    if (decision === "sent") {
      tokenVersion = await rotateVerificationToken(trx, userId);
      outboxEventId = await insertVerificationOutbox(trx, { userId, tokenVersion });
    }

    return saveReceipt(trx, {
      userId,
      requestKey,
      cooldownBucket: nextCooldownBucket(state),
      decision,
      tokenVersion,
      outboxEventId,
    });
  });

  return res.status(202).json(result);
});
Enter fullscreen mode Exit fullscreen mode

There are fancier versions, sure, but this one is easy to reason about during an incident. The database tells you whether the resend was accepted or suppressed. The queue only sends events tied to accepted receipts. Even a weird report involving fake e mail com test accounts or old tamp mail com notes from QA becomes easier to untangle, because the record of truth is not the inbox anymore.

How I test this without trusting the inbox too much

I still use a throwaway email in staging, but only to prove the final rendered message lines up with the accepted receipt. The inbox should confirm output, not define backend truth. For the cheaper end of the pyramid, I like receipt assertions plus outbox assertions in integration tests, then a small number of cheap API inbox smoke tests to make sure delivery still works end to end.

That split has saved me a lot of time, especialy when retries stack up during mobile testing. If a smoke test fails, I can ask:

  • was the receipt created?
  • was the decision sent or suppressed?
  • did one outbox event exist for that receipt?
  • did the inbox content match the accepted token version?

Those are crisp questions. "Did an email show up eventually?" is not. And once a team starts debugging from inbox timing alone, the conversation gets messy real quick.

Q&A

Should suppressed resends create a receipt too?

Yes. A suppression is still a decision made by the API. If you do not persist it, retries and support tooling get much harder to explain.

Do I need a new token on every resend?

Not always. Some systems reuse the active token inside a short window. Others rotate every accepted resend. Either can work, but the receipt should make the choice explicit.

Is this overkill for a small app?

Not really. The schema is tiny, and the payoff comes the first time duplicate email behavior shows up under retry pressure. Small tables that remove ambiguity are rarely wasted work, even if it feels a little nerdy at first.

Top comments (0)