# Ophelia API > 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 selects the provider; availability and bookings then take the `venue_id` that search returned. Base URL: https://api.opheliaos.com Authentication: Bearer token, `Authorization: Bearer YOUR_LIVE_KEY` API keys: create an account at https://dashboardopheliaos.com. A live key (oph_live_) needs billing set up first (402 billing_required). A test key (oph_test_) hits real venues with a free allowance of 3 confirmed bookings per organization (then 402 free_tier_exhausted). A beta key (oph_beta_) works on /v3 only. Docs (human-readable): https://www.opheliaos.com/docs Full docs (implementation contract): https://www.opheliaos.com/docs.md v3 beta: see "v3 (beta)" below Test first in "Ophelia Test City": search with "location": "Ophelia Test City" (flights: Ophelia Test City as origin or destination, written out; "OTC" alone is a real search) and only practice venues come back, for every vertical including hotels and flights. They run the real booking flow, the code is always 246810, responses say "provider": "practice", and they are never billed or counted against a test key's free bookings (https://www.opheliaos.com/docs/testing). Practice cards: any Luhn-valid number succeeds (4242424242424242); 4000000000000002 is declined at the confirm press; 4000000000000119 is pressed with no answer (hotels: processing + outcome_unknown; flights: verify_outcome); 4000000000003220 asks for 3-D Secure (flights: verify_outcome); a non-Luhn number is card_fill_incomplete. Ophelia Budget Stay's "1 left" rate fails rate_not_available, Practice Lodge is always sold out, and the cheapest itinerary on each practice route re-prices at checkout. Header Ophelia-Practice-Timing: live | fast (default) | instant sets how long each practice step takes. Everywhere else, every booking drives the real provider site and creates a real reservation or purchase, on test keys too: a test-key flight confirm buys a real ticket. ## Endpoints - GET /v1/: Index of the API and its partner endpoints - POST /v1/venues/search: Search venues for a vertical (dining, fitness, entertainment, travel) - POST /v1/availability/search: Get bookable slots for a specific venue - POST /v1/bookings: Create a booking for a venue and requested time; send end_user to book signed in as a person with a connected session - GET /v1/bookings/{booking_id}: Read a booking and its current status - POST /v1/bookings/{booking_id}/continue: Submit an OTP, card, confirm, or AgentCard answer to advance a booking - POST /v1/bookings/{booking_id}/modify: Change a confirmed booking's datetime, party_size, or seating_preference (OpenTable only; others 422 modify_unsupported) - POST /v1/bookings/{booking_id}/cancel: Cancel a booking (a confirmed one is cancelled at the venue on OpenTable only; elsewhere it stays confirmed with error_code cancellation_unsupported) - POST /v1/sessions, POST /v1/sessions/{id}/verify: Connect a person's own Resy account (live key only; texts them a code) - GET /v1/sessions, GET /v1/sessions/{id}, DELETE /v1/sessions/{id}: List, read, disconnect sessions - GET /v1/sessions/{id}/card, GET /v1/sessions/card?end_user=: Does the person's Resy account have a card on file (has_card) ## What a booking needs to succeed Ophelia fills the provider's real reservation form with the `customer` you send and the provider verifies that person by SMS or email. Placeholders are refused before anything runs (400 placeholder_customer). Send the real end user, exactly as below. - customer.name: the end user's REAL first and last name in one string, e.g. "Maria Garcia". OpenTable refuses a one-word name (400 missing_last_name). Names like "Customer Name" or "Test User" are refused. - customer.phone_number: a REAL mobile number the end user is holding right now, E.164 with country code, e.g. "+12125551234". Dining providers text the verification code to THIS number. North American numbers in a 555 exchange are refused. - customer.email: the end user's real email. Mindbody and StubHub may send their code here; every provider sends confirmations here. Addresses at domains reserved for documentation or testing (RFC 2606) are refused. - party_size: required for dining (1–20) and entertainment (ticket quantity). Omit for fitness. - availability_id and datetime: the slot's own values from /v1/availability/search, passed back verbatim (never `display_time`). A different datetime is refused with 400 slot_not_offered; an availability_id is good for 15 minutes. - venue_id: a `ven_` id returned by search. Never invent one (404 "venue not found; run venues/search first"). - Idempotency-Key header: unique per booking. Keys are per organization and never expire; the same key returns the booking it first created, whatever the body says. Flights ignore it. - metadata: only what the vertical asks for — see Verticals. - Client timeout above 150 s: create and /continue are held up to 150 s and return the next step with 200 (202 if still processing). ## OTP — how the code actually flows 1. The booking returns status `requires_action`, next_action.type `otp`, next_action.expires_at (currently 5 minutes from the pause; read it, never hard-code it), and next_action.requirements: the exact fields /continue expects ([{field, label, type, required}]). Build the /continue body from that list. 2. The provider (not Ophelia) has already sent the code: by SMS to customer.phone_number (dining), usually by email (Mindbody), to the buyer's phone or email (StubHub). Ophelia never sees or forwards the code. 3. Your app must prompt the END USER for the code they just received and POST it: `POST /v1/bookings/{id}/continue` with `{ "otp_code": "123456" }`. 4. Read the response's status again. `confirmed` means done. `requires_action` means another step. Never assume one /continue finishes it. 5. A wrong code ends in `failed` (e.g. not_confirmed_after_otp). There is no resend — create a new booking. A booking left in requires_action for more than 20 minutes becomes `expired` (error_code action_expired). 6. /continue on a booking that is not `requires_action` returns 200 with the booking unchanged — a 200 never means the code was accepted; read status. Practical consequence: you cannot complete a booking without a live human (or their phone) in the loop. Relay the code before next_action.expires_at. Exception: a Resy booking for an end_user with an active session texts no code. ## Verticals - dining / OpenTable: a dining search without `providers` searches OpenTable only. Card in metadata.payment (card_number, exp_month, exp_year, cvv, name_on_card, postal_code) unless availability with check_card_required: true answers card_required: false; without it a venue that needs one, or whose policy is unknown, fails with missing_card. Entered only if the venue's form asks; never charged. Verifies by SMS. Modify and cancel supported. - dining / Resy (`"providers": ["resy"]`): no Resy password. Resy texts a code to customer.phone_number; if that phone already has a Resy account, customer.email must be that account's email (account_email_mismatch). Some venues need a card: when availability says card_required: true, send metadata.payment (same shape as OpenTable) or use AgentCard, else the booking fails with card_required. Send end_user to keep and reuse the person's Resy sign-in. Confirmed Resy bookings cannot be cancelled through Ophelia. - fitness / Mindbody: metadata.payment with full billing address, charged up front; missing state/country fails with missing_billing_fields. metadata.password is optional: without it Mindbody sends the member a code (usually by email) and a new email gets a new account. Omit party_size. - entertainment / StubHub: party_size = ticket quantity. Search returns listings in metadata.sections; pass metadata.listing_id when booking. The checkout can stop twice: `{ "otp_code" }` (code to phone or email), then a top-level `{ "payment": {...} }` with full billing address — or send the card up front in metadata.payment. - travel: name the provider in `providers`: ["hotels"] or ["flights-com"]. - travel / Hotels: no OTP. Search and availability take check_in, check_out (dates), rooms (default 1), and party_size (total guests, default 2). Availability returns slots[] with kind "room" and an offer {label, price, amount, currency, cancellation, card_required}. Book with availability_id, datetime on the check_in day, customer, and metadata.postal_code; without a card this places nothing and returns requires_action / payment. /continue with the flat card (card_number, exp_month, exp_year, cvv, postal_code, name_on_card) returns requires_action / confirm; `{ "confirm": true }` places the reservation and charges the full total. A card in metadata.payment at create places and charges it in one call. Answer each step within 5 minutes (session_gone after 10). No modify; a confirmed reservation cannot be cancelled through Ophelia. - travel / Flights (`"providers": ["flights-com"]`, flights.com via Expedia — the only flights provider live; ["flights"] is not registered): no OTP. Search takes origin, destination (city = metro area, airport code = that airport), date, optional return_date, cabin, party_size, children, direct_only at the root; multi-city uses flight.flights[]. The returned venue IS the route; metadata.resolved says which airports were used. Availability needs only venue_id and returns itineraries; book with slots[].itinerary.itinerary_key, not availability_id. POST /v1/bookings (venue_id, metadata.itinerary_key, travellers[] each with first_name, last_name, gender, birth_day, birth_month, birth_year, phone, plus email on the first) drives the checkout and parks at the pay screen: 202, object flight_checkout, id fchk_…, and it buys nothing. /continue with `{ "card": { number, expirationDate "MM/YY", cvc, name }, "confirm": true }` buys the ticket; once the card is in, any answer without confirm: true ends the checkout. - Flights money rules: always send metadata.insurance ("decline" or "accept"), because omitting it buys trip protection. Always send metadata.max_total, the only cap on the purchase; over the cap, the checkout parks (reason over_max_total, with payable_total and max_total) and nothing is pressed. A paid seat needs metadata.seat_max_price. Send the full metadata.billing (street, city, state, zipcode) or none. metadata.hold true keeps the checkout answerable for 10 minutes but blocks other flights calls. Send an Idempotency-Key: a retry with the same key and body returns the first checkout. If /continue returns pressed: true with outcome_known: false, or any non-2xx after confirm: true, STOP: never retry, because a second press can buy a second ticket. Flights cannot be cancelled or modified through Ophelia (cancellation_unsupported / modify_unsupported). No refund. 3-D Secure after the press comes back as an unknown outcome (reason verify_outcome) and there is no card vault on this path, so use test cards for now. Calls take 30–90 s each; set client timeouts above 300 s. ## Availability - POST /v1/availability/search: body venue_id (required), party_size (default 2), datetime (default 24 h from now), optional window_minutes and check_card_required (true → response includes card_required when known). Returns slots[] with availability_id, kind (time, room, itinerary), datetime (venue-local, with offset), datetime_utc, display_time, status, price_required, deposit_required. ## Datetime semantics - `datetime` fields you send are the venue's local wall-clock time. A timezone suffix is converted to local; no suffix is treated as already local. A past date on search or availability moves to today at the same time; a past dining booking datetime is refused. ## Booking Lifecycle - processing: Work underway; poll GET /v1/bookings/{id} - requires_action: End user must act; read next_action.type (otp, payment, confirm, or payment_authorization) and next_action.expires_at - confirmed: Booking complete — the only status you may report as success; confirmation.confirmation_code has the provider's code - failed: Read error_code + error_message on the booking. Fix customer data: otp_not_sent, otp_not_received, not_confirmed_after_otp, account_email_mismatch, logged_in_not_booked, reconnect_required, account_exists, mindbody_social_login. Fix card: missing_card, credit_card_required, card_required, card_declined, missing_billing_fields, payment_failed, checkout_incomplete. Pick another slot: no_availability, party_size_unavailable, date_unavailable, class_unavailable, session_gone. Transient, retry: akamai_blocked, blocked, waf_blocked, captcha_required, cloudflare_blocked, timeout. AgentCard: payment_declined, payment_approval_expired, payment_setup_failed, payment_replay_failed; check the venue first after payment_outcome_unknown. - cancelled: Cancelled via /cancel - expired: Left in requires_action for more than 20 minutes (error_code action_expired); nothing was booked ## Payments: AgentCard (agentcard_external) The end user pays with a card from their AgentCard vault and approves with their passkey. Card number and CVV never reach you or Ophelia. Guide: https://www.opheliaos.com/docs/payments/agentcard - v1 only, Resy and OpenTable, live or test keys. When a venue cannot be paid through AgentCard, create returns 409 vault_unavailable and nothing is created; offer a different venue or time. - POST /v1/bookings with "payment_collection": "agentcard_external" (spelled exactly; an unrecognized value is treated as no card). No card fields. Optional metadata.billing_postal_code, billing_address_line1, billing_city, billing_state, billing_country. - Order differs: Resy asks for the OTP first, OpenTable asks for payment_authorization first. Branch on next_action.type every time. - At the card step: status requires_action, next_action.type payment_authorization, with expires_at (15 minutes, binding), agentcard_request, requirements. Add user (your AgentCard user id) to agentcard_request and POST it to AgentCard POST /api/v2/checkout/authorizations with your org token. Send the end user the returned approvalUrl. Poll until approved. - On approved: POST /v1/bookings/{id}/continue with { "psp_response": }. To abandon: { "psp_abort": true }; the booking fails with payment_declined. A 400 on /continue carries only an error message: read the booking (malformed body = still paused; late answer = failed with payment_outcome_unknown or payment_approval_expired). - Real bookings need AgentCard production credentials and a real card. ## Sessions, webhooks, errors - Sessions: connect a person's own Resy account once (POST /v1/sessions, then /verify with the texted code within 5 minutes); later bookings with the same end_user run signed in as them with no code. GET /v1/sessions/{id}/card answers has_card. Live keys only (403 live_key_required). - Webhooks: sent to your organization's webhook URL (set in the dashboard); booking.created, booking.requires_action, booking.processing, booking.confirmed, booking.failed, booking.cancelled, booking.modified. Signed with Ophelia-Signature (t=…,v1=HMAC-SHA256 of "."). At least once, possibly out of order; retried after 30 s, 2 min, 10 min. - Errors: `error` is always present; branch on `error_code` where set. Rate limits per organization per 5 minutes: search 25, availability 60, bookings 10, continue 20, get 300, cancel 30, session connect/verify 20, session card 30 (429 rate_limited with Retry-After). ## v3 (beta) v3 is a separate surface: instead of integrated providers, a real browser drives any public website (search, fill, press the final button). It is beta-only and irreversible. - Base https://api.opheliaos.com/v3. Auth `Bearer oph_beta_…` (beta keys only, requested from the dashboard's API Keys page). A beta key on /v1, or a live/test key on /v3, returns 403 wrong_key_environment. - Scopes: v3:search (search, availability), v3:book (bookings, get, continue, abandon), v3:sites:read (GET /v3/verticals, also open to v3:search and v3:book), v3:act (checked when a read asks for commit; the read is still refused 409 commitment_refused). - Endpoints: POST /v3/search (site, vertical, term, location, datetime, party_size → entities[] with ent_ ids and booking_url); POST /v3/availability (entity_id or url → offers[] with off_ ids that expire in 15 min); POST /v3/bookings (entity_id or url, optional offer_id, customer, card with full billing address, Idempotency-Key → 200 succeeded with bk_ id and confirmation_number, or 202 requires_action with attempt_id); GET /v3/bookings/{booking_id}; POST /v3/bookings/{attempt_id}/continue ({ "code" }); POST /v3/bookings/{attempt_id}/abandon; GET /v3/verticals (the ask shapes: drive profiles such as dining, hotels, flights); GET /v3/ (index). - There is no site catalog or allowlist: any public site can be driven; addresses not on the public internet are refused (409 private_address). authorize.max_commitment is read | begin | commit; bookings default to commit, meaning the final button is pressed. Send datetime in the venue's own offset. - No cancel and no modify: both answer 501 not_implemented_in_v3. Store confirmation_number, which is the only route to a manual cancel. /abandon releases a paused session and is not a cancel. - Statuses: succeeded 200, processing 200, requires_action 202, needs_input 422 (missing[] lists what to collect), failed 4xx/5xx. On failure, failure_class retryable_elsewhere is safe to retry; terminal means stop. - A paused drive with resumable: true: POST the code to continue_with within 10 minutes. With resumable: false: re-send the request with authorize.authorized_for set to the returned string. - Always send an Idempotency-Key; it is the only defence against a double booking. Check slot_source on availability before trusting the day. - Rate limits per org per 5 min: search 25, availability 60, bookings 10, continue 20, get 300 (abandon, cancel, modify share it), GET /v3/ and /v3/verticals 300. ## Docs pages - Introduction: https://www.opheliaos.com/docs - Quick start: https://www.opheliaos.com/docs/quick-start - Authentication: https://www.opheliaos.com/docs/authentication - Availability: https://www.opheliaos.com/docs/availability - Testing (practice venues): https://www.opheliaos.com/docs/testing - Endpoint reference: https://www.opheliaos.com/docs/endpoints - Dining: https://www.opheliaos.com/docs/dining (OpenTable: /docs/dining/opentable, Resy: /docs/dining/resy) - Fitness: https://www.opheliaos.com/docs/fitness - Entertainment: https://www.opheliaos.com/docs/entertainment - Travel: https://www.opheliaos.com/docs/travel (Hotels: /docs/travel/hotels, Flights: /docs/travel/flights) - Required fields: https://www.opheliaos.com/docs/required-fields - Booking lifecycle: https://www.opheliaos.com/docs/booking-lifecycle - Sessions: https://www.opheliaos.com/docs/sessions - Payments (AgentCard): https://www.opheliaos.com/docs/payments/agentcard - Security & compliance: https://www.opheliaos.com/docs/security - Errors: https://www.opheliaos.com/docs/errors - Webhooks: https://www.opheliaos.com/docs/webhooks - v3 beta: https://www.opheliaos.com/docs/beta (requires a signed-in account; Verticals: /docs/beta/verticals) For the complete implementation contract, request https://www.opheliaos.com/docs.md.