DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One payment can run our webhook four times, and the token has to come out the same every time

A customer buys a Notifio licence. Stripe sends checkout.session.completed to our webhook. The handler generates an activation token, writes a row, and emails the token.

Then it may run again. Four separate times, for reasons that have nothing to do with each other, and every one of them has to produce the same token the customer already has.

// Resolve the license token for this purchase. The token is STABLE once
// created, so retries (e.g. after an email-send failure) always reuse it
// and never issue a second row / second token.
let token: string;
Enter fullscreen mode Exit fullscreen mode

That comment is the whole design. Here are the four paths into it.

Path one: the same session, delivered twice

const [bySession] = await db
  .select()
  .from(licenses)
  .where(eq(licenses.stripeSessionId, session.id))
  .limit(1);

if (bySession) {
  // This exact session was already processed — this is a retry.
  token = bySession.token;
}
Enter fullscreen mode Exit fullscreen mode

The session id is a unique column, so this lookup is the authoritative answer to "have I already fulfilled this". It is checked first because it is the only one of the four that can be answered with certainty.

Note what this is not: an events table, a processed-webhook-ids set, or an idempotency key. The row the handler would have created is itself the receipt, so there is nothing extra to store and nothing extra to expire. That works because the fulfilment and the record are the same write. It would not work if fulfilment were a side effect somewhere else.

Path two: the same person, a different session

const [byEmail] = await db
  .select()
  .from(licenses)
  .where(eq(licenses.email, email))
  .limit(1);

if (byEmail) {
  // Duplicate purchase with a *different* session id. Reuse the existing
  // license instead of inserting (which would violate UNIQUE(email)).
  token = byEmail.token;
  console.warn(
    `[stripe-webhook] Duplicate purchase for ${email} (session ${session.id}); reusing existing license.`
  );
}
Enter fullscreen mode Exit fullscreen mode

This is a genuine second payment. Not a retry, not a race: somebody paid twice. /api/checkout returns a 409 for an email that already has a licence, so the normal route is closed, but a Payment Link, a Stripe dashboard invoice, or two tabs opened before either completed will all get here.

The decision is to fulfil it as the licence they already have, and log loudly. The alternatives are worse. Inserting would hit UNIQUE(email) and throw, which returns a 500, which makes Stripe retry forever on a payment that cannot succeed. Issuing a second row under a modified email would quietly give one human two licences and make the refund conversation incoherent. Reusing means the customer ends up with one working licence and one payment to refund, which is a support ticket rather than a bug.

Path three: two deliveries racing

token = generateToken();
try {
  await db.insert(licenses).values({
    id: createId(), email, token, stripeSessionId: session.id, active: true,
  });
} catch (err) {
  // Concurrent webhook won the race and inserted first. Fall back to
  // the row that won so we still deliver a valid token.
  const [row] = await db
    .select()
    .from(licenses)
    .where(eq(licenses.email, email))
    .limit(1);
  if (!row) throw err; // genuine DB error → 500 so Stripe retries
  token = row.token;
}
Enter fullscreen mode Exit fullscreen mode

Both earlier lookups can return nothing and the insert can still fail, because the two deliveries overlap: both read an empty table, both generate a token, both try to insert. One wins on UNIQUE(email).

The catch block is doing something more careful than it looks. It does not assume the error was the constraint. It goes and checks whether a row now exists, and only treats it as a lost race if one does. If the select comes back empty, the original error is rethrown, which becomes a 500, which is what you want for a connection failure or a schema problem: Stripe retries, and the failure stays visible.

Reading the error code instead would have been the other way to write this, and it ties the handler to a Postgres error string. Asking the database the question you actually have, "is there a row for this email now", works whichever error you got.

The loser's generated token is simply discarded. Tokens are cheap, and the one the winner wrote is already the one the system will validate.

Path four: the row is right and the email failed

const { success, error } = await sendEmail({ ... });

if (!success) {
  // A paid customer not receiving their token is critical — surface it.
  Sentry.captureException(
    error instanceof Error ? error : new Error("Activation email failed to send"),
    { level: "error", tags: { area: "stripe-webhook" }, extra: { email, sessionId: session.id } }
  );
  return NextResponse.json({ error: "Failed to send activation email." }, { status: 500 });
}
Enter fullscreen mode Exit fullscreen mode

The comment above this is the one that matters most:

// Deliver the activation email. sendEmail never throws — it returns a
// result — so we MUST inspect it. If delivery fails, return a non-2xx so
// Stripe retries later; the stable token means the resend is idempotent.
Enter fullscreen mode Exit fullscreen mode

A wrapper that returns { success, error } instead of throwing is a reasonable choice and a sharp edge. There is no way for the type system to insist you look, and the failure mode of not looking is the worst one this system has: the money is taken, the row is written, the webhook returns 200, and the customer has a licence they were never told the token for. Nothing is broken anywhere. It just does not work for them.

