Payments · Stripe Connect

When is the money actually there?

A marketplace takes money from a buyer, keeps a cut, and passes the rest to a seller. The hard part isn't the charge. It's knowing when the charge is real.

The shape of the problem

A two-sided marketplace where customers pay for a session with a provider. The platform keeps a percentage; the provider receives the remainder. After paying, the customer unlocks something that was previously hidden — in this case a private booking link.

That last detail is what makes it a security problem rather than a plumbing problem. There is a thing of value behind a paywall, and the only question that matters is: has this person actually paid?

The trap: fulfilling on the redirect

The obvious implementation is to fulfil when the payment provider redirects the customer back to your success URL. It is obvious, it is easy, and it is wrong for two independent reasons.

  • The redirect is client-controlled. It is a URL in a browser. Anyone can navigate to it directly, bookmark it, or share it. If that request marks a booking paid, you have built a free checkout.
  • The redirect can precede settlement. Several payment methods authorise immediately and settle asynchronously, minutes or days later. The customer is back on your success page while the money is still in flight — and may still fail.

The redirect tells you the customer came back. It does not tell you the money arrived. Those are different events, and only one of them is worth acting on.

What I built instead

Fulfilment is driven exclusively by signature-verified webhooks from the payment provider. The webhook endpoint reads the raw request body, verifies the signature against a shared secret, and only then interprets the payload. No signature, no state change.

The redirect still exists — customers need somewhere to land — but it is a purely cosmetic destination. It reads current booking state and renders it. It never writes.

The endpoint needs the raw body. If your framework has already parsed JSON by the time the handler runs, the signature will never match — the bytes it was computed over are gone. This catches people constantly:

// Raw body, mounted before any JSON body parser touches this route.
router.post('/webhooks/stripe', rawBody(), async (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,                        // Buffer, not a parsed object
      req.headers['stripe-signature'],
      config.get('stripe.webhookSecret'),
    );
  } catch {
    return res.sendStatus(400);        // unverified: stops here
  }

  await handle(event);
  res.sendStatus(200);                 // ack fast; Stripe retries non-2xx
});

Two details that matter more than they look. Acknowledge quickly — anything slow in the handler gets you retried and eventually disabled. And return 400 on a bad signature rather than throwing, because an unhandled exception here reads to the provider as "endpoint broken" instead of "request rejected."

Events worth handling

A checkout has more outcomes than "worked" and "didn't":

  • Completed — the common path, but still guarded on the payment status field inside the payload rather than the event name alone. A session can complete with payment unpaid.
  • Async payment succeeded — the delayed methods landing later.
  • Async payment failed — the same methods failing later, after the customer has long since closed the tab.
  • Expired — the customer abandoned checkout. Worth recording so the booking doesn't sit pending forever.
  • Refunded — access must be revoked, not just accounted for.
  • Account updated — the provider's own onboarding status changed, which affects whether they can be paid at all.

Splitting the money

Payouts use destination charges: the platform is the merchant of record, takes an application fee, and the remainder settles automatically into the provider's connected account. The alternative — collecting everything and transferring later — means holding other people's money and building your own payout scheduler. Not a business I wanted to be in.

Sellers onboard themselves through a hosted flow. The platform never sees or stores their bank details, which keeps a whole category of compliance out of the codebase.

Invariants worth enforcing

  • Amounts are computed server-side from the stored offering, never accepted from the client. A price in a request body is a suggestion, not a fact.
  • Money is stored in integer minor units. Floating point and currency should never meet.
  • The gated resource never appears on a public serializer. Not hidden by the UI — absent from the response shape entirely. It is returned only from the buyer's own paid booking.
  • The connected-account identifier is equally absent from anything public.

The fee is derived, never passed in:

const feePercent = config.get('payments.platformFeePercent');
const amountMinor = offering.amountMinor;       // from the DB, not the request
const applicationFeeMinor = Math.round(amountMinor * feePercent / 100);

await stripe.checkout.sessions.create({
  line_items: [{
    price_data: { unit_amount: amountMinor, currency },
    quantity: 1,
  }],
  payment_intent_data: {
    application_fee_amount: applicationFeeMinor,
    transfer_data: { destination: seller.stripeAccountId },  // destination
  },
  metadata: { bookingId: booking._id.toString() },  // webhook finds us by this
});

That metadata.bookingId is the thread tying the webhook back to your own record. Without it you're matching on session identifiers you have to store and look up anyway — with it, the event tells you which row to update.

Modelling seller readiness

The first version had four payout states, including a PENDING between "not started" and "enabled". It was removed in a later pass. Every read in the system branched on a single question — can this seller be paid, yes or no — so the intermediate state carried no logic and existed only to be misread. Collapsing it to not started / enabled / disabled removed a class of bug where code treated "pending" as good enough.

A state that no consumer branches on is not a state. It's a comment with a database column.

What I'd build next

Reconciliation. Webhooks can be missed — endpoints go down, deploys drop requests, providers retry and eventually give up. The correct backstop is a scheduled job that pulls the provider's own record of truth and compares it against local state, alerting on drift rather than trusting that every event arrived.

Stronger idempotency is the other half. Webhooks are at-least-once by design; a handler that isn't idempotent will eventually double-fulfil. Storing processed event identifiers and short-circuiting repeats is cheap insurance.

Stronger idempotency is the other half. Webhooks are at-least-once by design; a handler that isn't idempotent will eventually double-fulfil.

There's a second idempotency problem earlier in the flow that's easier to miss. A double-tapped buy button, a retried request, a flaky connection — any of them can create two bookings for the same offering, each with its own live payment artifact. Both can settle. The customer pays twice.

// Before creating anything, look for an in-flight attempt.
const pending = await bookings.findOne({
  customer: accountId, offering: offeringId, status: 'PENDING',
});

if (pending) {
  const resumed = await resumeIfStillLive(pending);  // open? return its URL
  if (resumed) return resumed;                       // dead? fall through
}

Reusing a live attempt rather than minting a new one is the fix. The subtlety is what counts as "live" — an expired session or a cancelled intent should fall through to a fresh booking, or you wedge the customer on a dead link forever.

← All engineering notes