# Ophelia API — Agent Integration Guide

Base URL: `https://api.opheliaos.com`
API Version: v1 (v3 beta: Part II, §32–37)
Format: REST + JSON
Authentication: Bearer API key
Docs: https://www.opheliaos.com/docs
API keys: create an account at https://dashboardopheliaos.com

This document is the complete implementation contract for Ophelia's v1 API, with the v3 beta summarized in Part II. An AI coding agent should be able to build a correct integration from this file alone, without scraping the website or guessing provider behavior.

---

## 1. Overview

Ophelia is one REST API to search and book across dining, fitness, entertainment, and travel. Every vertical uses the same request shape: on search, the `vertical` field (plus `providers` where a vertical has more than one) selects the provider; availability and bookings then take the `venue_id` that search returned.

Supported verticals and providers:

| Vertical | Provider(s) |
|---|---|
| dining | OpenTable, Resy (a dining search without `providers` searches OpenTable only) |
| fitness | Mindbody |
| entertainment | StubHub |
| travel | Hotels (`hotels`); Flights (`flights-com`, flights.com via Expedia — the only flights provider live) |

Conceptual model — every integration follows this shape:

```text
User intent
    │
    ▼
Venue / event search  ──────────────  POST /v1/venues/search
    │
    ▼
Select a venue_id from the results
    │
    ▼
Live availability  ──────────────────  POST /v1/availability/search
    │
    ▼
Select a slot; use its availability_id and datetime
    │
    ▼
Create booking  ──────────────────────  POST /v1/bookings
    │
    ▼
Inspect booking status
    │
    ├── requires_action? ─ no ──► terminal state (confirmed / failed / cancelled / expired)
    │
    └── requires_action? ─ yes
              │
              ▼
        inspect next_action.type
              │
              ▼
        collect the required user input
              │
              ▼
        POST /v1/bookings/{id}/continue
              │
              ▼
        repeat — a booking may pass through requires_action more than once
```

Flights bends this shape: the search result is the route, availability returns itineraries booked by `itinerary_key`, and `POST /v1/bookings` parks at the pay screen (202) until `/continue` with `confirm: true` (§20).

`requires_action` is a **normal** part of a successful booking, not an error. Never treat it as a failure, and never assume a single `/continue` call finishes it — inspect `next_action.type` again after every `/continue` response.

To exercise the whole flow without a real venue, account or card, use the practice venues in "Ophelia Test City" (§26).

---

## 2. Base URL, Authentication, Key Handling

```
Base URL: https://api.opheliaos.com
```

All `/v1` endpoints require your API key as a Bearer token:

```http
Authorization: Bearer YOUR_LIVE_KEY
Content-Type: application/json
```

Keys are created on the dashboard's API Keys page (an account is required). The prefix says what a key is for:

| Prefix | What it can do |
|---|---|
| `oph_live_` | Real bookings on `/v1`. Needs billing set up on your organization; until then every search, availability and booking call returns `402 billing_required`. |
| `oph_test_` | The same `/v1` calls, against real venues, with a free allowance of 3 **confirmed bookings** per organization (searches and unconfirmed attempts never use it). After that, search, availability and booking calls return `402 free_tier_exhausted`. Practice bookings (§26) do not use the allowance. Test keys cannot connect sessions (§24). |
| `oph_beta_` | The v3 beta only (Part II). On `/v1` it gets `403 wrong_key_environment`. |

- A request with a missing or invalid key returns `401 { "error": "unauthorized" }`.
- A key without the scope a route needs gets `403 { "error": "insufficient_scope", "required_scope": "..." }`.
- If API access for your organization is suspended, calls return `403` with `error_code: "org_suspended"`. Retrying will not help; contact support.

> **Outside Ophelia Test City, every booking is real.** A booking from a live or test key drives the real OpenTable / Resy / Mindbody / StubHub / hotel / flight site and creates a real reservation or purchase. Consequences for your first calls:
> - Test the flow end to end on the practice venues first (§26): search with `"location": "Ophelia Test City"`. They are never billed and walk the same steps as the real provider.
> - Against a real venue, use a real person (real name, a phone they hold, their email) and, where a card is needed, a real card. Placeholder data is refused up front (`400 placeholder_customer`, §8).
> - A reservation you did not mean to keep must be released with `POST /v1/bookings/{id}/cancel` (§22) where the provider supports it — otherwise it is on the venue's book until the guest cancels with the venue.

- **Never** put an API key in client-side/browser code — it must live server-side only.
- **Never** log a full API key, and never log card numbers, CVV, passwords, or OTP codes in application logs.

---

## 3. Endpoint Reference

| Method | Path | Description |
|---|---|---|
| GET | `/v1/` | Index: the API name, version, server time, and the partner endpoints |
| POST | `/v1/venues/search` | Search venues/events/routes for a vertical |
| POST | `/v1/availability/search` | Get live bookable slots for a specific venue |
| POST | `/v1/bookings` | Create a booking. Send `end_user` to book signed in as a person with a connected session (§24) |
| GET | `/v1/bookings/{booking_id}` | Read a booking and its current status |
| POST | `/v1/bookings/{booking_id}/continue` | Submit the action required by `next_action` |
| POST | `/v1/bookings/{booking_id}/modify` | Move a confirmed booking's datetime, party size, or seating preference (OpenTable only) |
| POST | `/v1/bookings/{booking_id}/cancel` | Cancel a booking (a confirmed booking is cancelled at the venue on OpenTable only) |
| POST | `/v1/sessions` | Connect a person's own Resy account; texts them a code (§24) |
| POST | `/v1/sessions/{id}/verify` | Send the code; the session becomes active |
| GET | `/v1/sessions` | List sessions; filter with `?end_user=`, `&provider=`, `&state=` |
| GET | `/v1/sessions/{id}` | Read one session |
| DELETE | `/v1/sessions/{id}` | Disconnect a session |
| GET | `/v1/sessions/{id}/card` | Does the person's Resy account have a card on file? |
| GET | `/v1/sessions/card` | The same card check, by `?end_user=` (`&provider=`, default `resy`) |

Only these partner endpoints exist in v1. Do not invent additional paths (e.g. there is no `/v1/venues/{id}`, no `PATCH` verb, no bulk endpoints). Webhooks are pushed to you (§25); there is no endpoint to register one per booking.

---

## 4. Canonical Integration Flow

1. **Search** — `POST /v1/venues/search` → get a `venues[]` array, each with a `ven_` id.
2. **Select a venue** — pick one `venue_id` from the results. Never invent one; always use an id the API returned.
3. **Check availability** — `POST /v1/availability/search` with that `venue_id` → get `slots[]`.
4. **Select a slot** — pick one, and take its `availability_id` and `datetime` verbatim.
5. **Create the booking** — `POST /v1/bookings` with the slot's `availability_id` and `datetime`, an `Idempotency-Key` header, and vertical-specific fields (customer, and card/listing where required).
6. **Inspect `status`** — if `requires_action`, branch on `next_action.type`, collect the corresponding input, and `POST /v1/bookings/{id}/continue` before `next_action.expires_at`. Repeat until the status is terminal.
7. **Report success only when `status: "confirmed"`.**

---

## 5. Datetime Semantics

This is the single, unambiguous rule for every `datetime` field Ophelia accepts as **input** (venue search, availability search, booking creation, modify):

> `datetime` is interpreted as the venue's local wall-clock time. If you include a timezone suffix (an offset, or `Z`), it is converted to the venue's local time automatically. If you omit a suffix (`2026-11-14T19:00:00`), the value is treated as already being the venue's local time.

This applies identically to `POST /v1/venues/search`, `POST /v1/availability/search`, and `POST /v1/bookings`. There is no separate "UTC mode" for any provider. On search and availability, a date that has already passed is moved to today at the same time of day. On a dining booking, a `datetime` in the past is refused (`validation failed: datetime: must be in the future`).

Fields **returned** by the availability endpoint use three distinct, non-interchangeable fields on each time slot:

```text
slot.datetime       → the venue-local instant, with its correct local UTC offset attached
slot.datetime_utc   → the exact same instant, expressed in UTC
slot.display_time   → a human-readable local rendering (e.g. "7:30 PM") — for display only
```

**Always pass the slot's `datetime` field (not `display_time`, and not a value you reconstruct yourself) into `POST /v1/bookings`.** `display_time` is a formatted string for showing to a human; it is not a valid API input and does not round-trip reliably.

---

## 6. Venue Search and the Venue Object

```http
POST /v1/venues/search
```

**Required:** `vertical`, `term`, `location`, `datetime` (RFC3339; §5), plus `party_size` for entertainment (the ticket quantity). Hotels take `check_in`/`check_out` and `rooms` instead of `datetime` (§19); flights take a route instead of `term`/`location` (§20). `providers` narrows the search to named providers (`["opentable"]`, `["resy"]`, `["hotels"]`, `["flights-com"]`).

The response has a `venues` array and a `count`. When some providers answered and others failed, the response is still `200` and lists the failures in `providers_failed`; when every provider failed it is `502 search_failed`, which is safe to retry. A location that cannot be geocoded returns `400 could not resolve location "…"`; send a more specific one.

### Stable fields (present across all providers)

| Field | Description |
|---|---|
| `id` (`ven_...`) | Stable identifier; use it as `venue_id` to call availability and to create bookings. Never construct or guess one. A `venue_id` is only known after a search returned it; an unknown one gets `404 venue not found; run venues/search first`. |
| `image` | Top-level representative photo URL from the provider, when available. Omitted if the provider returned none. |

### Provider-specific metadata

Everything else useful about a venue lives under `metadata`, and its shape **varies by provider** — do not assume it is normalized:

- `metadata.photo_url` — duplicates the top-level `image` value.
- Entertainment (StubHub) events: `metadata.sections`, an array of ticket listings, each with `listing_id`, `section`, `row`, `seats`, `price`, `quantity`.
- Hotels: `metadata.image`, `metadata.price_nightly`, `metadata.price_total`, `metadata.checkin`, `metadata.checkout`.
- Flights: `metadata.resolved` says which airports a place name meant (§20).

Do not assume other metadata keys exist across providers; only rely on fields confirmed for the provider/vertical you're integrating.

---

## 7. Availability

```http
POST /v1/availability/search
```

