Most Stripe Checkout examples stop at this:
switch (event.type) {
case "payment_intent.succeeded":
// mark paid
}
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, orfailed -
slot_status:held,confirmed, orreleased -
approval_status:not_required,pending_approval,approved,denied,countered, orexpired
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);
}
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>
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"));
Test the recovery paths first
The happy path is easy to picture. The useful tests are the awkward ones:
-
payment_intent.succeededdelivered twice confirms the slot once. -
payment_intent.payment_failedreleases a held slot. - A manual denial releases its hold.
- An expired approval releases its hold.
- A successful payment cannot bypass a required approval.
- 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)