How to Prevent Double Bookings When Using External Payment Providers
It's Monday morning. Two people are staring at the same 9 AM consultation slot on your website. User A clicks "Book," gets redirected to Stripe, and starts entering card details. Three seconds later, User B loads the page, sees the slot is still open, and clicks "Book" too.
One of them is about to have a very bad experience — and so are you.
This is the double booking problem, and it bites every developer who tries to wire a scheduling UI to an external payment provider. The fix isn't obvious, but the pattern behind it is elegant once you see it.
The Typical Paid Booking Flow (and Where It Breaks)
Here's what a standard paid-appointment flow looks like:
1. User picks a time slot
2. User gets redirected to a payment provider
3. User enters payment details
4. Payment succeeds
5. Booking is confirmed
Looks clean. Now look at what's not protected:
Between steps 1 and 5, the slot is unprotected. The user is off on Stripe's checkout page, fumbling with 3D Secure, maybe getting distracted for two minutes. During that entire window, your scheduling system still shows the slot as available.
The result is one of two bad outcomes:
- Double booking — two people end up owning the same slot. Someone has to get a cancellation email.
- Ghost payment — User A pays, comes back, and finds their slot was taken by User B while they were on the checkout page. Money charged, no booking to show for it.
Here's what the collision actually looks like in practice:
T+0s User A selects 9 AM → redirected to Stripe checkout
T+3s User B loads the calendar → 9 AM still shown as available
T+5s User B selects 9 AM → redirected to Stripe checkout
T+30s User B completes payment → booking confirmed for 9 AM
T+45s User A completes payment → 9 AM is already taken. Ghost payment.
This isn't a theoretical edge case. If you have popular time slots — the Monday 9 AM, the Friday afternoon — multiple people will try to book them simultaneously.
Why This Is Harder Than It Looks
Your first instinct is to build it yourself. Most developers do. It starts simple and then spirals.
Here's a sketch of what a typical DIY solution looks like:
// 1. "Soft lock" the slot when the user starts checkout
async function startCheckout(slotId: string, userId: string) {
await db.execute(
`UPDATE slots SET locked_by = ?, locked_at = NOW() WHERE id = ? AND locked_by IS NULL`,
[userId, slotId],
);
const row = await db.query(`SELECT locked_by FROM slots WHERE id = ?`, [
slotId,
]);
if (row.locked_by !== userId) {
throw new Error("Slot already taken");
}
return redirectToStripe(slotId);
}
// 2. Cron job to clean up expired locks (runs every minute)
cron.schedule("* * * * *", async () => {
await db.execute(
`UPDATE slots SET locked_by = NULL, locked_at = NULL
WHERE locked_at < NOW() - INTERVAL 15 MINUTE AND confirmed = false`,
);
});
// 3. Webhook handler to confirm booking after payment
async function handleStripeWebhook(event: StripeEvent) {
if (event.type === "checkout.session.completed") {
const slotId = event.metadata.slotId;
const slot = await db.query(`SELECT * FROM slots WHERE id = ?`, [slotId]);
// Race condition: lock may have expired between payment and webhook
if (!slot.locked_by) {
await refundPayment(event.payment_intent);
await sendApologyEmail(event.customer_email);
return;
}
await db.execute(`UPDATE slots SET confirmed = true WHERE id = ?`, [
slotId,
]);
}
}
This mostly works. But now you're maintaining:
- A locking mechanism with database-level atomicity concerns
- A cron job that has to run reliably, even under load
- Webhook handlers that need to be idempotent (Stripe can send the same event twice)
- Refund logic for edge cases where the lock expired mid-payment
- Calendar sync to propagate confirmed bookings to Google Calendar or iCloud
- Tests for every race condition permutation
And you haven't even handled what happens when your cron runs during a payment completion, or when two webhooks arrive out of order, or when the user refreshes the return page three times.
The problem isn't any single piece. It's that they all have to work together, perfectly, under concurrency.
The Solution: Reserve, Then Confirm
The underlying pattern that solves this cleanly is reserve, then confirm — a two-phase approach where slot selection and slot finalization are treated as separate, explicit operations.
The pattern has three properties:
Atomic reservation. When a user selects a slot, it's immediately and atomically removed from the availability pool. No window where two users can grab the same slot. Either you got the reservation or you didn't.
TTL-based expiry. The reservation has a time-to-live. If the user doesn't complete payment within that window (say, 15 minutes), the reservation expires automatically and the slot returns to the pool. No cron jobs, no cleanup scripts.
Idempotent confirmation. Confirming a reservation is a safe operation to call multiple times. If a webhook fires twice, or the user hits refresh on the success page, the result is the same: one booking, no duplicates.
In pseudocode, the flow looks like this:
// Step 1: User selects a slot — reserve it atomically
const reservation = reserveSlot(slotId, { ttl: "15m" });
// Slot is now invisible to other users
// Step 2: Redirect to payment
redirectToPaymentProvider({ metadata: { reservationId: reservation.id } });
// Step 3: On payment success — confirm the reservation
onPaymentSuccess(({ reservationId }) => {
confirmReservation(reservationId);
// Reservation becomes a permanent booking
});
// If payment never completes, the reservation expires on its own.
Now the two scenarios resolve cleanly:
Success path:
T+0s User A selects 9 AM → reservation created → slot removed from calendar
T+3s User B loads the calendar → 9 AM is not shown (reserved)
T+30s User A completes payment → reservation confirmed → booking finalized
User B never even sees the slot. There's no window for a collision.
Timeout path:
T+0s User A selects 9 AM → reservation created → slot removed from calendar
T+3s User B loads the calendar → 9 AM is not shown
T+900s User A abandons payment → reservation TTL expires
T+901s User B refreshes → 9 AM is available again
The system self-corrects. No one had to do anything.
Building This Yourself vs. Using a Scheduling API
You can build the reserve/confirm pattern from scratch. It's a well-understood concept. But the implementation details are where the difficulty hides:
Slot-level locking needs to be atomic across concurrent requests — not just within a single database, but across any connected calendars (Google Calendar, iCloud) that also reflect availability.
Reliable TTL expiration can't depend on a cron job that might be delayed under load. The expiry needs to be precise, or you'll either release slots too early (breaking in-progress payments) or too late (blocking availability unnecessarily).
Idempotent confirmation means your confirm endpoint must handle duplicate calls gracefully — which means tracking reservation state, guarding against race conditions between expiry and confirmation, and ensuring calendar events aren't created twice.
Calendar synchronization adds another layer: a confirmed booking needs to appear in the provider's calendar within seconds, and a released reservation needs to not leave behind ghost events.
If scheduling is a core part of your product and you want full control, building this is reasonable. But if you're adding paid bookings to an existing app — a coaching platform, an event site, a SaaS with appointment features — the infrastructure cost may not be worth it.
How Zaptime Implements This
Zaptime is a scheduling API that implements the reserve/confirm pattern as a first-class feature. Here's what the integration looks like using the Vue composable:
import { reserve, confirm } from "@zaptime/vue3";
async function handleBookingWithPayment() {
// Step 1: Lock the slot — atomically removed from all connected calendars
await reserve();
// Step 2: Process payment (Stripe, GoPay, any provider)
const paymentResult = await processPayment();
// Step 3: Finalize only if payment succeeded
if (paymentResult.success) {
await confirm();
}
// If payment fails, the reservation auto-expires. That's it.
}
Under the hood, reserve() performs atomic locking across all calendars connected to the event — Google Calendar, iCloud, or others. The reservation has a configurable TTL (up to 15 minutes), after which the slot silently returns to the availability pool. Calling confirm() is idempotent: duplicate webhook deliveries or page refreshes won't create duplicate bookings.
Zaptime offers three integration levels depending on how much UI control you need:
- Embeddable widget — a booking link or iframe. Zaptime handles everything, including built-in paid event support.
- Vue/Nuxt component — the
<ZaptimeCalendar />component with composables likeuseCalendar()anduseSelectedTimeSlot()for custom flows around the reserve/confirm cycle. - Headless API — call the Zaptime API directly from any backend, any framework, any payment provider.
The reserve/confirm pattern works the same way at every level.
Wrapping Up
The hardest part of paid bookings isn't the payment and it isn't the scheduling. It's the seam between them — the moment where a slot is selected but not yet paid for, where the user is on someone else's checkout page, where your system is holding its breath.
The reserve/confirm pattern closes that gap: lock the slot atomically, give the user time to pay, confirm on success, auto-release on failure.
Whether you build it yourself or use a tool that ships it natively, the pattern is the same. Get the reservation right, and double bookings stop being a problem.