**Required:** `venue_id` (the `ven_` id from search).
**Recommended:** `party_size` (defaults to 2) and `datetime` (RFC3339 or local without an offset — see §5; defaults to 24 hours from now).
**Optional:** `window_minutes` (width of the returned time window, centered on `datetime`; no effect on OpenTable today), `check_card_required` (when `true`, the venue's card policy is checked and reported as `card_required`; OpenTable only checks when asked, and may briefly hold a table to learn it, so send it only when you need the answer; Resy reports it whenever it is known).

Request:

```bash
curl -X POST https://api.opheliaos.com/v1/availability/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "venue_id": "ven_1a2b3c4d",
    "party_size": 2,
    "datetime": "2026-11-14T19:00:00-08:00",
    "check_card_required": true
  }'
```

Response:

```json
{
  "venue_id": "ven_1a2b3c4d",
  "slots": [
    {
      "availability_id": "avail_0772690bf37353a0",
      "kind": "time",
      "venue_id": "ven_1a2b3c4d",
      "datetime": "2026-11-14T17:30:00-08:00",
      "datetime_utc": "2026-11-15T01:30:00Z",
      "display_time": "5:30 PM",
      "party_size": 2,
      "status": "available",
      "price_required": false,
      "deposit_required": false
    }
  ],
  "card_required": false
}
```

- `kind` says what distinguishes the options: `time` for a reservation time, `room` for a hotel room (no `datetime`; §19), `itinerary` for a flight (§20).
- To book a slot, send its `availability_id` **and** its `datetime` to `POST /v1/bookings`. The booking is held to the time the slot was offered at: a different `datetime` is refused with `400 slot_not_offered`. An `availability_id` is good for 15 minutes; after that it is ignored and the booking runs on `venue_id` and `datetime` alone.
- Availability is live and can change before you book — treat it as a snapshot, not a hold.
- `card_required` is omitted when the policy is unknown. When it is `true`, the booking needs a card: include `metadata.payment` (§13), or pay through AgentCard (§13).
- A provider failure while reading availability is `502 availability_failed` (`provider` names it); retry.

---

## 8. Booking Creation

```http
POST /v1/bookings
```

Common fields across all verticals:

| Field | Required | Notes |
|---|---|---|
| `venue_id` | yes | From search. |
| `availability_id` | recommended | The slot's id from availability (§7). Required for hotels. |
| `datetime` | yes | The slot's returned `datetime` (§5, §7). Dining: must be in the future. |
| `party_size` | vertical-dependent | Required for dining (1–20) and entertainment (ticket quantity). Omit for fitness. |
| `customer.name` | yes | The end user's **real first and last name in one string** (`"Maria Garcia"`). OpenTable refuses a one-word name with `400 missing_last_name`. See the callout below. |
| `customer.phone_number` | yes | A **real mobile the end user is holding right now**, E.164 with country code (`"+12125551234"`). Dining providers text the verification code to this number. |
| `customer.email` | yes | The end user's real email. Mindbody and StubHub may send their verification code here; every provider sends confirmations here. |
| `metadata` | vertical-dependent | Card (`metadata.payment`), Mindbody password, StubHub listing, hotel postal code, flight choices — see §13–20. |
| `payment_collection` | no | `"agentcard_external"` to pay with the end user's AgentCard vault (§13). Spell it exactly: an unrecognized value is not rejected, it is treated as a booking with no card and fails at the venue's card step. |
| `end_user` | no | Your stable id for the person. With an active session for the venue's provider, the booking runs signed in as them (§24). |
| `vertical` | recommended | Include it explicitly rather than relying on inference from `venue_id`. |

Treat every field shown in a vertical's canonical example (§15–20) as required for that vertical — omitting one causes the booking to fail or behave unexpectedly.

`webhook_url` on the request is accepted and stored, but deliveries do not use it: webhooks always go to your organization's URL (§25).

**The call is held.** Create (and `/continue`) is held server-side for up to 150 seconds until the booking reaches its next step (`requires_action` or a final status) and returns it inline with `200`. If it is still `processing` when the hold ends you get `202`; poll `GET /v1/bookings/{id}`. Set your HTTP client's timeout above 150 seconds, or the booking keeps running after your client has given up. (Flights calls take longer; §20.)

### Checked on every booking

These are refused with a `400` before anything is created, so a mistake costs nothing:

| Rule | Refusal |
|---|---|
| `venue_id` from a search | `provider required: set the 'provider' field or use a venue_id with a known provider mapping` |
| A `customer` with at least a name, email or phone | `customer name or contact required` |
| Dining: `customer.name`, `party_size` 1–20, and a `datetime` in the future | `validation failed: …` (one message per field) |
| OpenTable: a first **and** last name in `customer.name` | `missing_last_name` |
| The real guest's details, not placeholders: no emails at domains reserved for documentation or testing (RFC 2606: the `example` domains, `.test`, `.invalid`), no North American numbers in a 555 exchange (like `+1 212 555 0123`), no names like "Customer Name" or "Test User" | `placeholder_customer` (the field is named in the message) |
| With an `availability_id`: the `datetime` it was offered at | `slot_not_offered` |

### The `customer` object is the real person, not a placeholder

Ophelia does not have its own reservation system. It fills the provider's actual reservation form with the `customer` you send, and the provider then verifies that person by SMS or email before it commits anything. The most common cause of failed integrations is sending data that cannot pass that verification:

| You send | What happens |
|---|---|
| `"name": "Maria"` (one word) | OpenTable: refused with `400 missing_last_name`. Elsewhere the last name is empty and the provider form may reject the submit. |
| `"name": "Customer Name"`, `"Test User"`, an email at a reserved documentation domain, a 555-exchange number | Refused with `400 placeholder_customer` before anything runs. |
| A made-up but plausible name, or your company name | Goes on the reservation as-is (and, on Resy, onto the account created for that phone). Produces a reservation nobody can claim at the door. |
| Someone else's phone, a landline, or your own number | The code goes to that number. The end user never sees it, the window lapses, nothing is booked. |
| `"phone_number": "2125551234"` (no country code) | May be misread by the provider form. Always send E.164: `"+12125551234"`. |
| A throwaway email | Verification codes and every provider's confirmation go there. |

Send exactly what the end user would type themselves on the provider's site.

---

## 9. Retries and the Idempotency-Key

```http
Idempotency-Key: <unique-key>
```

Send a unique `Idempotency-Key` header (e.g. a UUID you generate) on every `POST /v1/bookings` request.

- The key belongs to your organization and **does not expire**.
- **Same key again** → returns the booking that key first created, in its current state, and starts nothing new. The request body is **not compared**: a changed body under an old key still gets the old booking.
- **New key** → a brand new booking attempt. Use a new key for each booking you intend to make.
- A request refused for a missing or invalid field (§8) created no booking, so fixing it and retrying under the same key is safe.
- **After an uncertain outcome** (timeout, connection drop, 5xx with no body), the original request may have created a booking. Retry with the **same** key — you get that booking back — rather than a new one, which risks a duplicate.
- **Flights do not honour it.** Every `POST /v1/bookings` carrying `metadata.itinerary_key` drives a new checkout, whatever key it sends (§20).

Example:

```bash
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: 6f1c2b0e-8d4a-4f6e-9b3a-2c7d5e1f0a94" \
  -H "Content-Type: application/json" \
  -d '{ "venue_id": "ven_12345678", "availability_id": "avail_0772690bf37353a0", "datetime": "2026-11-14T19:30:00-05:00", "party_size": 2, "customer": { "name": "Jane Smith", "phone_number": "+15555555555", "email": "jane.smith.diner@gmail.com" } }'
```

---

## 10. Booking Lifecycle

| Status | Meaning |
|---|---|
| `processing` | Work is underway. Create and `/continue` are held for up to 150 seconds and return the next step inline with `200`, so you rarely observe this state; if the hold ends first you get `202` with this status. Poll `GET /v1/bookings/{id}`. |
| `requires_action` | An end-user action is needed; see `next_action`. Normal, not an error. Submit it to `/continue` before `next_action.expires_at`. |
| `confirmed` | Booking is complete. `confirmation.confirmation_code` carries the provider's code; `provider_booking_id` and `confirmed_at` are set. |
| `failed` | The provider could not complete the booking. `error_code` (stable, safe to branch on) and `error_message` say why (§27). |
| `cancelled` | Cancelled via `/cancel`. A confirmed booking is cancelled at the venue only on OpenTable (§22). |
| `expired` | A booking left in `requires_action` for more than 20 minutes becomes `expired`, with `error_code: "action_expired"`. Nothing was booked. |

A booking also carries `id` (`bkg_…`), `object: "booking"`, `vertical`, `venue_id`, `provider`, `datetime`, `party_size`, `customer`, `metadata` (card fields removed), `created_at` and `updated_at`, plus `end_user` and `session` when it ran signed in as a person (§24) and `last_modification` once it has been modified.

```json
{
  "id": "bkg_12345678",
  "object": "booking",
  "status": "confirmed",
  "vertical": "fitness",
  "venue_id": "ven_87654321",
  "provider": "mindbody",
  "provider_booking_id": "mb_9f21c8a0",
  "confirmation": {
    "confirmation_code": "MB-9F21C8A0",
    "message": "You're booked. Bring a mat — the studio doesn't rent them."
  },
  "confirmed_at": "2026-11-14T15:02:41Z"
}
```

**Never tell the user a booking is confirmed unless the API response reports `status: "confirmed"`.** `processing` and `requires_action` are not confirmations.

---

## 11. The `requires_action` Pattern

### How the verification code (OTP) actually flows

1. The booking returns `status: "requires_action"`, `next_action.type: "otp"`, `next_action.requirements` (the exact fields `/continue` expects), and `next_action.expires_at` — currently 5 minutes from the pause. Read it; never hard-code it.
2. By then the **provider** (not Ophelia) has already sent the code: by SMS to `customer.phone_number` for dining; usually by email to `customer.email` for Mindbody; to the buyer's phone or email for StubHub. `next_action.message` says where it went. Ophelia never sees, receives, or forwards the code — it only holds the provider's session open.
3. Your application must prompt the **end user** for the code they just received and submit it with `POST /v1/bookings/{id}/continue` and `{ "otp_code": "123456" }` (§12).
4. Inspect the new response's `status`. `confirmed` is done; `requires_action` means another step (StubHub may ask for `payment` next; AgentCard asks for `payment_authorization`).
5. A rejected code ends in `failed` (for example `not_confirmed_after_otp`). There is no resend endpoint — create a new booking to trigger a fresh code. A booking nobody answers becomes `expired` (§10).

Practical consequence: **a booking cannot complete without the end user (or their phone/inbox) in the loop before `next_action.expires_at`.** An unattended integration needs its own way to relay the code to `/continue` in that window. The exception is a Resy booking for an `end_user` with an active session (§24): no code is texted.

```json
{
  "id": "bkg_12345678",
  "object": "booking",
  "status": "requires_action",
  "vertical": "fitness",
  "venue_id": "ven_87654321",
  "provider": "mindbody",
  "next_action": {
    "type": "otp",
    "message": "Mindbody sent a 6-digit verification code to jane.smith.diner@gmail.com. ...",
    "requirements": [
      { "field": "otp_code", "label": "Verification Code", "type": "string", "required": true }
    ],
    "expires_at": "2026-11-14T15:05:00Z"
  }
}
```

`next_action` fields:

| Field | Meaning |
|---|---|
| `type` | What is needed: `otp`, `payment` (card details: StubHub, hotels), `confirm` (hotels: the press that places and charges), `payment_authorization` (AgentCard, §13), or another provider challenge. Branch on this. |
| `message` | Human-readable prompt, suitable to show the end user as-is. |
| `expires_at` | When the session lapses. For `otp` it is currently 5 minutes after the pause; for `payment_authorization` it is 15 minutes. Read it; never assume. |
| `requirements` | The exact fields `/continue` expects for this action, each with `field`, `label`, `type`, `required`. **Build the `/continue` body from this list** — prompt the end user with `label`, send the value under `field`. Do not hardcode the body from memory. |

Decision-tree pseudocode for handling any booking response:

```text
if status == "confirmed":
    done — report success

if status == "requires_action":
    inspect next_action.type
    collect the corresponding input from the user
    POST /v1/bookings/{id}/continue with that input, before next_action.expires_at
    re-inspect the new response's status — do NOT assume it is now "confirmed"

if status == "processing":
    poll GET /v1/bookings/{id} until it changes

if status == "failed":
    stop; surface error_code / error_message

if status == "cancelled":
    stop

if status == "expired":
    stop; the action window elapsed — a new booking attempt is required
```

**Never assume a single `/continue` call completes a booking.** StubHub can stop twice (code, then card) and hotels twice (card, then confirm) — always branch on the returned `next_action.type` rather than hardcoding a step count.

---

## 12. `/continue`

```http
POST /v1/bookings/{booking_id}/continue
```

Action types and their request bodies:

**`otp`** — dining, Mindbody, StubHub, and modify re-verification:

```json
{ "otp_code": "123456" }
```

**`payment`, StubHub** — the card is a **top-level** `payment` object, not nested under `metadata`:

```json
{
  "payment": {
    "card_number": "4111111111111111",
    "exp_month": "12",
    "exp_year": "2027",
    "cvv": "123",
    "name_on_card": "Jane Smith",
    "address_line1": "123 Market St",
    "city": "San Francisco",
    "state": "CA",
    "postal_code": "94103",
    "country": "US"
  }
}
```

**`payment`, hotels** — flat card fields; this fills the card in and returns `next_action.type: "confirm"` (§19):

```json
{ "card_number": "4111111111111111", "exp_month": "12", "exp_year": "2027", "cvv": "123", "postal_code": "10014", "name_on_card": "Jane Smith" }
```

**`confirm`, hotels** — places the reservation and charges the card. `"confirm": true` can also ride on the card body to do both at once:

```json
{ "confirm": true }
```

**`payment_authorization`** — AgentCard only. Body is `{"psp_response": {...}}` or `{"psp_abort": true}`. See §13.

**Flights** — a nested `card` plus `confirm` on the `fchk_` id (§20).

Always branch on the response's `next_action.type` — never hardcode an assumption about which action type comes next, or that `otp` is the only type that can appear.

**`/continue` is not an error if nothing is pending.** Calling it on a booking whose `status` is not `requires_action` (already `confirmed`, `failed`, `expired`, or still `processing`) returns `200` with the booking **unchanged** — the body you sent is discarded, and no error is raised. Never infer from a `200` that the code or card was accepted: read `status` and `next_action` on the response. If you are unsure whether an earlier `/continue` landed, `GET /v1/bookings/{id}` first (§28).

---

## 13. Cards and AgentCard

Cards are sent per vertical, as raw card fields in the places below, or — on Resy and OpenTable — authorized through the end user's AgentCard vault instead (`payment_collection: "agentcard_external"`).

### Card fields by vertical

| Vertical | Fields | Sent where | Charged? |
|---|---|---|---|
| Dining (OpenTable) | `card_number`, `exp_month`, `exp_year`, `cvv`, `name_on_card`, `postal_code` | `metadata.payment` on `POST /v1/bookings`, unless availability answered `card_required: false`. Without it, a venue that needs a card or whose policy is unknown fails with `missing_card` | No — entered only if the venue's form asks, held against no-shows |
| Dining (Resy) | Same as OpenTable | `metadata.payment`, only at a venue whose availability answered `card_required: true`; otherwise the booking fails with `card_required` at Resy's card form | Held by the venue |
| Fitness (Mindbody) | Full object incl. `address_line1`, `city`, `state`, `postal_code`, `country` | `metadata.payment` on `POST /v1/bookings`. Missing `state` or `country` fails with `missing_billing_fields` | Yes, up front |
| Entertainment (StubHub) | Full object incl. billing address | Top-level `payment` on the `/continue` that answers `next_action.type: "payment"`, or up front in `metadata.payment` | Yes |
| Travel (Hotels) | Flat `card_number`, `exp_month`, `exp_year`, `cvv`, `postal_code`, `name_on_card` | `/continue` at the `payment` step, or up front in `metadata.payment` | Yes, the full total, when the reservation is placed (`confirm`) |
| Travel (Flights) | Nested `card` { `number`, `expirationDate` "MM/YY", `cvc`, `name` } | `/continue` on the `fchk_` id | Only with `confirm: true`. Test cards for now (§20) |

- The card is typed into the provider's real checkout and authorized there. `4111111111111111` in these examples is a placeholder; a real provider declines it (`card_declined`). To try a flow without a card, use the practice venues (§26).
- All values are **strings**. `card_number`: digits only. `exp_month`: `"01"`–`"12"`. `exp_year`: four digits, `"2027"`. `postal_code`: the card's billing ZIP — the provider's address verification checks it, so a mismatch is a decline.
- Card fields are removed from the stored booking, from logs, and from every API response.

### AgentCard (`agentcard_external`)

The end user pays with a card from their AgentCard vault and approves each payment with their passkey. The card number and CVV never reach you or Ophelia. Guide: https://www.opheliaos.com/docs/payments/agentcard

- v1 only, on every v1 key (live or test). Supported on Resy (the card is saved on file to hold the table; nothing is charged) and OpenTable (the venue holds the card at seatings that require one).
- When a venue cannot be paid through AgentCard, create returns HTTP 409 `vault_unavailable` and nothing is created; offer the end user a different venue or time. `503 agentcard_disabled` means AgentCard payments are off; contact support.
- You need: an AgentCard organization token (client credentials; AgentCard API keys are rejected), and an AgentCard user with a stored card, connected to your AgentCard organization.
- Send `"payment_collection": "agentcard_external"` on `POST /v1/bookings`, and no card fields. Optional billing in `metadata`: `billing_postal_code`, `billing_address_line1`, `billing_city`, `billing_state`, `billing_country` — the processor's address check sees these, so send the end user's real ones (without them it sees postal code 94103 and country US). `end_user` is optional.
- `customer.phone_number` must be the end user's real mobile: the venue texts a code there (`next_action.type: "otp"`, 5-minute window). Resy asks for the code before payment; OpenTable asks for payment first. Branch on `next_action.type`. If a slot needs no card the booking may confirm without ever pausing for payment.
- `POST /v1/bookings` and `/continue` wait up to 150 seconds: `202` while processing, `200` otherwise. A 200 is not confirmation. Read `status`.
- At the card step the booking returns `status: "requires_action"`, `next_action.type: "payment_authorization"`, with `expires_at`, `agentcard_request`, and `requirements`. `agentcard_request` keys: `merchant`, `checkout_origin` (when known), `psp`, `mode`, `request.url`, `request.method`, `request.headers`, `request.body`, and `amount`, `currency` only when set (never on a Resy card hold).
- Copy every key unchanged, add `user` (your AgentCard user id), and POST it to AgentCard `POST https://api.agentcard.sh/api/v2/checkout/authorizations` with your organization token. Send the end user the returned `approvalUrl`.
- Read AgentCard `GET /api/v2/checkout/authorizations/{id}` until `approved`. `awaiting_approval`, `submitted_on_device`: keep waiting. `declined`, `expired`, `cancelled`: abandon.
- On `approved`, send the authorization's `response` object unchanged — never the whole authorization:

```json
{ "psp_response": { "status": 200, "headers": { "content-type": "application/json" }, "body": "{...}" } }
```

- To abandon: `{"psp_abort": true, "decline_code": "user_declined"}`. `decline_code` is an optional reason for your own records; it is not returned on the booking. The booking fails with `error_code: "payment_declined"`. Cancelling the Ophelia booking does not cancel the AgentCard authorization; cancel it on AgentCard.
- Deadline: `next_action.expires_at`, 15 minutes after the pause. Binding. AgentCard's own 15-minute window starts later, when you create the authorization; do not rely on it. Answer at least 30 seconds before `expires_at`. When it passes, stop polling, cancel the AgentCard authorization, and tell the end user to start again.
- Any HTTP 400 on `/continue` carries only an `error` message, so read `GET /v1/bookings/{id}` to tell the cases apart. A malformed `psp_response` leaves the booking paused (fix the body and retry before `expires_at`). An answer after `expires_at` fails the booking: `payment_outcome_unknown` after a late `psp_response` (the card may have reached the venue; check before booking again), `payment_approval_expired` after a late `psp_abort`.
- The `booking.requires_action` webhook (§25) carries the full `next_action`, including `agentcard_request`. The webhook's booking is a summary without the confirmation code; read `GET /v1/bookings/{id}` after `booking.confirmed`.
- There is no sandbox for AgentCard payments: every booking is a real reservation, on test keys too, and AgentCard sandbox cards cannot pay a live venue. An end-to-end test needs AgentCard production credentials and a real card. The whole loop takes about six minutes.

---

## 14. Provider / Vertical Matrix

| Vertical | Provider | Account requirement | Card | Confirmation | Special data |
|---|---|---|---|---|---|
| dining | OpenTable | none (guest) | `metadata.payment` unless availability says `card_required: false`; held only if the venue's form asks; never charged | SMS OTP | First and last name required; modify and cancel supported |
| dining | Resy | No Resy password. Guest: Resy creates an account for `customer.phone_number` if none exists; if the phone already has one, `customer.email` must be that account's email | At some venues (`card_required: true`): `metadata.payment` or AgentCard | SMS OTP, unless the `end_user` has an active session (§24) | `"providers": ["resy"]` on search; confirmed bookings cannot be cancelled through Ophelia |
| fitness | Mindbody | A new email gets a new account; an existing one signs in with an emailed code, or `metadata.password` if you have it | Required, charged up front; `state` and `country` required | Code, usually by email, when Mindbody asks | Omit `party_size`; confirmed classes cannot be cancelled through Ophelia |
| entertainment | StubHub | Guest checkout | Required, charged (at the `payment` step or up front in `metadata.payment`) | Code to the buyer's phone or email, then payment | `listing_id` from `metadata.sections`; purchased tickets cannot be cancelled through Ophelia |
| travel | Hotels | Guest | Required; charged the full total when the reservation is placed (`confirm`) | None (no OTP) | `check_in`/`check_out`, `rooms`; `availability_id`; `metadata.postal_code` (§19) |
| travel | Flights (`flights-com`) | Guest | Charged only by `/continue` with `confirm: true`; test cards for now (3-D Secure unhandled) | None (no OTP) | `travellers[]` with gender and date of birth; `metadata.itinerary_key`, `insurance`, `max_total` (§20) |

---

## 15. Dining — OpenTable

Card unless the venue is known card-free · OTP required.

```bash
# 1. Search. A dining search without "providers" searches OpenTable only;
#    naming it keeps the request explicit.
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "dining",
    "providers": ["opentable"],
    "term": "sushi",
    "location": "New York, NY",
    "datetime": "2026-11-14T19:15:00-05:00",
    "party_size": 2
  }'

# 2. (Recommended) Availability, with the card policy.
curl -X POST https://api.opheliaos.com/v1/availability/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "venue_id": "ven_12345678", "party_size": 2, "datetime": "2026-11-14T19:15:00-05:00", "check_card_required": true }'
# → slots[] (kind "time"), card_required: true | false | absent (unknown)

# 3. Book. Send metadata.payment unless availability answered card_required: false.
#    It is only entered if the venue's form asks, and Ophelia never charges it.
#    customer.name needs a first and last name.
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: dining-001" \
  -H "Content-Type: application/json" \
  -d '{
    "venue_id": "ven_12345678",
    "availability_id": "avail_8d6a0b078c6aefe7",
    "datetime": "2026-11-14T19:15:00-05:00",
    "party_size": 2,
    "customer": {
      "name": "Jane Smith",
      "phone_number": "+15555555555",
      "email": "jane.smith.diner@gmail.com"
    },
    "metadata": {
      "payment": {
        "card_number": "4111111111111111",
        "exp_month": "12",
        "exp_year": "2027",
        "cvv": "123",
        "name_on_card": "Jane Smith",
        "postal_code": "10014"
      }
    }
  }'
# → status: "requires_action", next_action.type "otp"

# 4. Submit the OTP the diner received
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp_code": "123456" }'
# → status: "confirmed"
```

- **When a card is needed:** `card_required: false` means you can book without `metadata.payment`; `true` means send it; absent means the policy is unknown and a card is still required. A booking with no card at a venue that needs one, or whose policy is unknown, fails with `error_code: "missing_card"`. To have the card authorized through AgentCard instead, see §13.
- **First and last name:** a one-word `customer.name` is refused up front with `400 missing_last_name`, before any code is texted.
- **Availability:** OpenTable returns the slots it offers around the requested `datetime`; `window_minutes` has no effect for OpenTable today.
- **Modify and cancel:** OpenTable is the only provider that supports both (§21, §22).
- **Quirk:** if you're testing against a real OpenTable account, the `customer` fields you submit (email, phone number) must match the info tied to that account, or the booking fails.

---

## 16. Dining — Resy

Card at some venues · no Resy password · OTP unless the `end_user` has an active session.

Search Resy by passing `"providers": ["resy"]`; a dining search without `providers` searches OpenTable only. No Resy password is ever needed: Resy texts a one-time code to `customer.phone_number`, and the booking returns `requires_action` with `next_action.type: "otp"`. If that phone already has a Resy account, `customer.email` must match the account's email, or the booking fails with `account_email_mismatch`. If it has none, a Resy account is created during the booking. `party_size` is required (1–20).

Some Resy venues need a card. Check `card_required` on the availability response: `false` means no card is needed; `true` means send `metadata.payment` (the same shape as OpenTable) or have the card authorized through AgentCard (§13). A booking that reaches Resy's card form without a card fails with `error_code: "card_required"`. `window_minutes` on availability keeps only slots within that many minutes of the requested `datetime`.

```bash
# 1. Search (Resy)
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "dining",
    "providers": ["resy"],
    "term": "sushi",
    "location": "New York, NY",
    "datetime": "2026-11-14T21:15:00-05:00",
    "party_size": 2
  }'

# 2. Book. No Resy password: Resy texts the diner a code. Add metadata.payment only
#    at a venue whose availability answered card_required: true.
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: dining-002" \
  -H "Content-Type: application/json" \
  -d '{
    "venue_id": "ven_12345678",
    "datetime": "2026-11-14T21:15:00-05:00",
    "party_size": 2,
    "end_user": "u_42",
    "customer": {
      "name": "Jane Smith",
      "phone_number": "+15555555555",
      "email": "jane.smith.diner@gmail.com"
    }
  }'
# → status: "requires_action", next_action.type "otp" (the diner gets an SMS code)

# 3. Submit the OTP
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp_code": "123456" }'
# → status: "confirmed"
```

- **End-user sessions:** send `end_user`, your stable id for the diner. Once a booking signs the diner in with the code, Ophelia keeps that Resy sign-in for them, and their later Resy bookings with the same `end_user` are made signed in as them, with no code. If Resy stops accepting the session, it is marked expired and the next booking goes through the code again. To connect a diner ahead of a booking, list or disconnect sessions, or check whether their Resy account has a card on file, see §24.
- **Cancelling or modifying:** a confirmed Resy booking cannot be cancelled or modified through Ophelia. `/cancel` leaves it `confirmed` with `error_code: "cancellation_unsupported"`, and the diner cancels with Resy directly. A booking still waiting on its code can be cancelled.
- **Quirk:** if you're testing against a real Resy account, the `customer` fields you submit (email, phone number) must match the info tied to that account.

---

## 17. Fitness — Mindbody

```bash
# 1. Search — omit party_size for fitness.
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "fitness",
    "term": "yoga",
    "location": "San Francisco, CA",
    "datetime": "2026-11-14T15:00:00-08:00"
  }'

# 2. Book — requires metadata.payment (card taken up front), including the billing
#    state and country. metadata.password is optional: it signs an existing member in;
#    without it Mindbody sends the member a code (usually by email).
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: fitness-001" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "fitness",
    "venue_id": "ven_87654321",
    "datetime": "2026-11-14T15:00:00-08:00",
    "customer": {
      "name": "Jane Smith",
      "phone_number": "+15555555555",
      "email": "jane.smith.diner@gmail.com"
    },
    "metadata": {
      "password": "MemberPass123!",
      "payment": {
        "card_number": "4111111111111111",
        "exp_month": "12",
        "exp_year": "2027",
        "cvv": "123",
        "name_on_card": "Jane Smith",
        "address_line1": "123 Market St",
        "city": "San Francisco",
        "state": "CA",
        "postal_code": "94103",
        "country": "US"
      }
    }
  }'
# → status: "requires_action", next_action.type "otp" when Mindbody asks for a code

# 3. Submit the code the member received
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp_code": "123456" }'
# → status: "confirmed"
```

**Signing in.** Mindbody identifies a member by email. A new email gets a new Mindbody account. An email that already has an account signs in with a code Mindbody emails to that address: the booking returns `requires_action` with an `otp` next action, and you submit the code as `otp_code` to `/continue`. If you know the account's password you can send it in `metadata.password` instead; it is optional.

- An account that signs in with Google or Apple is switched to email sign-in so the emailed code can be used; the link to Google or Apple is removed once the code is entered. If Mindbody does not offer that switch, the booking fails with `mindbody_social_login`. If an existing account is not offered a code sign-in at all, it fails with `account_exists`.
- Missing `payment.state` or `payment.country` fails with `missing_billing_fields`.
- Confirmed classes cannot be cancelled through Ophelia.

---

## 18. Entertainment — StubHub

`party_size` is the ticket quantity, not a person count for a reservation. Search returns ticket listings in `metadata.sections`, each with `listing_id`, `section`, `row`, `seats`, `price`, `quantity`. Pick one and pass its `listing_id` in `metadata`; that targets the exact ticket, so no price cap is needed. `customer.email` is used for delivery.

```text
search → select listing_id → create booking → code (/continue) → payment (/continue) → confirmed
```

```bash
# 1. Search — party_size is the ticket quantity; results only include
#    listings that can fulfill it.
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "entertainment",
    "term": "The Weeknd",
    "location": "New York, NY",
    "datetime": "2026-11-14T19:30:00-05:00",
    "party_size": 2
  }'
# → venues[].metadata.sections = [
#     { "listing_id": "L123", "section": "104", "row": "12",
#       "seats": "Seats 7-8", "price": "$145.00", "quantity": 4 }, ... ]

# 2. Book the chosen listing.
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: events-001" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "entertainment",
    "venue_id": "ven_11223344",
    "datetime": "2026-11-14T19:30:00-05:00",
    "party_size": 2,
    "customer": {
      "name": "Jane Smith",
      "email": "jane.smith.diner@gmail.com",
      "phone_number": "+15555555555"
    },
    "metadata": { "listing_id": "L123", "section": "104", "row": "12" }
  }'
# → status: "requires_action", next_action.type "otp" (StubHub sent a verification code)

# 3. Submit the code
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_11223344/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp_code": "123456" }'
# → status: "requires_action", next_action.type "payment"

# 4. Submit the card — top-level `payment`, not under metadata.
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_11223344/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payment": {
      "card_number": "4111111111111111",
      "exp_month": "12",
      "exp_year": "2027",
      "cvv": "123",
      "name_on_card": "Jane Smith",
      "address_line1": "123 Market St",
      "city": "San Francisco",
      "state": "CA",
      "postal_code": "94103",
      "country": "US"
    }
  }'
# → status: "confirmed"
```

- The checkout can stop for two actions, each returned as `requires_action`: a verification code sent to the buyer's phone or email (`otp`), and card details (`payment`). Send the card up front in `metadata.payment` and the card step is answered from it; a re-prompt for a corrected card still comes back to you.
- Purchased tickets cannot be cancelled through Ophelia.

---

## 19. Travel — Hotels

No OTP · nothing is placed until you confirm · the confirm is the charge.

Travel is `"vertical": "travel"` with two providers that behave very differently: Hotels (this section) and Flights (§20). Name the provider in `providers`.

```bash
# 1. Search — check_in/check_out replace dining's single datetime; rooms is hotels-only.
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "travel",
    "providers": ["hotels"],
    "term": "downtown Chicago",
    "location": "Chicago, IL",
    "check_in": "2026-12-10",
    "check_out": "2026-12-12",
    "party_size": 2,
    "rooms": 1
  }'
# → venues[] with id, name, rating, metadata.image, metadata.price_nightly, metadata.price_total

# 2. Availability — room/rate combinations for the stay.
curl -X POST https://api.opheliaos.com/v1/availability/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "venue_id": "ven_44556677",
    "check_in": "2026-12-10",
    "check_out": "2026-12-12",
    "party_size": 2,
    "rooms": 1
  }'
# → slots[]: availability_id, kind "room", status, offer { label, price, amount, currency,
#   cancellation, card_required, rate_plan_id, room_id }. No datetime on a room slot.

# 3. Book against the availability_id. Without a card this call places nothing:
#    it stops at the card form. datetime must be on the check_in day.
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: travel-001" \
  -H "Content-Type: application/json" \
  -d '{
    "availability_id": "avail_9c2f7b1a4e6d3058",
    "venue_id": "ven_44556677",
    "party_size": 2,
    "datetime": "2026-12-10T15:00:00Z",
    "customer": { "name": "Jane Smith", "phone_number": "+15555555555", "email": "jane.smith.diner@gmail.com" },
    "metadata": { "postal_code": "10014" }
  }'
# → status: "requires_action", next_action.type "payment"

# 4. Submit the card. This fills it in but does not place the reservation yet.
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_44556677/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "card_number": "4111111111111111",
    "exp_month": "12",
    "exp_year": "2027",
    "cvv": "123",
    "postal_code": "10014",
    "name_on_card": "Jane Smith"
  }'
# → status: "requires_action", next_action.type "confirm"

# 5. Confirm. THIS REQUEST IS THE CHARGE — it places the reservation and charges the card.
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_44556677/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "confirm": true }'
# → status: "confirmed", provider_booking_id, confirmation.confirmation_code
```

- `party_size` is total guests across all rooms (default 2); `rooms` is how many rooms to book (default 1). If `check_out` is missing, or not after `check_in`, one night is priced.
- Book with the slot's `availability_id`. `venue_id` is optional; without it send `provider: "hotels"`. `datetime` is the arrival time on the `check_in` day; another day is refused with `slot_not_offered`.
- Shortcuts: adding `"confirm": true` to the card body places the reservation in that call; a card sent up front in `metadata.payment` on `POST /v1/bookings` places and charges it in that call, with no payment or confirm round — unless `metadata.confirm` is `false`, which fills the card and stops at the confirm round.
- The card at `/continue` is flat: `card_number`, `exp_month`, `exp_year`, `cvv`, `postal_code`, `name_on_card` (defaults to `customer.name`). There is no `otp_code` for hotels.
- The `postal_code` sent with the card should match `metadata.postal_code` from the booking; a mismatch can fail the issuer's address check and decline the charge.
- Each `requires_action` carries `next_action.expires_at`, five minutes out. The checkout is held open for at most ten minutes; after that `/continue` fails with `error_code: "session_gone"` and the booking has to be started again.
- Show the end user the offer's `label`, `price` and `cancellation` before booking. There is no modify for hotels, and `/cancel` cannot cancel a confirmed hotel reservation: it stays `confirmed` with `error_code: "cancellation_unsupported"`. A booking still waiting at the card or confirm step can be cancelled; nothing has been placed yet.

Full reference: https://www.opheliaos.com/docs/travel/hotels

---

## 20. Travel — Flights

No OTP · `POST /v1/bookings` buys nothing (202, parked at the pay screen) · the purchase is `/continue` with `confirm: true` · no modify, cancel, or refund · test cards only.

> **Card handling is not at parity with Hotels. Use test cards for now.** There is no AgentCard vault on this path: a card sent to `/continue` travels in the request body, through the API. 3-D Secure appeared on every checkout measured and is **not handled**, so a card can be charged with no confirmation coming back.

The route is the venue. Search resolves a route into a `venue_id` that remembers the route, day, cabin and party. Availability returns itineraries, and you book with the itinerary's stable `itinerary_key`, not `availability_id`. `"flights-com"` (flights.com, via Expedia) is the only flights provider live; `["flights"]` is not registered and answers `400 none of the requested providers are registered`.

```bash
# 1. The route, as a venue. Name the provider.
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "travel",
    "providers": ["flights-com"],
    "origin": "New York",
    "destination": "Orlando",
    "date": "2026-11-20",
    "party_size": 1,
    "cabin": "ECONOMY"
  }'
# → venues[0].id "ven_ff91fdf3"; venues[0].metadata.resolved says which airports were used
#   ("New York" = the metro area: EWR, JFK, LGA)

# 2. Itineraries — the venue_id alone is enough.
curl -X POST https://api.opheliaos.com/v1/availability/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "venue_id": "ven_ff91fdf3" }'
# → slots[] (kind "itinerary"): itinerary.itinerary_key (stable), .price (the trip),
#   .price_per_traveller, .carriers, .summary

# 3. Optional re-quote: is that flight still offered at the price you showed?
#    Answered ONLY on the route form (a `flight` block instead of venue_id);
#    with venue_id, quoted_price is ignored.
curl -X POST https://api.opheliaos.com/v1/availability/search \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "flight": { "provider": "flights-com", "origin": "New York", "destination": "Orlando", "date": "2026-11-20", "adults": 1 },
    "itinerary_key": "1_F91659.EWR20261120",
    "quoted_price": 74.98
  }'
# → requested: { itinerary_key, still_offered, price, quoted_price, summary }
#   (price_changed and difference only when the price moved)

# 4. Drive the checkout and park in front of the pay control. BUYS NOTHING. 202.
#    Send an Idempotency-Key: a retry with the same key and body gets this checkout back.
curl -X POST "https://api.opheliaos.com/v1/bookings" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Idempotency-Key: flight-001" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "travel",
    "venue_id": "ven_ff91fdf3",
    "party_size": 1,
    "customer": { "name": "Jane Smith", "email": "jane.smith.traveller@gmail.com", "phone_number": "+15555555555" },
    "travellers": [
      { "first_name": "Jane", "last_name": "Smith", "gender": "female",
        "birth_day": "30", "birth_month": "08", "birth_year": "1990" }
    ],
    "metadata": {
      "itinerary_key": "1_F91659.EWR20261120",
      "insurance": "decline",
      "max_total": 80.00,
      "seat": "cheapest",
      "seat_max_price": 20,
      "hold": true
    }
  }'
# → 202 { "object": "flight_checkout", "id": "fchk_…", "status": "requires_action",
#         "session_held", "session_expires_at", "checkout_url", "checkout_total",
#         "quoted_price", "price_drift", "filled", "insurance", "three_ds" }

# 5. The purchase. The card and the press are ONE request. Card shape is nested.
curl -X POST "https://api.opheliaos.com/v1/bookings/fchk_9c2f7b1a4e/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "card": { "number": "4111111111111111", "expirationDate": "12/28", "cvc": "123", "name": "Jane Smith" },
    "confirm": true
  }'
# → 200 "status": "succeeded" with booking_reference and booking_id: the site confirmed.
# → 202 with pressed: true, outcome_known: false: pressed, and the site did not answer.
# Any non-2xx after confirm: true is an unknown outcome too. Do NOT retry.
```

**Search.** The fields sit at the request root: `origin`, `destination` (a city means the whole metro area, an airport code means that airport), `date` (or `datetime`, whose day is used), `return_date` (a round trip), `cabin` (`ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS`, `FIRST`; empty means economy), `party_size` (total travelling; absent means one adult), `children` (under 18; adults = `party_size − children`), and `direct_only`. There is no `term` or `location`. `trip_type` is optional because the shape implies it; a value that contradicts the shape is refused. For multi-city, send a `flight` object with `flight.flights[]` (each `{origin, destination, date}`) instead of the root fields, never both; inside the block the party is `adults`. When nothing flies the route that day, the answer is `200` with no venues and a `message`.

**Availability.** A saved route needs only `venue_id`. Send `datetime` or `party_size` only to change them; a multi-city venue still requires `datetime` (`400 datetime_required`). `availability_id` is regenerated on every search, while `itinerary_key` is stable. On an itinerary read from the site's own response, `price` is the trip total (`basis: "trip"`) and `price_per_traveller` is one person's fare; don't read `price_per_traveller` as the total. Adding `itinerary_key` (and `fare`, 0 = cheapest) opens that flight and puts its seat map on its slot (`slots[].seat_map`).

**Booking** (`POST /v1/bookings`, returns 202 and creates nothing):
- `venue_id` and `metadata.itinerary_key` are required. Route, day, cabin and children come from the venue; `party_size`, `cabin` and `children` at the root override it. To change the route or the day, search again.
- `travellers[]`: one per seat, each with `first_name`, `last_name`, `gender`, `birth_day`, `birth_month`, `birth_year` and `phone`; the first traveller also needs `email`. `customer.email` and `customer.phone_number` fill traveller one only, when they're absent there. An incomplete traveller is refused before a browser opens. Placeholder travellers are refused like customers (`400 placeholder_customer`).
- `metadata.insurance`: `"accept"` or `"decline"`. **Omitting it is a purchase**: the runner accepts the protection plan (measured: a $74.98 fare became $93.98, $19.00 per traveller). Always send it.
- `metadata.max_total`: **the only cap on the purchase**, checked against the site's own total immediately before the press. On this call, absent and `0` both mean no limit. Over the cap, the checkout parks with `reason: "over_max_total"`, `payable_total` and `max_total`, nothing is pressed, and it stays answerable. A cap given and a total that cannot be read refuses (`total_unreadable`) rather than pressing blind.
- `metadata.seat`: `"free"`, `"cheapest"`, or a seat id. A paid seat also needs `metadata.seat_max_price` (whole dollars); without it any seat that costs money is refused. Absent means the airline assigns one.
- `metadata.billing`: all of `street`, `city`, `state`, `zipcode`, or omit it entirely. Omitted, the runner fills a test address, which a real card fails at address verification. `filled` lists field names, not values, so it doesn't prove your address was used.
- `metadata.hold: true` keeps the checkout answerable at `/continue` for 10 minutes, but the flights provider runs at most two browsers at once, so a hold blocks capacity for every other flights call (searches included) until it's answered or expires. Without it, `session_held` is `false`, there is no `id`, and the checkout cannot be continued.
- `metadata.fare` picks the fare from the itinerary's fare list, as on availability (`0`, the default, is the cheapest); a number or a numeric string. Every park carries `reason`: `payment`, `confirm` or `over_max_total`. A repeat of the same POST with the same `Idempotency-Key` returns the first answer (header `Idempotent-Replayed: true`) instead of driving a second checkout; without a key every POST drives a new one. `metadata.card` may be sent here to fill the card at this step. `confirm` is not honoured on this call.
- `quoted_price` on the checkout is one traveller's fare and `price_drift` is the checkout total minus it; compare `checkout_total` against the trip `price` from availability instead.

**Purchase** (`POST /v1/bookings/{fchk_id}/continue`):
- `card`: `{ number, expirationDate ("MM/YY"), cvc, name }`. Required when the checkout parked for payment; answering that park without a card ends it with `no_card_supplied`. Any of number, expiry or CVC left out is filled from a test card.
- `confirm: true` presses the pay control and buys the ticket. At the payment park, `false` or absent fills the card and parks again, which is how you check `checkout_total` before committing. **Once the card is in, any answer without `confirm: true` ends the checkout** (`not_confirmed`) and it cannot be resumed.
- `max_total` raises or sets the cap for this answer; that's how an over-the-cap park is cleared. Here `0` is a $0 ceiling.
- **If `pressed: true` and `outcome_known: false`, stop.** The button was pressed and the site did not answer. A second press can buy a second ticket. Check `checkout_url` or the airline's records before any further call on that checkout. Any non-2xx after `confirm: true` (or a dropped connection) is an unknown outcome too. A 3-D Secure challenge after the press is answered as `202` with `reason: "verify_outcome"`, `pressed: true`, `outcome_known: false` and `bank_challenge: "3ds"`. The booking record then reads `requires_action` with `error_code: "outcome_unknown"` until someone has checked.
- A confirmed answer is `200` with `status: "succeeded"`, `booking_reference`, and `booking_id` (`bkg_fchk_…`), a booking record readable at `GET /v1/bookings/{id}`.

**Errors.** API checks answer with `error_code` (`placeholder_customer`, `venue_not_usable`, `itinerary_key_required`, `travellers_required`, `datetime_required`: 400; `not_confirmed`: 502). Browser-drive failures answer with `code` (not `error_code`), `retryable`, and sometimes `hint` and `problems`: `blocked`, `timeout`, `no_output` (503, retryable); `missing_traveller(s)`, `itinerary_gone`, `no_card_supplied`, `total_unreadable`, `payment_declined`, `ask_not_honoured` and other drive failures (502, not retryable unchanged).

**Timing** (measured against production): search 27–38 s, availability 35–43 s, booking 81–87 s, `/continue` 37.5 s (once 187 s, ending in an unknown outcome). Set your client timeout above 300 seconds; a serverless default kills the drive and leaves a checkout open with nothing on your side holding its id.

**No modify, cancel, or refund through Ophelia.** `/cancel` on a `bkg_fchk_…` record returns it unchanged with `error_code: "cancellation_unsupported"` and sends no webhook; `/modify` is refused with `modify_unsupported`. A ticket bought with `confirm: true` is changed or refunded only through the airline or agent that issued it, under its fare rules. Treat the press as final.

Full reference: https://www.opheliaos.com/docs/travel/flights

---

## 21. Modify

```http
POST /v1/bookings/{booking_id}/modify
```

Moves a `confirmed` booking. **OpenTable only**; other providers answer `422 modify_unsupported` and nothing is attempted. A booking that is not confirmed is `409`. Fields are independent — send only what you're changing:

- `datetime` (string, optional) — new date/time (§5 semantics apply).
- `party_size` (integer, optional) — new party size.
- `seating_preference` (string, optional, e.g. `"bar"`).
- `payment` (object, optional) — a card, the same shape as `metadata.payment`, if the new time needs one.

```bash
# Change date/time only
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/modify" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "datetime": "2026-12-01T19:15:00" }'

# Change party size only
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/modify" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "party_size": 4 }'

# Change datetime, party_size, and seating_preference together
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/modify" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "datetime": "2026-12-01T19:45:00",
    "party_size": 4,
    "seating_preference": "bar"
  }'
```

The booking stays confirmed at the original time until OpenTable accepts the change. If the venue refuses, the answer is `422` with the provider's code and the unchanged booking, still confirmed at its original time; `last_modification` records the outcome. A successful modify sends a `booking.modified` webhook; a refused one sends none.

**OTP after a modify:** OpenTable may require re-verification after certain modifications — the booking returns `requires_action` with `next_action.type: "otp"`. There is no separate endpoint for this; reuse `/continue`:

```bash
curl -X POST "https://api.opheliaos.com/v1/bookings/bkg_12345678/continue" \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp_code": "123456" }'
```

---

## 22. Cancel

```http
POST /v1/bookings/{booking_id}/cancel
```

Takes no body. Cancels a booking that has not finished (for example one still waiting on its code or card). A `confirmed` booking is cancelled at the venue **only on OpenTable** today; on other providers the call returns `200` with the booking still `confirmed` and `error_code: "cancellation_unsupported"`, and the guest must cancel with the venue directly. If the venue refuses, the booking also stays `confirmed` and `error_code` says why. Cancelling a booking that is already `cancelled`, `failed` or `expired` changes nothing.

Never call `/cancel` on a flight booking (§20). Don't promise refunds or cancellation windows; the venue's own policy applies. After calling `/cancel`, read `status` on the response (or `GET /v1/bookings/{id}`): only `cancelled` means it was cancelled.

---

## 23. Retrieve Booking

```http
GET /v1/bookings/{booking_id}
```

Use this to:
- Poll while a booking is `processing`.
- Recover the true state of a booking after any network uncertainty (timeout, dropped connection) before deciding whether to retry or create a new one.
- Verify a `/cancel` or `/modify` call actually landed.
- Read the full booking (including `confirmation.confirmation_code`) after a webhook, which carries only a summary.

An unknown id returns `404 booking not found`; the body lists your unfinished bookings from the last day in `open_bookings`.

---

## 24. Sessions — a person's own Resy sign-in

Connect a person's own Resy account once. Every Resy booking you make for them afterwards is made signed in as them, in their name, with no code. Full reference: https://www.opheliaos.com/docs/sessions

- Sessions need a **live** API key: connecting texts a real phone. A test key gets `403 live_key_required`. Resy is the only provider today.
- `end_user` is *your* stable id for the person: the same string you send as `end_user` on `POST /v1/bookings`. It is scoped to your organization.

**1. Connect.**

```bash
curl -X POST https://api.opheliaos.com/v1/sessions \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "end_user": "u_42",
    "provider": "resy",
    "phone": "+15555555555",
    "email": "jane.smith.diner@gmail.com",
    "name": "Jane Smith"
  }'
```

| Field | Notes |
|---|---|
| `end_user` | Required. Your id for the person. |
| `provider` | Optional, defaults to `resy` (the only provider today). |
| `phone` | Required. E.164 or a 10-digit US number. The code is texted here. |
| `email` | Required. The email on the person's Resy account. Resy asks for it after the code; Ophelia answers it for them. |
| `name` | Optional. Kept on the session as `owner.name`. |

Resy texts the person a 6-digit code. The call takes about 15–20 seconds and returns `202`:

```json
{
  "id": "sess_21ee94ef92fcafcf9e63597b",
  "object": "session",
  "provider": "resy",
  "owner": { "type": "end_user", "end_user": "u_42", "name": "Jane Smith" },
  "state": "awaiting_code",
  "next_step": {
    "type": "verify_code",
    "sent_to": "•••5555",
    "expires_at": "2026-11-14T18:39:19Z",
    "url": "/v1/sessions/sess_21ee94ef92fcafcf9e63597b/verify"
  }
}
```

If Resy signs the person in without a code, the call returns `200` with the session already `active` and no `next_step`.

**2. Verify.** Ask the person for the code and send it before `next_step.expires_at` (5 minutes):

```bash
curl -X POST https://api.opheliaos.com/v1/sessions/sess_21ee94ef92fcafcf9e63597b/verify \
  -H "Authorization: Bearer YOUR_LIVE_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "671737" }'
# → state "active", account.name, connected_at, last_verified_at, expires_at
```

If Resy sends a second code instead of accepting the first, the call returns `202 another_code_sent`; verify again with the new code.

**3. Book.** Send `end_user` on `POST /v1/bookings`. When that person has an active session for the venue's provider, the booking uses it, no code is texted, and the booking carries `"session": "sess_…"`. If Resy no longer accepts the session, nothing is booked and the booking fails with `error_code: "reconnect_required"`. Connect again, or retry the booking: it goes through Resy's sign-in and keeps the new session.

**4. Check for a card on file.** Some Resy venues need a card on the person's account. Ask before you try:

```bash
curl https://api.opheliaos.com/v1/sessions/sess_21ee94ef92fcafcf9e63597b/card \
  -H "Authorization: Bearer YOUR_LIVE_KEY"

# or by your own id for the person (provider defaults to resy)
curl "https://api.opheliaos.com/v1/sessions/card?end_user=u_42" \
  -H "Authorization: Bearer YOUR_LIVE_KEY"
```

```json
{
  "object": "card_check",
  "session": "sess_21ee94ef92fcafcf9e63597b",
  "provider": "resy",
  "owner": { "type": "end_user", "end_user": "u_42", "name": "Jane Smith" },
  "account": { "name": "Jane Smith" },
  "has_card": true,
  "checked_at": "2026-11-14T18:40:02Z"
}
```

Ophelia signs in with the person's session and reads their payment methods; nothing is changed. The answer is yes or no: no card details are returned or kept. A check takes about 15–30 seconds, so run it when it matters rather than on every screen. If Resy no longer accepts the session, the answer is `409 reconnect_required` (never `has_card: false`) and the session becomes `expired`. No session for that `end_user` is `404 session_not_found`.

**Whose account.** `owner` is who the session is for: your `end_user` and the `name` you sent. `account.name` is the name on the person's own Resy account, read when they connect and on every card check. It is absent until it has been read, and removed on disconnect.

**State.** `state` is one of `awaiting_code`, `active`, `expired`, `disconnected`. `expires_at` is the expiry Resy set; after it the session is not used. Your organization's own concierge sessions appear in the same list with `owner.type: "organization"`; they are read-only here (disconnecting or card-checking one is `409 organization_session`).

**Errors:**

| HTTP | error_code | Meaning |
|---|---|---|
| 400 | `end_user_required`, `phone_invalid`, `email_required`, `code_required`, `invalid_json`, `invalid_state` | Fix the request. |
| 400 | `invalid_code` | Resy rejected the code. Check it, or connect again. |
| 202 | `another_code_sent` | Resy sent a new code. Verify again with that one. |
| 403 | `live_key_required` | Use a live key. |
| 404 | `session_not_found` | No such session (or none for that `end_user`). |
| 409 | `session_not_active` | The session is expired or disconnected. Connect again. |
| 409 | `reconnect_required` | Resy turned the session away during a card check. Connect again. |
| 409 | `not_awaiting_code` | Nothing is waiting for a code. Connect again. |
| 409 | `organization_session` | One of your organization's own sessions; manage it in the dashboard. |
| 410 | `code_expired` | The 5 minutes passed. Connect again. |
| 422 | `provider_unsupported` | Only `resy` today. |
| 429 | `code_recently_sent` | A code went out under 30 seconds ago. Wait. |
| 502 | provider codes, `card_check_failed` | Resy's sign-in or the card check failed. Retry. |
| 503 | `sessions_unavailable` | Sessions are not available right now. Retry later. |

Rate limits per organization per 5 minutes: connect and verify share `session_connect` (20); card checks `session_card` (30). Sessions are never returned, logged or stored unencrypted.

---

## 25. Webhooks

Webhooks go to your organization's webhook URL, set on the dashboard's Settings page. Every booking your organization makes is delivered there, whichever API key made it. With no URL set, nothing is sent. Delivery history is on the dashboard's Webhooks page. `webhook_url` on `POST /v1/bookings` is accepted and stored, but deliveries do not use it. Full reference: https://www.opheliaos.com/docs/webhooks

| Type | Sent when |
|---|---|
| `booking.created` | A booking was accepted. Its status is usually `processing`. |
| `booking.requires_action` | The booking is waiting on the person or on you (a code, a payment authorization). `next_action` is included. |
| `booking.processing` | You answered with `/continue` and the booking is running again. Not sent on create. |
| `booking.confirmed` | The provider confirmed the booking. |
| `booking.failed` | The booking failed. `error_code` and `error_message` say why. |
| `booking.cancelled` | The booking was cancelled. |
| `booking.modified` | A modification succeeded. A refused modification sends no event. |

No event is sent when a booking expires (§10). Read the booking to see it.

**Payload.** A `POST` with a JSON body. `data.object` is a summary of the booking: `id`, `object`, `status`, `provider`, and when present `error_code`, `error_message`, `next_action` and `last_modification`. For everything else (including the confirmation code), read `GET /v1/bookings/{id}`.

```json
{
  "id": "evt_Xk3pQ9vR2mA",
  "type": "booking.confirmed",
  "created": "2026-11-14T15:02:41Z",
  "data": {
    "object": {
      "id": "bkg_12345678",
      "object": "booking",
      "status": "confirmed",
      "provider": "opentable"
    }
  }
}
```

**Signature.** Each delivery carries an `Ophelia-Signature` header of the form `t=<unix seconds>,v1=<hex>`. `v1` is the HMAC-SHA256 of `<t>.<raw body>`, keyed with your organization's webhook signing secret, hex-encoded. Compute it over the raw bytes you received, before parsing the JSON, and compare in constant time. Every attempt is signed afresh, so `t` is the time of that attempt. The signing secret is on the dashboard's Settings page, under Webhook signing secret (press Reveal, then Copy).

```javascript
import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  return parts.v1 && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
```

**Delivery and retries.** A background worker sends deliveries every 30 seconds, so an event usually arrives within half a minute. Any `2xx` within 10 seconds counts as delivered. Anything else (another status, a timeout, a connection error) is retried after 30 seconds, then 2 minutes, then 10 minutes; after the fourth failed attempt the delivery is abandoned. Delivery is at least once and events can arrive out of order: deduplicate on the event `id`, and treat `data.object.status` as a hint — when order matters, read the booking.

---

## 26. Testing — practice venues

Practice venues in "Ophelia Test City" exist for every vertical, hotels and flights included. They run the real booking flow and book nothing real. Search with `"location": "Ophelia Test City"` (for flights: Ophelia Test City as the origin or destination) and only practice venues come back; nothing else ever reaches them. Use them to test your integration end to end, as often as you like: no real venue, account or card is involved, and nobody is texted or emailed. Full reference: https://www.opheliaos.com/docs/testing

They are not mock responses. Restaurants, classes and tickets walk the same steps as the provider they imitate. Hotels and flights run Ophelia's production hotels and flights code against a pretend site, so the payment pause, the confirm step, the held checkout, the price ceiling and the unknown-outcome state behave exactly as they do for a real booking. Every practice response carries `"provider": "practice"`, and every practice venue has `metadata.practice: true`.

| | What it books | Billed | Free test bookings |
|---|---|---|---|
| Practice venues (any key) | Nothing | Never | Not used |
| Test key, anywhere else | Real reservations and purchases | No | One per confirmed booking, 3 in all, then `402 free_tier_exhausted` |
| Live key | Real reservations and purchases | Yes | — |

**A test key is not a sandbox.** Outside Ophelia Test City, a test key drives the real site: a hotel confirm charges the card and a flight confirm buys a real ticket. Practice bookings are never billed and do not use your test key's free bookings, but your key still has to be usable: a live key needs billing set up, and a test key must not have used up its free allowance.

| Vertical | Venue | Flow |
|---|---|---|
| dining | Practice Bistro | Like OpenTable with no card: texted code, then confirmed. Can be cancelled. |
| dining | Practice Steakhouse | Like an OpenTable venue that holds a card: slots carry `card_required: true`. Texted code, then confirmed. Can be cancelled. |
| dining | Practice Omakase | Like Resy: texted code, then confirmed. Cannot be cancelled once confirmed. |
| fitness | Practice Flow, Practice Reformer, Practice Power Yoga | Like Mindbody: emailed code, then confirmed. |
| entertainment | Practice Arena | Like StubHub: pick a listing from `metadata.sections` and send its `listing_id` in `metadata`. Emailed code, then confirmed. |
| travel (hotels) | Practice Inn ($159/night), The Practice Grand ($412), Ophelia Budget Stay ($89), Practice Boutique Hotel ($219), Practice Resort & Spa ($329), Practice Lodge ($129, always sold out) | Like hotels.com (§19): rates, then `next_action` `payment`, card on `/continue`, `confirm`, `{"confirm": true}`, confirmed. Or `payment_collection: "link"`: the practice pay page has no card fields, and the booking goes `processing` → `confirmed`. No code. |
| travel (flights) | Any route with "Ophelia Test City" (airport `OTC`) at either end; the other end JFK, BOS, ATL, MIA, ORD, DEN, LAS, LAX, SFO or SEA, or one of those cities by name | Like flights.com (§20): six departures a day, nonstop or via Practice Junction (PJX), every cabin and fare. `POST /v1/bookings` answers `202` with a held `fchk_` checkout (`reason: "payment"`); the card on `/continue` gives `reason: "confirm"`; `{"confirm": true}` gives `status: "succeeded"` with a `booking_reference` and a `bkg_fchk_…` booking. Or `payment_collection: "link"`: a `bkg_` booking, the practice pay page, `processing`, `next_action` `confirm`, `{"confirm": true}`, confirmed. No code. |

Flights are matched on the words "Ophelia Test City": a search from `"OTC"` alone is not practice and goes to the real flights site. As on a real route, the $14.10-a-traveller protection plan is bought unless you send `"insurance": "decline"`.

**The code.** Every practice code is `246810`. It never appears in an API response: as with a real provider, your agent has to ask the person for it. Any other code fails the booking with `error_code: "otp_invalid"`, the way a wrong code does at the real provider. Hotels and flights send no code.

**Test cards and failure cases.** Practice venues never charge a card; any number that passes the Luhn check works (`4242424242424242`). These values fail on purpose:

| Send | Hotels | Flights |
|---|---|---|
| `4000000000000002` | Declined at the confirm press: `failed`, `error_code: "not_confirmed"` | Declined at the confirm press: `502`, `code: "payment_declined"`, not retryable |
| `4000000000000119` | Pressed, no answer: `202 processing`, `error_code: "outcome_unknown"`; the booking stays `processing` | Pressed, no answer: `reason: "verify_outcome"`, `pressed: true`, `outcome_known: false` |
| `4000000000003220` | Confirmed (hotels ask for no 3-D Secure) | 3-D Secure asked after the press: `reason: "verify_outcome"`, `pressed: true`, `outcome_known: false` |
| A number that fails Luhn (`4242424242424241`) | `failed`, `error_code: "card_fill_incomplete"` at the card step | `502`, `code: "card_fill_incomplete"` at the card step |
| `max_total` below the checkout total | Not enforced for hotels yet: the booking confirms. Check the total yourself. | Parks unpressed: `reason: "over_max_total"` with `payable_total`; answer with a higher `max_total` and `confirm: true` |
| A room or itinerary | Ophelia Budget Stay's "1 left at this price" rate: `failed`, `error_code: "rate_not_available"`. Practice Lodge: availability returns no slots. | The cheapest itinerary on every route re-prices at checkout (+$12.40 a traveller), so its `price_drift` is never 0 |

On `outcome_unknown` or `verify_outcome`, stop and tell the person: the pay button was pressed and the site never answered. Never press again (§28).

**Timing.** The `Ophelia-Practice-Timing` request header sets how long each practice step takes: `live` (as long as the real site: a hotel search about 25 s, a flight checkout about a minute), `fast` (a tenth of that; the default) or `instant` (no time; for automated suites). Send it on every request. Any other value returns `400 invalid_practice_timing`.

```bash
# 1. Search
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vertical": "dining",
    "term": "dinner",
    "location": "Ophelia Test City",
    "datetime": "2026-11-14T19:00:00-05:00",
    "party_size": 2
  }'

# 2. Book a slot from Practice Bistro
curl -X POST https://api.opheliaos.com/v1/bookings \
  -H "Authorization: Bearer YOUR_TEST_KEY" \
  -H "Idempotency-Key: practice-001" \
  -H "Content-Type: application/json" \
  -d '{
    "venue_id": "ven_...",
    "availability_id": "avail_...",
    "datetime": "2026-11-14T19:00:00-05:00",
    "party_size": 2,
    "customer": {
      "name": "Jane Smith",
      "phone_number": "+12124567890",
      "email": "jane.smith.diner@gmail.com"
    }
  }'
# → "status": "requires_action", next_action.type "otp"

# 3. Send the code
curl -X POST https://api.opheliaos.com/v1/bookings/bkg_.../continue \
  -H "Authorization: Bearer YOUR_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "otp_code": "246810" }'
# → "status": "confirmed", "provider": "practice",
#   "confirmation": { "confirmation_code": "PRACTICE-BISTRO-..." }
```

```bash
# A practice flight: search, itineraries, held checkout, card, press
curl -X POST https://api.opheliaos.com/v1/venues/search \
  -H "Authorization: Bearer YOUR_TEST_KEY" -H "Content-Type: application/json" \
  -d '{ "vertical": "travel", "origin": "Ophelia Test City", "destination": "Los Angeles",
        "date": "2026-11-12", "party_size": 1, "cabin": "ECONOMY" }'
curl -X POST https://api.opheliaos.com/v1/availability/search \
  -H "Authorization: Bearer YOUR_TEST_KEY" -H "Content-Type: application/json" \
  -d '{ "venue_id": "ven_..." }'
# → slots[].itinerary.itinerary_key, e.g. "1_P0296_P0297.OTC20261112"
curl -X POST https://api.opheliaos.com/v1/bookings \
  -H "Authorization: Bearer YOUR_TEST_KEY" -H "Idempotency-Key: practice-flight-001" \
  -H "Content-Type: application/json" \
  -d '{ "vertical": "travel", "venue_id": "ven_...", "party_size": 1,
        "customer": { "name": "Jane Smith", "phone_number": "+12124567890", "email": "jane.smith.traveler@gmail.com" },
        "travellers": [{ "first_name": "Jane", "last_name": "Smith", "gender": "female",
          "birth_day": 14, "birth_month": 3, "birth_year": 1990,
          "phone": "+12124567890", "email": "jane.smith.traveler@gmail.com" }],
        "metadata": { "itinerary_key": "1_P0296_P0297.OTC20261112", "insurance": "decline",
          "max_total": 250, "hold": true,
          "billing": { "street": "1 Main St", "city": "New York", "state": "NY", "zipcode": "10014", "country": "US" } } }'
# → 202 { "object": "flight_checkout", "id": "fchk_...", "status": "requires_action", "reason": "payment" }
curl -X POST https://api.opheliaos.com/v1/bookings/fchk_.../continue \
  -H "Authorization: Bearer YOUR_TEST_KEY" -H "Content-Type: application/json" \
  -d '{ "card": { "number": "4242424242424242", "expirationDate": "12/28", "cvc": "123", "name": "Jane Smith" } }'
# → 202 { "reason": "confirm", "pay_control": "Complete booking", "checkout_total": { "total_text": "$184.48" } }
curl -X POST https://api.opheliaos.com/v1/bookings/fchk_.../continue \
  -H "Authorization: Bearer YOUR_TEST_KEY" -H "Content-Type: application/json" \
  -d '{ "confirm": true }'
# → 200 { "status": "succeeded", "provider": "practice", "booking_id": "bkg_fchk_...",
#         "booking_reference": "PRACTICE-FLIGHT-...", "payable_total": 184.48, "price_drift": 0 }
```

The usual request rules still apply (§8), so a practice run also tests your input: the customer needs a first and last name, and placeholder guests are refused (`placeholder_customer`), including any phone number in a 555 exchange.

---

## 27. Error Handling

Errors return a non-2xx status with a JSON body. `error` is always present. Where a refusal has a stable, machine-readable name it is in `error_code`; branch on that, not on the wording of `error`. A few older refusals put their code in `error` itself (`unauthorized`, `billing_required`, `free_tier_exhausted`, `rate_limited`, `insufficient_scope`).

| Status | Code | Meaning | Agent response |
|---|---|---|---|
| 400 | `missing required fields` (in `error`) | Venue search is missing fields (see `missing` array) | Fix the input |
| 400 | `invalid request body` (in `error`) | Body is not valid JSON, or a field has the wrong type (`field`, `detail`) | Fix the input |
| 400 | `placeholder_customer` | The customer (or a flight traveller) is a placeholder (§8) | Ask the end user for their real details |
| 400 | `missing_last_name` | OpenTable needs a first and last name | Ask for the full name |
| 400 | `slot_not_offered` | `datetime` is not the time the `availability_id` was offered at | Book the slot's own `datetime`, or check availability again |
| 400 | (no code) `validation failed: …` | Names each field, e.g. `datetime: must be in the future` | Fix the input; nothing was created |
| 400 | (no code) `could not resolve location "…"` | Venue search location not found | Send a more specific location |
| 400 | (no code) `provider required: …` | `venue_id` has no resolvable provider | Use a `venue_id` a search returned |
| 401 | `unauthorized` | Missing or invalid API key | Fix the `Authorization` header |
| 402 | `billing_required` | Live key without billing configured | Tell the user to configure billing; do not retry |
| 402 | `free_tier_exhausted` | Test key's allowance (3 confirmed bookings) used up | Use a live key with billing, or practice venues; do not retry |
| 403 | `insufficient_scope` | The key lacks the scope (`required_scope`) | Use a key with that scope |
| 403 | `wrong_key_environment` | A beta (v3) key was sent to `/v1` | Use a live or test key |
| 403 | `org_suspended` | API access for your organization is suspended | Contact support; retrying will not help |
| 403 | `live_key_required` | Sessions need a live key (§24) | Use a live key |
| 404 | (no code) `booking not found` | Unknown booking id; `open_bookings` lists your unfinished ones from the last day | Check the id |
| 404 | (no code) `venue not found; run venues/search first` | The `venue_id` was never returned by a search | Search first |
| 409 | (no code) | Modify on a booking that is not confirmed | Only confirmed bookings can be modified |
| 409 | `vault_unavailable` | `agentcard_external` on a venue AgentCard cannot pay; nothing created | Offer a different venue or time |
| 413 | (no code) `body too large` | Request bodies are limited to 1 MB | Shrink the body |
| 422 | `modify_unsupported` | This provider cannot modify bookings (only OpenTable can) | Nothing was attempted |
| 422 | the provider's code | The venue refused a modification; the body has the unchanged booking | It is still confirmed at the original time |
| 429 | `rate_limited` | Over the endpoint's rate limit (below) | Wait `Retry-After` seconds, then retry |
| 502 | `search_failed` | Every provider failed for this search (`providers_failed`) | Retry |
| 502 | `availability_failed` | The provider failed while reading availability (`provider`) | Retry |
| 503 | `no_search_provider` | No provider able to search is configured for this request (400 when you named the providers yourself) | Check `vertical` / `providers` |
| 503 | `billing_check_unavailable`, `entitlement_check_unavailable` | Billing could not be checked for this key | Retry shortly |
| 503 | `agentcard_disabled` | AgentCard payments are off | Contact support |

AgentCard `/continue` answers a malformed `psp_response`, or an answer after `expires_at`, with `400` and only an `error` message; read the booking to tell them apart (§13). Session codes are in §24; flights drive errors in §20.

Example bodies:

```json
{ "error": "missing required fields", "missing": ["vertical", "datetime"] }
{ "error": "invalid request body", "field": "party_size", "detail": "party_size must be a whole number, got string" }
{ "error": "the customer must be the real guest: ...", "error_code": "placeholder_customer" }
{ "error": "unauthorized" }
{ "error": "free_tier_exhausted", "limit": 3, "message": "Test API keys are limited to 3 free calls..." }
{ "error": "insufficient_scope", "required_scope": "bookings:create" }
{ "error": "rate_limited", "message": "rate limit exceeded", "limit": 25, "retry_after_seconds": 180 }
```

### Rate limits

Per organization, shared by all its API keys, over a fixed 5-minute window. Every metered response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window resets). Over the limit you get `429 rate_limited` with a `Retry-After` header.

