bookings:view; holds and bookings bookings:create; reschedule and cancel
bookings:edit (cancel is a status change — the row stays). Design: scheduling-api-design.md.
Booking, end to end
Idempotency.
POST /v1/bookings, POST /v1/scheduling/reservations and
POST …/assignments each mutate a calendar, a hold, or a rotation cursor, so
they require Idempotency-Key (428 IDEMPOTENCY_KEY_REQUIRED without it).
Replaying the same key and body returns the original response with
Idempotent-Replay: true — a double-submit creates one booking. Cancel and
reschedule honour the header when sent.
What a booking carries. user_id (the rep), the slot, attendee,
meeting_title, meeting_url / calendar_event_link when the provider returned
them, provider, record_id (the CRM record you passed — it rides on the
webhook events), test_mode, and rescheduled_from.
Errors you should handle. 409 slot_conflict with details.alternatives
(the slot filled between search and confirm — offer one of those), 409 slot_reserved, 409 no_slot / no_eligible_rep (engine shape), 409 no_calendar (the rep has not connected a calendar — a setup problem, not a
retry), and 503 provider_error (the calendar provider refused; nothing was
booked, retry later).
Reach. A booking exists for a caller when its rep is within the caller’s
reach: team- or branch-scoped keys list, read, cancel and reschedule only their
reps’ bookings; company-scoped keys everything.
Test mode. An f9_test_ credential runs the whole engine but books against
a fake calendar: no real event, no OAuth call, provider: "test",
test_mode: true, no webhook events. Live credentials never see test bookings
and vice versa. Holds are real either way — a hold is not an external effect.
Webhooks. appointment.scheduled, appointment.rescheduled and
appointment.cancelled fire from the booking service for API bookings (before
this wave nothing produced them), with appointment_id, record_id and the
time. Subscribe with POST /v1/webhooks.
Windows and limits
from defaults to now, to to seven days later; a window may span at most
31 days (400 beyond). Id lists take up to 50 uuids, and every user id must be a
user of the workspace — a stranger’s id is 400 VALIDATION_ERROR with
details.unknown_user_ids, never a partial answer. The slot search is
additionally capped to the policy’s horizon.
The two pure reads (availability, slots) return
Cache-Control: private, max-age=30: the availability index is rebuilt on
calendar change and the app refreshes on the same cadence. The search and the
suggestion are never cached (tokens are minted per call).
Slots, offers and tokens
GET /v1/scheduling/slots is what you want to show a calendar. POST /v1/scheduling/slots/search is what you want when you intend to book: it
runs the pool’s policy — exposure mode individual picks one rep per slot (a
preview of the rotation; it does not advance), merged lists every eligible rep,
shared defers assignment to booking time — drops slots that another caller is
already holding, and signs each offer as a slot_token valid for the policy’s
reservation TTL. Issuing a token holds nothing; reserving and confirming are the
writes. When nothing fits the window the response has slots: [] and
alternatives with the nearest slots just outside it.
Errors: a pool that is not yours, deleted, or outside your reach is a 404; a
workspace with no default policy and no policy_id is 409 CONFLICT
(details.reason: no_policy).
Reach
A key whose owner is team- or branch-scoped lists only pools with at least one member in reach and reads only those; a company-scoped key (or a key with no owner) sees every pool. Availability and slot reads are not reach-filtered — they take explicit user ids that must be workspace users — because there is no record yet to scope; the pool list is where the boundary sits.Latency budget
Reservations are Postgres-backed holds (no external store). The engine is O(users × days): per user it loads the profile, working windows, blocking segments (synced calendar index + internal events + confirmed bookings) and the skill tags, then slices free ranges into slots. Budget on the current Redis-backed paths, unchanged by this wave:
Rate limiting is the standard per-key, per-endpoint minute/hour bucket, so a
busy search loop cannot starve a key’s other calls. A dedicated tighter category
for search is noted in the design and will land with if the budget above
is exceeded in practice.
Parity
Same engine as the app’s Distribute pages and the session endpoint (_shared/distribute/*; find_slots and confirm_slot were moved into shared
modules so both doors run one copy). The AI assistant’s tools find_slots,
book_meeting, reschedule_meeting and cancel_meeting call the same
services — a booking made by an agent is a row you can read, reschedule or cancel
here, and vice versa.