Returning 500 is what makes the retry happen, and the retry is safe precisely because path one will find the session id and hand back the same token. The resend is a resend, not a reissue. There are more rules about which of our emails are allowed to fail quietly in Four kinds of email, one endpoint, and only one caller is allowed to throw.

Two gates before any of that

The handler does not reach the token logic until two things are true.

// Only fulfil sessions that are actually paid. Async payment methods can
// fire checkout.session.completed while still "unpaid"/"processing".
if (session.payment_status && session.payment_status !== "paid") {
  return NextResponse.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

The event name says completed, which most people read as paid. With a delayed notification method, a session can complete while the payment is still processing, and fulfilling there means issuing a licence for money that may never arrive. The acknowledgement is a 200 because this is not an error and there is nothing to retry; the paid state will arrive as its own event.

And the upgrade branch is checked before the licence logic entirely:

// Auto-reply upgrade. Handled before the licence logic below, which assumes
// the payment is for a new licence and would otherwise issue a second one
// (and re-send an activation email) to someone who already has one.
if (session.metadata?.["kind"] === UPGRADE_METADATA_KIND) {
  await handleUpgrade(session);
  return NextResponse.json({ received: true });
}
Enter fullscreen mode Exit fullscreen mode

Both products are one-time payments through the same event, so the ordering of those two blocks is load-bearing. That one has its own post: Two products, one Stripe event, and the branch that must come first.

The one place we deliberately do not retry

handleUpgrade resolves which licence to upgrade by metadata first and email second:

if (!license) {
  // Someone paid and we cannot grant them the feature. Surface it rather
  // than dropping it silently.
  Sentry.captureMessage("Auto-reply upgrade could not be matched to a licence", {
    level: "error",
    tags: { area: "stripe-webhook" },
    extra: { sessionId: session.id, licenseId, email },
  });
  return;
}
Enter fullscreen mode Exit fullscreen mode

It returns rather than throwing, so the webhook answers 200 and Stripe never tries again. That is the opposite of path four, and it is deliberate: a retry would re-run the same two lookups against the same data and fail the same way. There is no version of this that a machine fixes, so it becomes an alert with the session id attached and a human grants the upgrade.

Worth being clear about the cost: that decision makes a Sentry alert the only thing standing between a paying customer and silence. If the alert is not watched, the failure is permanent. Returning 500 would have made Stripe keep asking, which is a louder and more annoying form of the same alert, and honestly a defensible alternative.

The granting path itself is idempotent by the same constraint trick as the licence:

if (license.autoReply) {
  console.log(`[stripe-webhook] Licence ${license.id} already upgraded; session ${session.id} ignored`);
  return;
}
Enter fullscreen mode Exit fullscreen mode

plus auto_reply_session_id being unique, so a retry cannot be recorded as a second purchase even if the boolean check were ever bypassed.

What transfers

  1. Pick one stable identifier per payment and never regenerate it. Everything else in this handler is downstream of the token being stable. Once it is, every retry path collapses into "find the existing one".
  2. Let the fulfilment row be the idempotency record. If the thing you create is uniquely keyed by the thing that caused it, you do not need a separate processed-events table.
  3. In a race, ask the database the question, not the error. "Does a row exist now" is portable and honest. "Is this error code 23505" is a guess about which error you got.
  4. A result object instead of an exception means you have to write the check. Grep for every caller of any { success, error } function and confirm each one inspects it. The one that does not will be the one that matters.
  5. Decide per failure whether a retry can possibly help. Email send: yes, retry. Unmatchable upgrade: no, alert a human. Returning 500 for something a machine cannot fix just buries the alert in noise.

The product all of this exists to deliver is a one-time £20 desktop app: notifio.app/pricing for what you get, notifio.app/download for the installers, and notifio.app/help for what the token does once it arrives.

Top comments (3)

Collapse
 
makeev profile image
Mikhail Makeev •

Your fifth point, deciding per failure whether a retry can help, is one we learned backwards. Our handler answered 200 to anything it produced, errors included. One of those errors was user-not-found, and it looked like your unmatchable upgrade: nothing a machine could fix. It was a race. The webhook could land before our user row had its Stripe customer id committed, so the same lookup a minute later would have worked. Now that case answers 503 and gets redelivered, and an unknown price id still gets a 200. Does the warn log in path two turn into a refund by hand, or does something pick it up?

Collapse
 
muhammad_turnergane_7ddc profile image
Muhammad Turner Gane •

Path four is the one I would have missed. A send wrapper that returns a result instead of throwing is exactly how a paid customer ends up with a row and no email while every log line looks fine.

One thing worth double checking on the payment_status gate: when a delayed payment method does clear, Stripe sends it as checkout.session.async_payment_succeeded, not as a second checkout.session.completed. If the endpoint isn't subscribed to that event, or that event doesn't run the same token path, those customers pay and nothing ever issues their licence. It's easy to miss because card payments never go down that branch in testing.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.