| Endpoint | Per 5 min |
|---|---|
| `POST /v1/venues/search` | 25 |
| `POST /v1/availability/search` | 60 |
| `POST /v1/bookings` | 10 |
| `POST /v1/bookings/{id}/continue` | 20 |
| `GET /v1/bookings/{id}` | 300 |
| `POST /v1/bookings/{id}/cancel` | 30 |
| `POST /v1/sessions`, `POST /v1/sessions/{id}/verify` | 20 |
| `GET /v1/sessions/{id}/card`, `GET /v1/sessions/card` | 30 |

### Booking `error_code` (status `failed`)

A booking whose provider failed is not an HTTP error: it comes back with `status: "failed"` and its own `error_code` and `error_message`. The `error_message` is written for a developer and usually names the fix. Grouped by what to do next:

**Fix the customer data, then create a new booking**

| `error_code` | Providers | Meaning |
|---|---|---|
| `otp_not_sent` | OpenTable, Resy | The provider accepted the form but never sent the SMS — almost always a bad or unreachable `customer.phone_number`. |
| `otp_not_received` | OpenTable, Resy | The code window closed with no code submitted. The end user never got it or you never relayed it. |
| `not_confirmed_after_otp` | OpenTable, Resy | A code was submitted but the provider did not confirm — typically a wrong code. |
| `otp_invalid` | Practice venues | A code other than `246810` (§26). |
| `account_email_mismatch` | Resy | `customer.phone_number` belongs to an existing Resy account whose email differs from `customer.email`. Send that account's email, or a phone with no Resy account. |
| `logged_in_not_booked` | Resy | The phone belongs to an existing Resy account; Resy signed in instead of reserving and no table was taken. |
| `email_required` | Resy | Resy asked for email verification and `customer.email` was empty. |
| `reconnect_required` | Resy (sessions) | The `end_user`'s session is no longer accepted. Connect again, or retry the booking (§24). |
| `account_exists` | Mindbody | An existing account was not offered a code sign-in. Send its password in `metadata.password`. |
| `mindbody_social_login` | Mindbody | A Google/Apple account Mindbody would not switch to email sign-in. |

