Journal
Engineering3 min read

Event-driven by design: how one payment becomes loyalty, retention, and a receipt

A booking platform lives or dies on consistency. Here is how a single Stripe payment fans out into loyalty points, retention, a receipt and a ledger entry, why every effect is gated on a real state change, and where we are taking the architecture next.

BEThe Bookatu engineering team

When a client pays for a booking, a deposit, a package or a membership, that single event should move the whole system. Their loyalty balance should grow. They should count as active again, not lapsed. They should get a receipt. And the money should land in the books for reconciliation. Get one of those wrong and the product quietly loses trust: a member is charged but their credits never appear, or a Stripe retry double-counts a sale.

For a while each of those was its own island. The webhook marked things paid and stopped there. So we made the payment an event, and let the rest of the system react to it.

The pattern: fan out, but only on a real state change

Diagram: a Stripe payment hits an idempotent webhook, which calls applyPaidRipple, fanning out to loyalty, retention, receipt and ledger.
One payment, four effects. The webhook is the seam; the ripple is best-effort and never blocks the money path.

Stripe sends checkout.session.completed to one webhook. The handler does the money-critical write first (mark the deposit paid, flip the invoice to paid, grant the package credit), then calls a single shared function, applyPaidRipple, for the engagement side. The ripple is best-effort: every step is wrapped so a failure logs and is swallowed, because a flaky email provider must never stop us from recording a real payment.

ts
// In the webhook, after the money-critical write succeeds:if (fresh) {  await applyPaidRipple({    orgId,    customerId,    amountCents,    loyaltyReason: "Package purchase",    receiptTo: email,    receiptDedupeKey: `payment_receipt:package:${session.id}`,  });}// applyPaidRipple: loyalty -> lastVisitAt bump -> transactional receipt,// each in its own try/catch. The ledger write runs unconditionally below.

Idempotency is the whole game

Stripe will deliver the same event more than once. That is a feature, not a bug, and it means every handler has to be safe to run twice. Two layers protect us. First, the event id is recorded in a processed-events table with a unique constraint, so a redelivery of the exact same event short-circuits. Second, and more importantly, each pathway gates its ripple on its own genuine state change: a boolean that is only true the first time the row actually flips.

ts
// markInvoicePaid flips 'open' -> 'paid' and returns true ONLY on that// real transition. A redelivery sees 'paid' already and returns false.const flipped = await markInvoicePaid(invoiceId, paymentIntentId, amountTotal);if (flipped) {  await applyPaidRipple({ /* ...loyalty, retention, receipt */ });}// So even if the event-id dedupe is bypassed, the ripple still fires once.

The dedupe key is not a nice-to-have. It is the difference between a member being thanked once and being charged twice.

Where this is heading

Today the ripple is an in-process fan-out, which is the right amount of machinery for where we are: it is simple, transactional and easy to reason about. But the webhook is deliberately a seam, and the roadmap turns that seam into a durable event bus. The shape stays the same; the delivery gets stronger.

  • A queue in front of each effect (loyalty, retention, messaging), so a slow consumer never holds up the webhook and retries are automatic with backoff.
  • An outbox table written in the same transaction as the money, then relayed to the bus, so an effect is never lost between 'paid' and 'published'.
  • Splitting the heaviest consumers (notifications, retention scoring) into their own deployable services as load grows, while the core booking write stays a single, strongly-consistent unit.
  • A managed event/queue layer on our cloud, so scale is a config change rather than a migration.

The lesson that holds across all of it: keep the money path strongly consistent and synchronous, and make everything that hangs off it event-driven, idempotent and best-effort. A payment is a fact. Everything good that follows from it is a reaction, and reactions should be safe to replay.

engineeringevent-drivenarchitecturestripeidempotencyroadmap
Ready to put this to work?

Bookatu gives you a branded booking page, deposits, memberships, gift cards and reminders, with 0% commission on your bookings.

Start free