Engineering
The Stripe + paid-bookings pattern for solo SaaS founders
How to charge invitees per meeting via Stripe Checkout without building a billing system. Includes the slot-holding logic and 30-minute cleanup pattern.
Paid bookings — where the invitee pays you for the meeting they're scheduling — is one of the most useful and least-implemented patterns in the scheduling-tool space. Calendly hides it behind their $20/mo plan and an add-on. Cal.com makes you build it yourself. SavvyCal doesn't do it.
You can ship it end-to-end in about 200 lines of code using Stripe Checkout. Here's the pattern.
The flow
- Invitee fills the booking form and clicks Confirm
- Server creates the booking with status=CONFIRMED, paymentStatus=“unpaid”
- Server creates a Stripe Checkout session in mode=payment with the booking ID as client_reference_id
- Server redirects the invitee to session.url
- Stripe collects payment and redirects back to /booking/confirmed?id=...&paid=1
- Stripe webhook (checkout.session.completed) flips paymentStatus to “paid”
- Webhook fires the normal post-booking side effects: confirmation email, ICS attachment, host notification, your own webhooks
The slot-holding question
The interesting design question is: what happens to the slot while the invitee is on the Stripe Checkout page? Two paths:
- Hold the slot — create the booking as CONFIRMED before redirecting, so nobody else can book it. Risk: if the invitee abandons checkout, the slot is locked forever.
- Don't hold the slot — only create the booking after payment. Risk: someone else books the slot mid-checkout and Stripe charges the first invitee for a meeting that no longer exists.
Hold the slot. The abandonment risk is solvable; the double-booking risk is not. Solve abandonment with two mechanisms:
- Listen for
checkout.session.expiredin your Stripe webhook (fires 24 hours after creation, or immediately on user-cancellation in some cases) and delete the booking when it fires. - Run a cron every 10 minutes that deletes any booking with paymentStatus=“unpaid” older than 30 minutes. Belt-and-braces.
The minimum viable Stripe Checkout call
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer_email: invitee.email,
client_reference_id: booking.id,
line_items: [{
price_data: {
currency: eventType.currency,
product_data: { name: eventType.title },
unit_amount: eventType.priceCents
},
quantity: 1
}],
metadata: { bookingId: booking.id, type: "booking_payment" },
success_url: `${appUrl}/booking/confirmed?id=${booking.id}&paid=1`,
cancel_url: `${appUrl}/${user.username}`
});
await prisma.booking.update({
where: { id: booking.id },
data: {
paymentStatus: "unpaid",
stripeCheckoutId: session.id
}
});
return redirect(session.url);The webhook handler
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === "payment" && session.metadata?.type === "booking_payment") {
const bookingId = session.metadata.bookingId;
const booking = await prisma.booking.update({
where: { id: bookingId },
data: { paymentStatus: "paid", stripeCheckoutId: session.id },
include: { eventType: true, user: true }
});
await Promise.allSettled([
sendBookingConfirmation(booking),
sendHostBookingNotification(booking),
dispatchBookingEvent("booking.created", booking)
]);
}
break;
}Things to skip
- Don't build a refund flow. Stripe's dashboard handles it. If you cancel a paid booking, decide your policy (refund or not) and refund manually in Stripe.
- Don't build receipts. Stripe sends a receipt email by default.
- Don't take a platform fee on top. Charging customers extra to use the “paid bookings” feature when Stripe already takes 2.9% is greedy and competitively bad.
The biggest unlock for solo consultants and coaches is the 2-minute setup to start charging for their time. Don't bury it behind an enterprise tier.