**Fix the card, then create a new booking**

| `error_code` | Providers | Meaning |
|---|---|---|
| `missing_card` | OpenTable | The venue needs a card, or its policy is unknown, and none was sent (§15). |
| `credit_card_required` | OpenTable | Same, discovered at the reservation form. |
| `card_required` | Resy | The slot needs a card and none was sent. Send `metadata.payment` or use AgentCard (§16). |
| `card_declined` | OpenTable, Resy, AgentCard | The provider or processor rejected the card (invalid/test number, expired, AVS mismatch). |
| `missing_billing_fields` | Mindbody | `payment.state` or `payment.country` missing. |
| `payment_failed` | Mindbody | Mindbody rejected the payment method. |
| `checkout_incomplete` | StubHub | Checkout reached but the payment step could not be completed — usually no card was supplied. |
| `missing_listing` | StubHub | No `metadata.listing_id` from `metadata.sections`. |

**Pick a different slot or venue, then create a new booking**

| `error_code` | Providers | Meaning |
|---|---|---|
| `no_availability` | all | The exact requested time is no longer offered. Ophelia never books a different time; `error_message` lists what is available. |
| `party_size_unavailable` | OpenTable | Venue does not take this party size online. |
| `date_unavailable` | OpenTable | Calendar would not accept the date (too far out, closed). |
| `class_unavailable`, `no_class_found` | Mindbody | Class full, registration closed, or not on the schedule at that time. |
| `session_gone` | Hotels | The held checkout timed out before `/continue` (§19). |

