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.

POST /v1/availability/search
→ 200 { requested_window, candidates[], candidate_limit, candidates_truncated }

Openavail:

  1. Checks the calendar for existing events and blocked time
  2. Checks existing holds and active bookings
  3. Applies the rule set (working hours, class priorities, buffers)
  4. 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 preemptable object:
{
  "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_codeMeaning
NO_FREE_SLOTSCalendar is busy — try a different window
DAILY_HOURS_LIMITOwner hit their daily meeting cap — try tomorrow
OFF_DAYNon-working day (e.g. Saturday)
WORKING_HOURSWorking day but window is outside configured hours
HARD_BLOCKWindow 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:

ScopeUse it whenConfirm rule
candidateOne exact candidate has been selectedstart and end must match the held candidate
windowA short active negotiation needs a stable option setstart 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:

  1. Validates the hold is still active and not expired
  2. Writes the event to Google Calendar (Microsoft 365 coming soon)
  3. Commits the booking atomically — if it displaced one or more lower-priority bookings, displaced_count is included in the response and the calendar owner receives a preemption email with an Undo link; any agent notifications are returned in pending_notifications

The confirm body accepts the following fields:

FieldRequiredTypeNotes
startYesISO 8601 datetimeMust fall within the hold window
endYesISO 8601 datetimeMust fall within the hold window
titleYesstringBecomes the calendar event title
attendeesNo{ 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}