Two-phase commit
How Openavail prevents race conditions between concurrent agents.
Why explicit holds
Availability search is read-only. Agents can explore candidate times or prepare an approval flow without reserving capacity. A hold is created only when the agent is ready to reserve a candidate or short negotiation window.
Openavail's hold mechanism serialises commitment: once an agent holds time, that time is invisible to other agents' searches until the hold expires or is promoted. The calendar itself is not modified until confirm.
Phase 1 — Search
POST /v1/availability/search
→ 200 { requested_window, candidates[], candidate_limit, candidates_truncated }
Openavail:
- Checks the calendar for existing events and blocked time
- Checks existing holds and active bookings
- Applies the rule set (working hours, class priorities, buffers)
- Returns a capped set of meeting-sized candidates without creating a hold
Search responses default to 50 candidates. Agents can pass max_results up to 100; if
candidates_truncated is true, narrow the window or run another search.
Search does not return hold_id, expires_at, or expires_in_seconds. Those fields belong to hold creation.
Free slots and preemptable slots
The candidates array contains two kinds of entries:
- Free slots — the time range is open. No extra fields.
- Preemptable slots — the time range is occupied by a lower-priority booking that your meeting class will automatically displace at confirm time. These slots carry a
preemptableobject:
{
"start": "2026-06-01T14:00:00Z",
"end": "2026-06-01T14:45:00Z",
"preemptable": {
"occupying_class": "internal_sync",
"occupying_priority_tier": "normal"
}
}
Preemptable candidates only appear when your agent has the preempt permission and your meeting class outranks the occupying one.
If no slots are available the response is 409 NO_SLOTS_AVAILABLE. The body includes a reason_code field that tells your agent why:
reason_code | Meaning |
|---|---|
NO_FREE_SLOTS | Calendar is busy — try a different window |
DAILY_HOURS_LIMIT | Owner hit their daily meeting cap — try tomorrow |
OFF_DAY | Non-working day (e.g. Saturday) |
WORKING_HOURS | Working day but window is outside configured hours |
HARD_BLOCK | Window overlaps a recurring blocked period (e.g. lunch) |
Include next_available_lookahead_hours (integer, 1–72, default 72) to receive a next_available: { start, end } hint — the nearest free slot beyond your latest_end, returned for all reason codes above. If nothing is found within the lookahead period the field is omitted (never null). When slots do exist but fall just beyond the window, next_available_exceeds_lookahead: true is set instead — retry with a larger lookahead value. The 72-hour default is sized to bridge a full Friday-evening → Monday-morning gap in any timezone.
The response is 422 WINDOW_TOO_NARROW (not 409) when the window is shorter than the requested duration — the body includes window_duration_minutes and required_duration_minutes.
Phase 2 — Hold
POST /v1/holds
→ 200 { hold_id, hold_scope, held_window, expires_at, expires_in_seconds }
Two hold scopes are supported:
| Scope | Use it when | Confirm rule |
|---|---|---|
candidate | One exact candidate has been selected | start and end must match the held candidate |
window | A short active negotiation needs a stable option set | start and end may be any valid slot inside the held window |
Hold creation reserves Openavail capacity and does not call the calendar provider again by default. If you need fresh provider data, run another search before creating a hold.
Phase 3 — Confirm
POST /v1/bookings/{hold_id}/confirm
→ 200 { booking_id, correlation_id, start, end, title, attendees, displaced_count?, pending_notifications }
The confirm call:
- Validates the hold is still active and not expired
- Writes the event to Google Calendar (Microsoft 365 coming soon)
- Commits the booking atomically — if it displaced one or more lower-priority bookings,
displaced_countis included in the response and the calendar owner receives a preemption email with an Undo link; any agent notifications are returned inpending_notifications
The confirm body accepts the following fields:
| Field | Required | Type | Notes |
|---|---|---|---|
start | Yes | ISO 8601 datetime | Must fall within the hold window |
end | Yes | ISO 8601 datetime | Must fall within the hold window |
title | Yes | string | Becomes the calendar event title |
attendees | No | { email: string; displayName?: string }[] | Defaults to []. The confirmed attendees are echoed back in the 200 response — no follow-up GET needed to verify they were saved. |
The same title and attendees fields are accepted on POST /v1/bookings (the one-step direct booking endpoint), where meeting_class is also required.
Side-effects are guaranteed to execute at least once. If the calendar write fails, Openavail retries automatically.
For candidate holds, start and end must match the held candidate exactly.
What happens if you skip confirm
If you call POST /v1/holds and never confirm, the hold expires after ttl_seconds and the reserved capacity is released. No calendar write ever occurs. The expired hold is recorded in the audit log with action: hold_expired.
Your agent should always confirm within the TTL. If your processing takes longer than the TTL, run POST /v1/availability/search again and create a fresh hold.
Idempotency
Search, hold, and confirm accept an Idempotency-Key header. If the same key is sent twice within 24 hours, the second call returns the same response as the first without executing again. Use a deterministic key per attempt:
Idempotency-Key: {agent-id}:{job-id}:{attempt-number}