**AgentCard (`payment_collection: "agentcard_external"`, §13)**

| `error_code` | Meaning |
|---|---|
| `payment_declined` | You sent `psp_abort`. Start a new booking if the end user wants to. |
| `payment_approval_expired` | No answer before `next_action.expires_at`. If the end user had approved, check with the venue first. |
| `payment_outcome_unknown` | The card may have reached the venue (e.g. a late `psp_response`). Check before booking again. |
| `payment_setup_failed` | The payment could not be prepared for this venue. Offer a different venue or time. |
| `payment_replay_failed` | The processor's answer could not be applied to the checkout. The processor may hold the card; check with the venue first. |

**Transient — wait, then retry with a new booking (same input is fine)**

| `error_code` | Providers | Meaning |
|---|---|---|
| `akamai_blocked`, `blocked` | OpenTable | Bot challenge served instead of the reservation page. |
| `waf_blocked`, `captcha_required` | Resy | Cloudflare/DataDome or an image captcha. |
| `cloudflare_blocked` | StubHub | Cloudflare challenge. |
| `mindbody_signin_error` | Mindbody | Mindbody's own sign-in failed ("Something went wrong, please try again later"). |
| `timeout`, `navigation_failed`, `browser_launch_failed`, `runner_error` | all | Infrastructure on Ophelia's side. Back off 30–60 s. |

