Double-bookings are one of those bugs that feel impossible until they happen to you β and then they feel obvious in hindsight.
Here's the scenario: your app shows a list of available slots. Two users see the same open slot at 10:00 AM on Thursday. Both tap "Book" within a second of each other. Your backend receives two requests for the same provider, same date, same time.
What happens next depends entirely on how you're booking.
Why date+time Booking Is Unsafe
The classic booking call looks like this:
// β οΈ Unsafe
await orita.book({
eventTypeId: "evt_123",
date: "2026-08-05",
time: "09:00"
});
# β οΈ Unsafe
orita.book(
event_type_id="evt_123",
date="2026-08-05",
time="09:00"
)
This is optimistic concurrency. Your client assumes the slot is still open at the moment it calls book(). There's no reservation. There's no lock. The slot was open when you fetched availability β but that was seconds or minutes ago.
Here's the timeline when two users race:
T=0ms User A sees slot "09:00 Thursday" as available
T=0ms User B sees slot "09:00 Thursday" as available (same fetch)
T=800ms User A taps "Book"
T=850ms User B taps "Book" (50ms later β well within normal UX timing)
T=900ms Request A arrives at server β checks availability β slot open β books it β
T=910ms Request B arrives at server β checks availability β slot... still open?
(depends on read isolation, cache TTL, replication lag)
β books it too β (double-booking)
In a monolith with synchronous DB writes, you might get lucky. In a distributed system with any caching layer β and most production scheduling systems have one β you will eventually hit this. Usually at the worst possible time.
How slotId Fixes This
When you get slots back from getSlots or resolveScheduling, each option includes a slotId:
// β
Safe
await orita.book({
slotId: "c2xvdC10MDAxfDIwMjYtMDgtMDV8MDk6MDA"
});
# β
Safe
orita.book(slot_id="c2xvdC10MDAxfDIwMjYtMDgtMDV8MDk6MDA")
The difference isn't cosmetic. When you pass a slotId, the server validates and claims the slot atomically β no separate availability check, no window between "is it free?" and "mark it taken." The slotId carries the full identity of the slot (provider, date, time), and the booking operation uses it as a key for an atomic write.
If two requests arrive with the same slotId, one succeeds and the other gets a 409:
{
"error": "slot_unavailable",
"message": "This slot has already been booked. Please select another time.",
"code": 409
}
No double-booking. No silent failure. A clear, actionable error.
The Correct UX Pattern
The booking flow with slotId looks like this:
1. Call getSlots or resolveScheduling
β Receive options[], each with a fresh slotId
2. Display options to the user
3. User selects a slot
4. Call book({ slotId })
β 200: booking confirmed
β 409: slot was taken between display and selection
Step 4 is where it matters. The slotId encodes the slot's identity at the moment it was fetched β provider, date, time β so the server can verify it's still valid at booking time without a separate round-trip.
Status values your booking response can return:
| Status | Meaning |
|---|---|
| pending | Booking created, awaiting provider confirmation |
| confirmed | Booking confirmed by both sides |
| cancelled | Booking was cancelled |
| completed | Session has occurred |
Most platforms receive pending immediately and confirmed within seconds via webhook.
slotId Is Deterministic β But Treat It As Opaque
A slotId is a base64url encoding of eventTypeId|providerId|date|HH:MM. That means the same slot always produces the same slotId β it's deterministic and reproducible.
You can decode it:
const decoded = atob(slotId.replace(/-/g, '+').replace(/_/g, '/'));
// β "evt_123|prov_abc|2026-08-05|09:00"
But you shouldn't rely on this. The internal format is an implementation detail. Treat slotIds as opaque tokens β get them from the API, pass them back to the API. Don't construct them yourself, don't cache them long-term, and never hardcode one.
Never Hardcode a slotId
This is the corollary to "treat it as opaque":
// β Never do this
const ALWAYS_BOOK_THIS_SLOT = "c2xvdC10MDAxfDIwMjYtMDgtMDV8MDk6MDA";
await orita.book({ slotId: ALWAYS_BOOK_THIS_SLOT });
A slotId is valid only for the slot it represents. If that slot has already been booked, the slotId is permanently dead β you'll get a 409 every time. Always fetch fresh slotIds from getSlots or resolveScheduling immediately before presenting options to the user.
Handling 409: Don't Just Show an Error
A 409 is not a bug in your code β it's the system working correctly. What matters is what you do next.
Wrong:
"An error occurred. Please try again."
Right:
"Someone just booked that slot. Here are the next available times:"
[show fresh options]
In practice:
try {
const booking = await orita.book({ slotId: selectedSlotId });
showConfirmation(booking);
} catch (err) {
if (err.status === 409) {
// Slot was taken β fetch fresh options and show alternatives
const { options } = await orita.getSlots({ ... });
showAlternatives(options);
} else {
throw err; // unexpected error, surface it
}
}
try:
booking = orita.book(slot_id=selected_slot_id)
show_confirmation(booking)
except OritaError as e:
if e.status == 409:
# Fetch fresh and show alternatives
options = orita.get_slots(...)["options"]
show_alternatives(options)
else:
raise
The UX goal is to keep the user in the flow. A 409 isn't a dead end β it's a redirect to a fresh set of options, with a friendly explanation. Most users will book the next available slot without a second thought.
Summary
| | date+time booking | slotId booking | |---|---|---| | Race condition possible | β Yes | β No | | Atomic validation | β No | β Yes | | 409 on conflict | β Silent or undefined | β Always | | Treat as opaque | N/A | β Yes |
The rule is simple: always book with slotId. Get fresh slotIds from getSlots or resolveScheduling, display them to the user, and pass the selected one directly to book(). Handle 409 by fetching fresh options.
If you've shipped a scheduling feature before, you've probably been burned by double-bookings at some point. slotId is the fix β not a mitigation, not a retry strategy. The fix.
