Booking an appointment sounds simple until two users try to book the same slot at the same time.
This is the fundamental scheduling race condition: both users see a slot as available, both submit a booking request, and one of them gets an error after the fact — or worse, both bookings are confirmed and a provider gets double-booked.
Orita's three-step workflow is designed to make this problem impossible. The pattern is: resolve → hold → confirm.
The Problem: Slot Contention
Before getting into the solution, it's worth being precise about what causes this problem.
When you query available slots and then submit a booking, there is an irreducible gap between those two operations. In a concurrent system with multiple users or multiple AI agent threads, another booking can succeed in that window.
A naive implementation looks like this:
User A: GET /availability → [slot at 3pm: available]
User B: GET /availability → [slot at 3pm: available]
User A: POST /bookings { slot: "3pm" } → 200 OK
User B: POST /bookings { slot: "3pm" } → 200 OK ← double booking
The fix requires an atomic reservation — a step that temporarily claims a slot before committing to it, so no other booking can succeed against the same slot during your decision window.
The Three-Step Pattern
Step 1: Resolve
POST /api/v1/resolutions
The resolution finds available provider-and-time options that satisfy your constraints and preferences. It returns a ranked list of options with scores, matched constraints, and reasoning.
// Request
{
"serviceId": "svc_therapy_60min",
"windowStart": "2026-08-04T00:00:00Z",
"windowEnd": "2026-08-08T23:59:59Z",
"constraints": {
"languageCodes": { "anyOf": ["es"] },
"modalityCodes": { "anyOf": ["video"] }
},
"preferences": {
"dayParts": ["afternoon"]
}
}
// Response
{
"resolutionId": "res_4k8m2p",
"options": [
{
"optionId": "opt_7x2c",
"providerId": "prov_9a21b",
"score": 94,
"slot": { "start": "2026-08-06T15:00:00Z", "end": "2026-08-06T16:00:00Z" },
"reason": "Matches all required conditions and is the earliest available afternoon appointment."
}
]
}
At this point, no slot has been reserved. The options represent what's available now.
Step 2: Hold
POST /api/v1/resolutions/:id/options/:optionId/hold
The hold step atomically claims the slot for a fixed TTL. During this window, no other booking can succeed against the same slot.
// Request
{
"ttlSeconds": 120
}
// Response
{
"holdId": "hold_9f3d1",
"expiresAt": "2026-08-04T14:25:01Z",
"status": "active"
}
The default and maximum TTL is 120 seconds — long enough for a user to review and confirm, short enough to keep availability accurate.
If the slot was taken between resolve and hold, the hold returns a 409:
// 409 Conflict
{
"error": "SLOT_UNAVAILABLE",
"message": "This slot was booked by another user. Request a new resolution to see updated availability.",
"resolutionId": "res_4k8m2p"
}
The correct handling is to re-run the resolution with the same parameters. Orita will return the next best available option.
If the hold expires (TTL elapses without a confirm), the slot becomes available again automatically. No cleanup is needed. If the user then tries to confirm, they'll receive an error and should re-run the resolution.
Step 3: Confirm
POST /api/v1/resolutions/:id/confirm
Confirmation re-validates the slot and atomically converts the hold into a confirmed booking. The Idempotency-Key header is required.
POST /api/v1/resolutions/res_4k8m2p/confirm
Idempotency-Key: idmp_4a8d2c1e-9b3f-4e17-b8a2-3c0f1d5e8b7a
Content-Type: application/json
// Request body
{
"client": {
"name": "Ana López",
"email": "ana@example.com",
"timezone": "America/New_York"
}
}
// Response
{
"bookingId": "bk_18382",
"status": "confirmed",
"providerId": "prov_9a21b",
"slot": { "start": "2026-08-06T15:00:00Z", "end": "2026-08-06T16:00:00Z" },
"confirmedAt": "2026-08-04T14:23:45Z"
}
Idempotency: The Same Key Returns the Same Response
The Idempotency-Key is not just a formality. It's how you safely retry confirmation requests without risk of duplicate bookings.
The rule: if you send the same Idempotency-Key to /confirm more than once, the second and subsequent requests return the same response as the first — without creating a new booking.
const idempotencyKey = `idmp_${crypto.randomUUID()}`;
// First call — creates booking
const result1 = await fetch(`/api/v1/resolutions/${resolutionId}/confirm`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ client }),
});
// Second call with same key — returns same booking, no duplicate
const result2 = await fetch(`/api/v1/resolutions/${resolutionId}/confirm`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ client }),
});
// result1.bookingId === result2.bookingId
Generate the idempotency key before the first attempt, store it, and reuse it on retries. Never generate a new key per attempt.
Complete JavaScript Implementation
import crypto from "crypto";
import { OritaClient } from "orita-sdk";
const orita = new OritaClient({ apiKey: process.env.ORITA_API_KEY });
async function bookAppointment(request) {
const { serviceId, constraints, preferences, client, windowStart, windowEnd } = request;
// Step 1: Resolve — find available options
const resolution = await orita.resolutions.create({
serviceId,
windowStart,
windowEnd,
constraints,
preferences,
});
if (resolution.options.length === 0) {
throw new Error("No providers available matching your criteria.");
}
const topOption = resolution.options[0];
// Step 2: Hold — claim the slot for 120 seconds
let hold;
try {
hold = await orita.resolutions.hold(resolution.resolutionId, topOption.optionId, {
ttlSeconds: 120,
});
} catch (err) {
if (err.code === "SLOT_UNAVAILABLE") {
// Slot was taken — retry with fresh resolution
console.warn("Slot taken during hold. Retrying with fresh resolution...");
return bookAppointment(request); // re-resolve
}
throw err;
}
// Step 3: Confirm — atomically commit the hold to a booking
const idempotencyKey = `idmp_${crypto.randomUUID()}`;
const booking = await orita.resolutions.confirm(resolution.resolutionId, {
client,
idempotencyKey,
});
return booking;
}
// Usage
const booking = await bookAppointment({
serviceId: "svc_therapy_60min",
windowStart: "2026-08-04T00:00:00Z",
windowEnd: "2026-08-08T23:59:59Z",
constraints: {
languageCodes: { anyOf: ["es"] },
modalityCodes: { anyOf: ["video"] },
acceptsNewClients: true,
},
preferences: {
dayParts: ["afternoon"],
},
client: {
name: "Ana López",
email: "ana@example.com",
timezone: "America/New_York",
},
});
console.log(booking);
// { bookingId: "bk_18382", status: "confirmed", ... }
Complete Python Implementation
import os
import uuid
from orita import OritaClient
orita = OritaClient(api_key=os.environ["ORITA_API_KEY"])
def book_appointment(
service_id,
window_start,
window_end,
constraints,
preferences,
client,
_depth=0,
):
if _depth > 3:
raise RuntimeError("Too many retries. No slots available.")
# Step 1: Resolve
resolution = orita.resolutions.create(
service_id=service_id,
window_start=window_start,
window_end=window_end,
constraints=constraints,
preferences=preferences,
)
if not resolution.options:
raise ValueError("No providers available matching your criteria.")
top_option = resolution.options[0]
# Step 2: Hold
try:
hold = orita.resolutions.hold(
resolution_id=resolution.resolution_id,
option_id=top_option.option_id,
ttl_seconds=120,
)
except orita.errors.SlotUnavailableError:
# Slot was taken between resolve and hold — retry
return book_appointment(
service_id, window_start, window_end,
constraints, preferences, client,
_depth=_depth + 1,
)
# Step 3: Confirm
idempotency_key = f"idmp_{uuid.uuid4()}"
booking = orita.resolutions.confirm(
resolution_id=resolution.resolution_id,
client=client,
idempotency_key=idempotency_key,
)
return booking
booking = book_appointment(
service_id="svc_therapy_60min",
window_start="2026-08-04T00:00:00Z",
window_end="2026-08-08T23:59:59Z",
constraints={
"languageCodes": {"anyOf": ["es"]},
"modalityCodes": {"anyOf": ["video"]},
"acceptsNewClients": True,
},
preferences={
"dayParts": ["afternoon"],
},
client={
"name": "Ana López",
"email": "ana@example.com",
"timezone": "America/New_York",
},
)
print(booking.booking_id) # bk_18382
print(booking.status) # confirmed
Edge Cases Reference
| Scenario | What Happens | Correct Response |
|---|---|---|
| Slot taken between resolve and hold | POST /hold returns 409 SLOT_UNAVAILABLE | Re-run resolution, offer next option |
| Hold expires before confirm | POST /confirm returns 410 HOLD_EXPIRED | Re-run full workflow from resolution |
| Network error during confirm | Retry with same Idempotency-Key | Safe — same key returns same booking |
| Duplicate confirm with same key | Returns original booking | Expected behavior, no duplicate created |
| Confirm after hold expires | Returns 410 HOLD_EXPIRED | Re-run resolution |
Why Not Just "Book Directly"?
Some scheduling APIs offer a single /bookings endpoint that checks availability and creates the booking in one call. The problem is that the slot check and the write are two separate operations underneath, and a concurrent request can slip between them.
Orita's hold is an atomic reservation at the database level. Once a hold is active, no other booking can succeed against the same slot — the lock is held until the hold expires or the booking is confirmed.
This matters at scale. An AI agent handling 10 concurrent users, a webhook consumer processing events in parallel, a load-balanced API with multiple instances — any concurrent system needs an atomic hold to guarantee correctness.
Summary
The resolve → hold → confirm pattern gives you:
- Correct availability data at resolve time
- Atomic slot reservation that prevents double bookings
- Safe retries via idempotency keys on confirmation
- Automatic cleanup if holds expire without confirmation
For production AI applications, this is the only workflow that's correct. The simpler "just book it" patterns work until they don't — and they fail loudly in front of users.