**Not failures, but read them:** `action_expired` on an `expired` booking (§10); `cancellation_unsupported` on a booking that stays `confirmed` after `/cancel` (§22).

Anything else is an internal or provider-UI failure; surface `error_message` to the developer. Retry rules in §28 still apply — check `GET /v1/bookings/{id}` before creating a new booking after an uncertain outcome.

---

## 28. Safe Retry Rules

| Call | Safe to retry blindly? |
|---|---|
| `POST /v1/venues/search` | Yes, with reasonable backoff. Read-only. `502 search_failed` is safe to retry. |
| `POST /v1/availability/search` | Yes. Read-only; results may simply have changed. |
| `POST /v1/bookings` | Only with the **same** `Idempotency-Key`: it returns the booking that key first created (§9). Never generate a new key to retry an uncertain outcome; that risks a duplicate booking. Flights ignore the key — never retry a flight booking blindly. |
| `POST /v1/bookings/{id}/continue` | **No** — if the outcome is uncertain (timeout, dropped connection), call `GET /v1/bookings/{id}` first to see the actual state, then decide whether the action still needs to be submitted. Never retry a flights `/continue` after `confirm: true`. |
| `POST /v1/bookings/{id}/modify` | Check state via `GET` before resubmitting after an uncertain outcome. |
| `POST /v1/bookings/{id}/cancel` | Cancelling an already `cancelled`, `failed` or `expired` booking changes nothing, but check state via `GET` first. Never on flights. |
| `GET /v1/bookings/{id}` | Yes. Read-only. |

