Skip to content
WhenTap
← All articles
Engineering

The paid booking that said "pending", a Stripe Connect webhook story

The WhenTap team··7 min read

There is a particular kind of bug that is worse than a crash: the one where everything reports success and the result is still wrong. We hit one wiring Stripe payments into WhenTap. A customer paid for a booking, Stripe showed the charge as complete, the money was on its way to the right account, and the booking in WhenTap sat there saying pending, as if nothing had happened.

Nothing had failed. The payment was real. The bug was in a detail of how Stripe Connect delivers events, and it is worth writing down because it is easy to get wrong and invisible when you do.

How WhenTap takes payment

WhenTap does not sit in the middle of your money. When you connect Stripe, you connect your own Stripe account, and paid bookings charge the customer and pay out to you directly. WhenTap sets its platform fee to zero, so it takes 0% of a booking. (More on that model in taking payment without a cut.)

Mechanically, that means when a customer pays for a booking, the Checkout session is created on your connected account, not on WhenTap’s platform account. That one design choice, correct and necessary for the 0% model, is exactly what set up the bug.

From building WhenTap: we ran a real test booking through the deployed widget, paid it with a Stripe test card, and watched Checkout complete. The Stripe dashboard confirmed the payment. Then we looked at the booking: still pending, still holding the slot as unpaid. We re-checked the charge, it was genuinely paid. We re-checked our webhook handler, the code was fine. The handler was simply never being called for that event. Stripe had sent the notification somewhere our listener wasn’t.

A booking only flips from pending to confirmed when WhenTap receives the checkout.session.completed webhook and verifies it. The payment worked, but that event never reached the endpoint doing the verifying, so the booking never advanced. The slot stayed held, the customer got no confirmation, and from the outside it looked like the payment had silently failed, even though it was sitting completed in Stripe.

Why: connected-account events go to the Connect endpoint

Here is the part that is easy to miss. In Stripe Connect, there are two kinds of webhook endpoint:

  • A platform endpoint (connect=false) receives events about your own platform account, like your customers’ subscription payments to you.
  • A Connect endpoint (connect=true) receives events that happen on connected accounts, like a booking checkout completing on a customer’s own Stripe.

Because a booking’s Checkout is created on the connected account, checkout.session.completed for that booking is a connected-account event. It is only ever delivered to a Connect endpoint. Our webhook was registered as a platform endpoint, so it faithfully received every subscription event and never saw a single booking payment. The event was not lost; it was delivered exactly as Stripe intends, to an endpoint we hadn’t set up yet.

A checkout created on a connected account emits an event that only reaches the Connect webhook endpoint, never the platform endpoint that handles subscriptions

This is the sort of thing the documentation does tell you, in a sentence, somewhere. But it does not feel true until you have a paid-but-pending booking in front of you and have to work backwards from “the money moved but the code never ran” to “oh, the event is going to a different endpoint entirely.”

The fix, in two layers

We fixed it in two layers, because a payment system should never hinge on a single event arriving.

First, the right endpoint. We registered a Connect webhook endpoint so connected-account events actually reach WhenTap, and made the handler verify signatures against more than one signing secret. That lets the platform endpoint (subscriptions) and the Connect endpoint (booking payments) share one handler cleanly, each with its own secret, without either rejecting the other’s events as unsigned.

Second, a reconcile pass. Even with the endpoint correct, webhooks can be delayed, retried, or dropped. So there is now a reconciliation step that takes any booking still sitting pending and asks Stripe directly whether its session was paid, then confirms it if so. The webhook is the fast path; reconciliation is the backstop that makes “paid but pending” self-heal instead of needing a human. The booking that started this whole investigation was the first thing the reconcile pass fixed.

The lesson: never trust a webhook to always arrive

The specific bug is a Stripe Connect detail. The general lesson is broader and applies to any event-driven integration: a webhook is a notification, not a guarantee. It can go to the wrong endpoint, arrive late, or not arrive at all. Anything important, like whether a customer’s booking is actually paid, needs a way to be re-derived from the source of truth on demand, not just set once when an event happens to land.

For WhenTap that means a paid booking has two independent paths to confirmed: the webhook when it arrives, and a direct check against Stripe when it doesn’t. Payments are one place where “probably delivered” is not good enough, because the failure is invisible and lands on your customer. If you are connecting payments, see taking payments with Stripe; for the same “the event went where you didn’t expect” lesson in calendars, see two-way calendar sync.

Take bookings on your own Webflow site

No Calendly redirect, no cut of your bookings. Start free.