> ## Documentation Index
> Fetch the complete documentation index at: https://flow9.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Scheduling over the API

> Ask when people are free, then hold a slot and book it. Reads take bookings:view; holds and bookings bookings:create; reschedule and cancel

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.

| Call                                                        | What it answers                                                                                                |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `GET /v1/scheduling/availability?user_ids=&from=&to=`       | Per user: bookable at all? free and busy ranges, and why (working hours, time off, holidays, calendar health). |
| `GET /v1/scheduling/slots?member_ids=&policy_id=&from=&to=` | Bookable slots for named reps under a policy — plain, no tokens.                                               |
| `POST /v1/scheduling/slots/search`                          | The offer flow for a **pool**: slots with signed `slot_token`s you can later reserve and book.                 |
| `GET /v1/scheduling/pools`, `GET /v1/scheduling/pools/{id}` | The pools (teams) the caller may route into, with member ids.                                                  |
| `GET /v1/scheduling/pools/{id}/suggestion?policy_id=&at=`   | Who the policy would pick right now — a peek, the rotation does not move.                                      |

## Booking, end to end

```
POST /v1/scheduling/slots/search        { pool_id }                 → slots[] with slot_token
POST /v1/scheduling/reservations        { slot_token }              → reservation (a hold, TTL)   Idempotency-Key
POST /v1/bookings                       { slot_token, reservation_id, attendee, meeting, record_id } → booking   Idempotency-Key
GET  /v1/bookings/{id}
POST /v1/bookings/{id}/reschedule       { from, to }
POST /v1/bookings/{id}/cancel
```

| Call                                                                | What it does                                                                                                                                                                                                                                           |
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST /v1/scheduling/reservations`                                  | Hold the slot behind a `slot_token` for the policy's TTL (or a shorter `ttl_seconds`). Held slots are not re-offered. `409 slot_reserved` if someone else holds it.                                                                                    |
| `GET` / `POST …/extend` / `DELETE /v1/scheduling/reservations/{id}` | Read, extend (within the hard cap), release. Expired, released or foreign = `404`.                                                                                                                                                                     |
| `POST /v1/scheduling/pools/{id}/assignments`                        | The write twin of the suggestion: pick a rep **and advance the rotation**. For systems that create the meeting themselves.                                                                                                                             |
| `POST /v1/bookings`                                                 | Book. **Two shapes:** `slot_token` (confirm an offered slot; the rep's calendar is re-read live, the event created, the hold consumed) or `member_ids` (the engine picks the rep and the earliest slot under the policy). A body with both is refused. |
| `GET /v1/bookings`                                                  | Most recent start first, cursor on `(start_at, id)`, filters `user_id`, `status`, `from`, `to`.                                                                                                                                                        |
| `POST /v1/bookings/{id}/cancel`                                     | Removes the event (best effort), marks the row `cancelled`. The row stays; there is no hard delete. Idempotent.                                                                                                                                        |
| `POST /v1/bookings/{id}/reschedule`                                 | Same rep, new window; the old row becomes `rescheduled`, the new one links `rescheduled_from`. Returns the **new** booking.                                                                                                                            |

**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:

| Call          | Typical                                 | Worst case at the limits (50 users × 31 days)                             |
| ------------- | --------------------------------------- | ------------------------------------------------------------------------- |
| availability  | \< 300 ms for 5 users / 7 days          | \~2 s                                                                     |
| slots, search | \< 500 ms for a 10-member pool / 7 days | \~3 s (search also does one Redis round trip for the reservation overlay) |
| suggestion    | \< 200 ms                               | \~1 s                                                                     |

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.