---

## 29. Rules for AI Coding Agents

**DO**
- Use only the documented endpoints and exact paths in §3.
- Use only `venue_id`/`ven_...` values returned by the API — never invent or guess one.
- Book with the availability slot's `availability_id` and its `datetime` verbatim (§5, §7).
- Collect the end user's real name, mobile and email; never fill in placeholders (§8).
- Test end to end on the practice venues in "Ophelia Test City" first (§26).
- Inspect `status` on every booking response before acting on it.
- Inspect `next_action.type` on every `requires_action` response — never assume which type it will be — and answer before `next_action.expires_at`.
- Handle multiple sequential `/continue` calls (StubHub: code then card; hotels: card then confirm).
- Check `card_required` on availability before deciding whether to send `metadata.payment` (OpenTable, Resy).
- For travel, always name the provider (`["hotels"]` or `["flights-com"]`).
- On every flights booking, send `metadata.insurance` and `metadata.max_total` explicitly, and compare `checkout_total` to the trip price before `/continue` with `confirm: true` (§20).
- Send a unique `Idempotency-Key` on every `POST /v1/bookings` call, and reuse it to retry an uncertain outcome.
- Set HTTP client timeouts above 150 seconds (above 300 seconds for flights).
- Call `GET /v1/bookings/{id}` to recover state after any uncertain network outcome, and after a webhook when you need the full booking.
- Verify the `Ophelia-Signature` header on every webhook and deduplicate on the event `id` (§25).
- Keep API keys server-side only.
- Treat provider-specific requirements (card, billing address, listing_id, travellers) explicitly per §14–20 rather than assuming one shape fits all verticals.

**DON'T**
- Don't invent endpoints, fields, statuses, or error codes not listed in this document.
- Don't invent a `venue_id`, `availability_id`, or `booking_id`.
- Don't assume every provider behaves like OpenTable (Resy needs a card only at some venues; Mindbody may email its code; hotels charge on `confirm`).
- Don't assume OTP is the only `next_action.type` — `payment`, `confirm` and `payment_authorization` exist.
- Don't assume one `/continue` call finishes a booking.
- Don't report `processing` or `requires_action` to the user as "confirmed."
- Don't blindly retry `POST /v1/bookings` or `/continue` with a fresh idempotency key after an uncertain outcome — check state first.
- Don't call `/cancel` or `/modify` on a flight booking, and don't promise cancellation on providers that don't support it (§22).
- Don't expose API keys in client-side code.
- Don't log card numbers, CVV, passwords, or OTP codes.
- Don't fabricate a successful booking outcome.
- Don't retry a flights `/continue` that returned `pressed: true` with `outcome_known: false` — a second press can buy a second ticket.
- Don't send a v1 key to `/v3` or a beta key to `/v1` — each is refused with `403 wrong_key_environment` (§32).

---

## 30. What Data to Collect From the User

**Common (every vertical):**
- The end user's real **first and last name**, sent as one string — never a placeholder (§8)
- The end user's real **mobile number in E.164** — dining codes are texted there (§11)
- The end user's real **email** — Mindbody and StubHub may send codes there, and all confirmations go there
- Party size (dining, entertainment — not used for fitness)
- Desired date/time
- Payment details, only when the flow actually requires them (check `card_required` from availability, or wait for `next_action.type: "payment"`). When required: a real card, `MM`/`YYYY` expiry as strings, CVV, cardholder name, billing ZIP; full billing address for fitness and entertainment (§13). Or an AgentCard user id on Resy/OpenTable.
- A way for the end user to hand you the verification code before `next_action.expires_at` (currently 5 minutes)

**Provider-specific — collect only when that flow needs it:**
- OpenTable: card unless availability says `card_required: false` (held, not charged)
- Resy: a phone with no existing Resy account, or that account's email; a card at venues with `card_required: true`; your `end_user` id to reuse a session
- Mindbody: card with full billing address (state and country); the emailed code; the account password only if you already have it
- StubHub: listing selection (section/row/seats), full billing address
- Hotels: check-in and check-out dates, number of rooms, total guests, the card's billing ZIP; a card charged the full total on `confirm`
- Flights: for every traveller, legal first and last name, gender, date of birth and phone (plus email for the first); cabin; an explicit insurance choice; a price ceiling (`max_total`); seat preference and the most they'll pay for a seat; a complete billing address or none

Don't front-load a card or password prompt before you know the specific flow requires it — inspect `card_required` (availability) or `next_action.type` (booking) first.

---

## 31. Minimal Agent Recipe

```text
1. POST /v1/venues/search → pick a venue_id
2. POST /v1/availability/search with that venue_id → pick a slot
3. POST /v1/bookings with the slot's availability_id and datetime, the real customer,
   and an Idempotency-Key header
4. Inspect status
5. If requires_action: inspect next_action.type, collect the input, POST .../continue
   before next_action.expires_at
6. Repeat step 4–5 until status is terminal
7. Only report success when status == "confirmed"
8. Use GET /v1/bookings/{id} to recover or verify state after any uncertain call
```

Flights differs at steps 3–5: the booking parks at the pay screen (202) and step 5 is `/continue` with the card and `confirm: true`, capped by `max_total` (§20).

For the complete short-form index, see https://www.opheliaos.com/llms.txt.


---

# Part II — v3 (beta)

v3 is a separate product surface, not another v1 provider. v1 calls integrated partners' APIs. v3 drives the site itself: a real browser walks the funnel, fills the form and presses the button on any public website you name. The two share auth, rate limiting and metering, and nothing else; a key works on exactly one of them. Beta access is limited, and beta keys are requested from the dashboard's API Keys page.

## 32. v3 Base, Keys, and Scopes

```
Base path:    https://api.opheliaos.com/v3
Auth:         Authorization: Bearer oph_beta_…
Content type: application/json   (max body 1 MiB)
Cancellation: not available
```

| Key prefix | `/v1` | `/v3` |
|---|---|---|
| `oph_beta_` | 403 `wrong_key_environment` | allowed |
| `oph_live_` / `oph_test_` | allowed | 403 `wrong_key_environment` |

| Scope | Opens |
|---|---|
| `v3:search` | `POST /v3/search`, `POST /v3/availability`. Subsumed by `v3:book` |
| `v3:book` | `POST /v3/bookings`, `GET /v3/bookings/{id}`, `/continue`, `/abandon`. The full authority to complete a booking |
| `v3:act` | Nothing on its own. Checked when a read carries `authorize.max_commitment: "commit"`: without it `403 insufficient_scope`; with it the read is still refused `409 commitment_refused` |
| `v3:sites:read` | `GET /v3/verticals` (also open to `v3:search` and `v3:book`). The name predates the removal of the site catalog |

A beta key carries `v3:search`, `v3:book` and `v3:sites:read`. `GET /v3/` needs no scope; `POST /v3/bookings/{id}/cancel` and `/modify` need none either (they always answer 501). Every id is scoped to the key's organization; another organization's id looks exactly like one that never existed. A key with no organization gets 403 `no_organization`.

If v3 isn't enabled on a deployment, search, availability, bookings, get, continue and abandon answer 503 `v3_disabled`; `GET /v3/` still answers 200 with `"enabled": false`, `GET /v3/verticals` still answers, and cancel and modify still answer 501. Authorization is checked first.

Refusals from the shared auth, rate-limit and billing layers arrive before v3 and do not use the v3 envelope: `401 {"error": "unauthorized"}`; `403 error_code: wrong_key_environment`; `403 error: insufficient_scope` (with `required_scope`, and `accepted_scopes` where several would do); `403 error_code: org_suspended`; `402 error: free_tier_exhausted` (beta keys are billed like test keys: allowed while the organization is under its free test allowance, which confirmed test bookings consume); `429 error: rate_limited`.

## 33. v3 Endpoints and Lifecycle

| Method | Path | Description |
|---|---|---|
| POST | `/v3/search` | Search one site. Mints an `ent_` id for every result reached |
| POST | `/v3/availability` | Read bookable offers (`off_`) on one entity |
| POST | `/v3/bookings` | Drive the funnel and press the final button. Irreversible |
| GET | `/v3/bookings/{booking_id}` | Recorded state of one booking |
| POST | `/v3/bookings/{attempt_id}/continue` | Answer a challenge (OTP) on a paused drive |
| POST | `/v3/bookings/{attempt_id}/abandon` | Release a paused session you will not answer |
| POST | `/v3/bookings/{booking_id}/cancel`, `/modify` | Registered to refuse: always 501 `not_implemented_in_v3` |
| GET | `/v3/verticals` | The ask shapes v3 can put to a site (§35) |
| GET | `/v3/` | Index: whether v3 is enabled here, every route, and the no-undo and addressing rules in brief |

