DEV Community

Freelance Deal Desk
Freelance Deal Desk

Posted on

Stripe webhooks need a state machine, not just a switch statement

Most Stripe Checkout examples stop at this:

switch (event.type) {
  case "payment_intent.succeeded":
    // mark paid
}
Enter fullscreen mode Exit fullscreen mode

That is a useful beginning, but it is not yet a payment workflow. A real booking flow also has a calendar slot, a database record, retries, late events, and sometimes a human approval step. Treating each webhook as an isolated callback leaves gaps where a paid customer can lose a slot—or where a failed payment leaves a slot unavailable forever.

Here is the smaller model I use before wiring an application to live vendor APIs.

Make the booking state explicit

Record three separate facts:

  • payment_status: awaiting_payment, paid, or failed
  • slot_status: held, confirmed, or released
  • approval_status: not_required, pending_approval, approved, denied, countered, or expired

Those states are deliberately independent. A booking can be paid while its calendar confirmation is retrying. A low-value booking can be held while an operator decides whether to accept it. And a failure must release a held slot without touching a slot that has already been confirmed.

Idempotency belongs at the event boundary

Stripe retries webhooks. Your endpoint must therefore persist the event ID before it performs side effects. A second delivery should return success without charging, confirming, or releasing anything again.

if (seenStripeEvents.has(event.id)) return { duplicate: true };
seenStripeEvents.add(event.id);

if (event.type === "payment_intent.succeeded") {
  await confirmSlot(booking.calSlotId, booking.slotKey);
  await writeBilledBooking(booking, event.data.object.id);
}
Enter fullscreen mode Exit fullscreen mode

In production, seenStripeEvents should be a table with a unique event ID, written in the same transaction that changes the booking state. An in-memory set only makes the example easy to read.

Use one stable key for calendar side effects

A booking ID makes a good idempotency key for the calendar provider:

booking:<booking-id>
Enter fullscreen mode Exit fullscreen mode

Pass that same key when holding, confirming, and releasing a slot. If a network request times out after the provider completed its work, retrying with the same key is safe. This is much better than trying to infer state from a transient API error.

Never trust a webhook without its raw-body signature check

The signature must be computed over the exact raw request body and rejected outside a short replay window. Do this before JSON parsing when the framework requires it. Constant-time comparison also avoids turning a signature endpoint into an oracle.

const expected = createHmac("sha256", secret)
  .update(`${timestamp}.${rawBody}`)
  .digest("hex");

const valid = actual.length === expected.length &&
  timingSafeEqual(Buffer.from(actual, "hex"), Buffer.from(expected, "hex"));
Enter fullscreen mode Exit fullscreen mode

Test the recovery paths first

The happy path is easy to picture. The useful tests are the awkward ones:

  1. payment_intent.succeeded delivered twice confirms the slot once.
  2. payment_intent.payment_failed releases a held slot.
  3. A manual denial releases its hold.
  4. An expired approval releases its hold.
  5. A successful payment cannot bypass a required approval.
  6. Altered or stale webhook signatures are rejected.

Writing these tests before connecting Stripe or a calendar API forces the product decision into code: every hold has a release path, every external event is repeat-safe, and billed records have an immutable payment ID.

For a separate walkthrough of promotion codes in Stripe Checkout, see this test-first guide.

Top comments (0)