PUT /api/v1/sales-availability
Replaces the complete sales availability configuration of the organization
bound to the API key, in one transaction. Anything you omit is gone: an empty
closers array, weekly array or exceptions array deliberately clears what
was there.
| Role in the organization | This endpoint |
|---|---|
OWNER | Allowed |
ADMIN | Allowed |
MEMBER | Refused — 403 |
| Requirement | Rule for this endpoint |
|---|---|
| Business role | Every closer in the rotation holds the organization-scoped closer responsibility |
| Resource scope | Organization attached to the API key |
| Participation | None |
| Resource state | None — the first call creates the configuration |
| Header | Required | Description |
|---|---|---|
x-api-key | yes | API key created for this organization |
Idempotency-Key | yes | Stable key for this replacement; a replay returns the first result |
X-Correlation-Id | no | Caller correlation id, returned or generated |
| Field | Type | Required | Nullable | Description |
|---|---|---|---|---|
timeZone | string | yes | no | IANA time zone, for example Europe/Paris |
durationMinutes | integer | yes | no | 30, 45, 60 or 90 |
bufferBeforeMinutes | integer | yes | no | 0, 5, 10, 15 or 30 |
bufferAfterMinutes | integer | yes | no | 0, 5, 10, 15 or 30 |
closers | array | yes | no | Up to 100 closers; each account at most once |
closers[].userId | UUID | yes | no | Account holding the organization closer responsibility |
closers[].position | integer | yes | no | Positive; only the order matters, positions are renumbered from 1 |
closers[].active | boolean | yes | no | false keeps the closer in the rotation without offering slots |
closers[].dailyCapacity | integer | yes | no | 1 to 100 calls per local day |
closers[].weekly | array | yes | no | One entry per weekday at most |
closers[].weekly[].weekday | integer | yes | no | 0 Sunday to 6 Saturday |
closers[].weekly[].windows | array | yes | no | Up to 8 ranges |
closers[].weekly[].windows[].startMinute | integer | yes | no | 0 to 1439, local minutes from midnight |
closers[].weekly[].windows[].endMinute | integer | yes | no | 1 to 1440, after startMinute |
closers[].exceptions | array | yes | no | Up to 100; one exception per date at most |
closers[].exceptions[].id | UUID | no | no | Existing exception id, when you keep one |
closers[].exceptions[].localDate | string | yes | no | Real calendar date, YYYY-MM-DD |
closers[].exceptions[].effect | string | yes | no | unavailable (no windows) or custom_hours (at least one window) |
closers[].exceptions[].windows | array | yes | no | Up to 8 ranges, same shape as weekly windows |
{
"timeZone": "Europe/Paris",
"durationMinutes": 45,
"bufferBeforeMinutes": 15,
"bufferAfterMinutes": 15,
"closers": [
{
"userId": "11111111-1111-4111-8111-111111111111",
"position": 1,
"active": true,
"dailyCapacity": 4,
"weekly": [
{ "weekday": 1, "windows": [{ "startMinute": 540, "endMinute": 720 }] }
],
"exceptions": [
{ "localDate": "2026-09-22", "effect": "unavailable", "windows": [] }
]
}
]
}curl -X PUT "$COHORTE_API_BASE_URL/api/v1/sales-availability" \
-H "x-api-key: $COHORTE_API_KEY" \
-H "Idempotency-Key: sales-availability-2026-09-15" \
-H "Content-Type: application/json" \
-d '{"timeZone":"Europe/Paris","durationMinutes":45,"bufferBeforeMinutes":15,"bufferAfterMinutes":15,"closers":[{"userId":"11111111-1111-4111-8111-111111111111","position":1,"active":true,"dailyCapacity":4,"weekly":[{"weekday":1,"windows":[{"startMinute":540,"endMinute":720}]}],"exceptions":[]}]}'200 OK — the configuration as stored. replayed: true when the key was already used.400 Bad Request — missing Idempotency-Key, invalid JSON, an unknown time zone, a value outside its allowed set or bounds, a closer listed twice, a weekday or exception date repeated for one closer, ranges that overlap once buffers are applied, an exception whose windows contradict its effect, or a closer without the organization closer responsibility; code is invalid_request.401 Unauthorized — missing, unknown or expired key; code is unauthorized.403 Forbidden — the bearer is not OWNER or ADMIN; code is forbidden. A closer or setter responsibility never grants it: closers read the configuration, only the school edits it.The stored configuration, with the same fields as Read sales availability, plus the envelope:
| Field | Type | Nullable | Description |
|---|---|---|---|
success | boolean | no | true for a successful request |
replayed | boolean | no | true when the idempotency key replayed a replacement |
data.version | integer | no | New configuration version |
data.rotationCursor | integer | no | Stable round-robin cursor, kept across replacements |
data.closers[] | array | no | Rotation as stored, renumbered from 1, with id, name and email |
correlationId | UUID | no | Correlation id for support |
{
"success": true,
"replayed": false,
"data": {
"organizationId": "5e0b7a61-0000-4000-8000-000000000000",
"timeZone": "Europe/Paris",
"durationMinutes": 45,
"bufferBeforeMinutes": 15,
"bufferAfterMinutes": 15,
"rotationCursor": 3,
"version": 8,
"closers": [
{
"id": "c3f1d8a2-0000-4000-8000-000000000000",
"userId": "11111111-1111-4111-8111-111111111111",
"name": "Sophie Laurent",
"email": "sophie@example.com",
"position": 1,
"active": true,
"dailyCapacity": 4,
"weekly": [
{ "weekday": 1, "windows": [{ "startMinute": 540, "endMinute": 720 }] }
],
"exceptions": []
}
]
},
"correlationId": "9d0f2c47-0000-4000-8000-000000000000"
}organizationId is not a field: the organization comes from the key, and a
value sent in the body changes nothing.Idempotency-Key returns the original state without a
second audit entry or event.sales_availability.replaced
domain event. It creates no booking.Keep one idempotency key for one normalized intention. Current authorization is checked again on retry. A different payload or resource with that key, or an older key whose intention cannot be verified, returns 409 conflict without another mutation. Inspect the original outcome before creating a new command. Identical retries do not resend notifications.
closers[].timeZone is an optional IANA string in the request and a required
string in the response. Invalid zones return 400. An explicit value changes
that closer’s schedule zone, preserving the submitted wall-clock minutes and
exception dates. Confirmed bookings retain their instants.
When omitted, an existing closer keeps their saved zone. A newly configured
closer inherits their prepared draft zone, or the school zone if no draft
exists. Changing the school timeZone never resets existing personal zones.
An idempotent replay returns its historical configuration, including the
historical school zone for older records that predate personal zones.
Daily capacity and exceptions use each closer’s local calendar. Available slots are the sorted union of UTC instants from those calendars. Nonexistent spring DST times are skipped; repeated fall times use the later occurrence. Durations and buffers remain elapsed time and must fit within availability.
bookingHorizonDays is a nullable integer from 1 to 365. It limits the start of
new public bookings (page and widget) to strictly before the current instant
plus that many 24-hour days. Booking links inherit this school setting.
A null value means no horizon limit. When replacing availability, omitting the
field preserves the current limit; sending null clears it.
Existing bookings, rescheduling, operator-created bookings and internal previews
are unchanged. Historical responses without a configured limit return null.
Each closer availability accepts optional nullable bookingHorizonDays (integer
1–365). Omission preserves the stored value; null inherits the team limit.
Personal availability reads also expose teamBookingHorizonDays. The effective
public booking limit is the minimum of configured team and closer limits.
Candidates are evaluated independently: a restricted closer does not hide slots
offered by another closer. Both null means unlimited. Existing appointments,
rescheduling and operator bookings are unchanged. Prepared availability keeps
this setting when adopted by an administrator. Existing rights and idempotency apply.