```bash
# 1. Search a site
curl -X POST https://api.opheliaos.com/v3/search \
  -H "Authorization: Bearer YOUR_BETA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "site": "opentable.com", "vertical": "dining", "term": "italian",
        "location": "New York, NY", "datetime": "2026-11-14T19:30:00-05:00", "party_size": 2 }'
# → entities[]: entity_id "ent_…", name, host, addressable, booking_url, price, rating, times[]

# 2. (Optional) Offers on one entity
curl -X POST https://api.opheliaos.com/v3/availability \
  -H "Authorization: Bearer YOUR_BETA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "entity_id": "ent_2f9c…", "datetime": "2026-11-14T19:30:00-05:00", "party_size": 2 }'
# → slot_source, offers[]: offer_id "off_…", label, kind, expires_at (15 minutes)

# 3. Book. Defaults to max_commitment "commit": it presses the final button.
curl -X POST https://api.opheliaos.com/v3/bookings \
  -H "Authorization: Bearer YOUR_BETA_KEY" \
  -H "Idempotency-Key: trip-4821-dinner" \
  -H "Content-Type: application/json" \
  -d '{
    "entity_id": "ent_2f9c…",
    "offer_id": "off_71ab…",
    "customer": { "name": "Jane Smith", "email": "jane.smith.diner@gmail.com", "phone": "+15555555555" },
    "card": { "number": "4242424242424242", "exp": "04/29", "cvv": "123", "name": "Jane Smith",
              "street_address": "123 Market St", "city": "San Francisco", "state": "CA",
              "postal_code": "94103", "country": "US" }
  }'
# → 200 { status "succeeded", booking_id "bk_…", confirmation_number, cancellable: false, cancel_via }
# → or 202 { status "requires_action", booking_id "bk_…", attempt_id "att_…", challenge_type,
#            resumable, continue_with, live_url, cancellable: false, cancel_via }

# 4. (Only after a 202 with resumable: true) answer the challenge
curl -X POST https://api.opheliaos.com/v3/bookings/att_5c02…/continue \
  -H "Authorization: Bearer YOUR_BETA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "code": "481920" }'
```

- `site` (search) is a host or full http(s) URL. Any public site can be driven; there is no allowlist. A bare name is treated as a domain guess (`opentable` becomes `www.opentable.com`), so a typo opens a browser at whatever public domain it spells.
- `vertical` is optional: a drive profile (`hotels`, `flights`) or a category that maps to exactly one (`dining`). An umbrella like `travel` or `entertainment` is `400 ambiguous_vertical`, and the message lists the profiles that would work (§35).
- **Times are the venue's wall clock.** With a `location`, v3 geocodes it and converts a zoned `datetime` to the venue's local time; without one (the usual case for `entity_id`/`url` calls) the clock time you wrote is driven as-is. Send the venue's own offset (`2026-11-14T19:30:00-05:00` is 7:30 PM in New York) and the answer is the same either way.
- Search also takes `end_date`/`end_datetime` (the other end of a span), `destination` (or `end_destination` / `end_destination_address`), `slots` and `actor` (maps of role → typed value, for what the fixed fields cannot express), and `customer` (the flat form of `actor`). Never put card or billing values in `slots` or `actor`.
- Bookings take `customer` (`name` or `first_name` + `last_name`, `email`, `phone`, and where a form asks `dob`, `sex`, `postal_code`, `address`, `location`, `terms`), every search field with the same meaning, and `card` (`number`, `exp`, `cvv`, `name`, `postal_code`, `street_address`, `city`, `state`, `country`) or the same card in v1's `payment` shape (merged field by field; `card` wins). Cards are never echoed in any response.
- An entity's `times[]` are the labels shown on the result card, not offers. You can't book a `times` entry. `addressable: false` means the entity has no link of its own and cannot be booked.
- `attempt_id` is returned on `POST /v3/bookings` only when the drive paused resumably; it is always present on `GET`. On `/continue`, `otp_code` is accepted as an alias for `code`.

## 34. v3 Addressing and Authorization

**Addressing.** Availability and bookings act on exactly one thing, named by `entity_id` (`ent_…`, which wins if both are sent) or `url` (an absolute http(s) URL — the `booking_url` a search returned). Send at least one, or the request fails with 400 `invalid_input`. An entity-addressed call takes its site from the entity, so you do not send `site`; if you omit `vertical`, the one the search stored on the entity is used. A `url` that matches no stored entity mints a new one, on any host, and buys no extra authority. An address that is not on the public internet (loopback, link-local, private-network, single-label hosts) is refused with 409 `private_address`. An `offer_id` must belong to the addressed entity (400 `offer_entity_mismatch`). Past its `expires_at` it's refused with 409 `offer_expired`: re-read availability, because a stale offer replayed against a moved grid books the wrong slot.

| Prefix | Object |
|---|---|
| `ent_` | Entity: one addressable thing |
| `off_` | Offer: a bookable coordinate, 15-minute expiry |
| `bk_` | Booking |
| `att_` | Attempt: the drive behind a booking |
| `drv_` | Drive: the audit row |

An id from the wrong namespace fails with 400 `wrong_id_namespace`, not 404.

**Authorization.** `authorize.max_commitment` is a bound, not a target:

| Value | The drive may | Default on |
|---|---|---|
| `read` | Reach results, open one, report what's there. Presses nothing | search, availability |
| `begin` | Open the booking step and walk to the guest form. Types no card, presses no finalizer | — |
| `commit` | Fill the guest form, type the card you sent, and press the final button. Only allowed on bookings | `POST /v3/bookings` |

Your bound is the only ceiling: there is no per-site limit. An unrecognised value is 400 `invalid_input`. Two combinations are 409 `commitment_refused`: a search or availability read carrying `commit` (reads accept `read` or `begin`), and a booking carrying `read`. `site` is required whenever `max_commitment` is `begin` or `commit` (entity-addressed calls satisfy it from the entity). `authorize.authorized_for` defaults to your handle on bookings. `authorize.fill_identity` (default `false`) lets the drive answer a sign-in wall with the customer's phone or email; the one-time code it triggers is always handed back to you, never auto-filled.

## 35. v3 Verticals

```bash
curl https://api.opheliaos.com/v3/verticals \
  -H "Authorization: Bearer YOUR_BETA_KEY"
```

`GET /v3/verticals` lists the ask shapes v3 knows how to put to a site. There is no site list: v3 drives whatever public host it is given, so what is worth reading in advance is which question each vertical asks. It is not organization-scoped, answers even when v3 is disabled, and is budgeted under `v3_catalog` (300 per 5 minutes). Scope: `v3:sites:read`, `v3:search` or `v3:book`.

Each entry has `vertical` (the public category), `action` (what is booked within it), `drive_profile` (the value to send as `vertical` when the category alone is ambiguous), `ask_shape` (extra structure: `span`, `route`, `term`; omitted when none), `availability_unit`, and `required_choices`.

| drive_profile | vertical / action | unit | required_choices |
|---|---|---|---|
| `dining` | dining / restaurant_reservation | table | slot |
| `hotels` | travel / hotel_booking | room | room_type, rate_plan |
| `flights` | travel / flight_booking | itinerary | fare_class |
| `cars` | travel / car_rental | vehicle | vehicle |
| `activities` | travel / activity_booking | tour | departure |
| `events` | entertainment / event_booking | seat | ticket_tier, quantity |
| `tickets` | entertainment / ticket_purchase | seat | quantity, section |
| `shopping` | retail / product_purchase | item | variant, quantity |
| `health` | healthcare / appointment, beauty / beauty_appointment | appointment | appointment, visit_reason |
| `fitness` | fitness / class_booking | class | session |

`vertical` on a request takes either a drive profile or a category that maps to exactly one; `travel` and `entertainment` cover several, so naming them is 400 `ambiguous_vertical`. Omitting `vertical` is fine.

## 36. v3 Statuses, Pauses, and Errors

Every response has one envelope. Only `status` is always present, so branch on it.

| status | HTTP | Means | Retry? |
|---|---|---|---|
| `succeeded` | 200 | The drive did what was asked | no |
| `processing` | 200 | Work under way (e.g. `continue_in_flight`, a second continue while the first is still typing) | poll GET |
| `requires_action` | 202 | Paused on a human step. Always 202, even when `error_code` says the pause can no longer be answered | answer it |
| `needs_input` | 422 | Un-actionable as sent; nothing ran. `missing[]` lists typed inputs to render a form from | re-send with more |
| `failed` | 4xx/5xx | See `error_code`. `attempt_abandoned` is the one failed booking that answers 200 | by `failure_class` |

`failure_class: "retryable_elsewhere"` means nothing was committed, so a fresh attempt is safe. `"terminal"` means it failed or the state is ambiguous: stop, because a retry risks a double booking. A failure whose code is not in the table below answers 503 with `Retry-After: 30` when its class is `retryable_elsewhere`, and 502 otherwise.

**Pauses.** `resumable: true` means a browser session is held open (the OTP case). POST the code to `continue_with` within the continue window (10 minutes). `resumable: false` means nothing is held: the drive stopped to ask permission for the press described in `action`, so re-send the original request with `authorize.authorized_for` set to the exact string returned. A pause nobody answers reads back on `GET` as 202 `requires_action` with `resumable: false`, no `continue_with` and `error_code: "attempt_expired"`; nothing was completed — start a new booking.

**`/continue` answers:** `succeeded` / `failed` (the code was typed and the drive finished); `idempotent_replay: true` (already finished, the stored answer); `processing` with `continue_in_flight` (an earlier continue is still applying the code — do not re-send; read the booking); 410 `attempt_expired` (the session was no longer held; the code was never typed); 409 `transaction_status_unknown` (the code was delivered and the answer never came back — do not retry; read the booking and check with the venue); 404 `unknown_attempt`; 400 `wrong_id_namespace`.

**Errors:**

| error_code | HTTP |
|---|---|
| `invalid_input`, `wrong_id_namespace`, `offer_entity_mismatch`, `ambiguous_vertical` | 400 |
| `no_organization`, `wrong_key_environment` | 403 |
| `unknown_entity`, `unknown_offer`, `unknown_booking`, `unknown_attempt` | 404 |
| `no_site`, `unresolvable_site`, `private_address`, `commitment_refused`, `unaddressable_entity`, `offer_expired` | 409 |
| `search_error`, `term_not_applied`, `location_not_applied` (search could not reach a result set that answers what or where you asked) | 409 |
| `no_action_found`, `booking_error`, `checkout_incomplete` (a booking that did not complete; nothing was booked or charged) | 409 |
| `transaction_status_unknown` (contact lost after the final press may have landed — do not retry) | 409 |
| `attempt_expired` | 410 |
| `needs_input` | 422 |
| `internal` | 500 |
| `not_implemented_in_v3` (cancel, modify) | 501 |
| `search_nav_failed` (the browser never reached the site) | 502 |
| `drive_capacity` (Retry-After: 60), `no_clean_exit` (Retry-After: 30), `v3_disabled` | 503 |
| `attempt_abandoned`, `continue_in_flight` | 200 |

All four booking-completion codes (`no_action_found`, `booking_error`, `checkout_incomplete`, `transaction_status_unknown`) are terminal: on a surface that cannot cancel, an automatic retry is a second reservation. A booking can also succeed with no `confirmation_number` (common for RSVPs, appointment requests and free events); `reason` then says so.

**Validation** (400 `invalid_input`, terminal, checked before any browser starts; the first problem found is returned): `party_size` 0–64; `datetime` strict RFC 3339 with a zone; `max_commitment` one of `read`/`begin`/`commit`; `idempotency_key` matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`. On sites with a reviewed recipe, a request missing a declared input is answered `422 needs_input` with a typed `missing[]` before the browser starts.

## 37. v3 Rules and Limits

- **No undo.** `/cancel` and `/modify` exist and always answer 501 `not_implemented_in_v3`, with `cancellable: false`, `cancel_via`, `booking_id`, and the venue's `confirmation_number` where captured. The booking stands, and the end user contacts the venue. Store `confirmation_number`: it's the only route to a manual cancel.
- **`/abandon` is not a cancel.** It releases a held session and its drive slot, answering 200 with `status: "failed"` and `error_code: "attempt_abandoned"`. Treat the booking's outcome as unknown. If the booking had already finished, the answer is its stored state (including a `succeeded` with its `confirmation_number`). Call it whenever a person won't answer a challenge: live drives run on a fixed pool of browsers shared by every organization, and a parked challenge holds one until the 10-minute timeout; while the pool is full everyone gets `503 drive_capacity`.
- **Idempotency is the only defence against a double booking.** Send `Idempotency-Key` (or `idempotency_key`) on every booking. It's checked before anything resolves or spawns, and a replay returns the first answer with `idempotent_replay: true`; no second booking is made.
- **Verify the day.** Check `slot_source` on availability: `multiday_gql` is date-checked, while `modal_dom`/`dom` are scrapes that may show today whatever date you asked, and `reason` says so.
- **Send a full billing address with the card** (street, city, state, postal code). A postal code alone can stall at a real payment form.
- **A drive is a promise to try.** Drives can be blocked or pages can change, which is what `failure_class` is for. `served_by`, `path`, `drive_id` and `duration_ms` ride on any answer that drove something.
- **Rate limits** (per organization, fixed 5-minute window): search 25, availability 60, bookings 10, continue 20, `GET /v3/bookings/{id}` 300, abandon/cancel/modify 300 (shared with get), `GET /v3/` and `GET /v3/verticals` 300. Every metered response carries `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`; a 429 carries `Retry-After` and `{"error": "rate_limited", "message", "limit", "retry_after_seconds"}`. On search, availability and bookings, a request that spawned nothing (refused at resolution, failed validation, answered by the input check, or an idempotent replay — no `drive_id`) has its unit refunded; one refused before reaching the service (missing scope, a body that is not JSON, no `entity_id` or `url`) still counts.

Full reference: https://www.opheliaos.com/docs/beta
