Skip to main content
Ask when people are free, then hold a slot and book it. Reads take 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.