OpheliaDocs
Search docs⌘K
DashboardCreate an account
Verticals · Travel

Flights

A route is the venue · the irreversible press is on /continue · test cards only

Flights is not at parity with Hotels, and the difference is the card. There is no Agentcard Vault on this path: a card sent to /continuetravels in the request body, through the API process, to the provider's runner. Hotels parks the processor request so a number never passes through Ophelia's code; flights has no equivalent yet. 3-D Secure is mounted on every checkout measured and cannot be answered here — a challenge after the press comes back as an unknown outcome, and a card can be charged with no confirmation coming back. Use test cards until the Vault covers this path.
Practise in Ophelia Test City first. A route with Ophelia Test City at either end runs this same checkout against a pretend airline: the held fchk_ checkout, the card, the press, over_max_total and verify_outcome, with no ticket bought and nothing billed. Test cards there trigger a decline, 3-D Secure and an unknown outcome on purpose. Everywhere else, a test key buys a real ticket. See Testing.

Flights is the one vertical whose venue is not a place. POST /v1/venues/search takes the route in the same envelope every other vertical uses — origin and destination at the root, where a stay puts check_in and rooms — and answers with the route as a venue, whose metadata.resolvedis the only place the API reports how a place was interpreted — asking for "New York" takes the metro area covering EWR, JFK and LGA, and nothing else tells you that. POST /v1/availability/search then returns itineraries as slots, each carrying a stable itinerary_key; that key, not availability_id, is what you book with. POST /v1/bookings drives the real checkout and stops in front of the pay control without pressing it — it answers 202, not 201, because nothing was created. The irreversible press lives on /continue.

Each call says only what the one before it did not. The route is saved as a venue when you search it, so availability needs the venue_id and nothing else — not the day, not the party — and a booking needs it plus the itinerary you picked. Anything you state explicitly still wins: availability takes a different datetime or party_size, and a booking a different party_size, cabin or children. To change the route itself, search it again.

Use these commands

curl
# 1. The route, as a venue. Name the provider: "flights-com" is the only flights
# provider live today.
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-10-20",
"party_size": 1,
"cabin": "ECONOMY"
}'
# → venues[0].id = "ven_ff91fdf3"
# → venues[0].metadata.resolved says WHICH airports that meant
# A round trip adds "return_date". Multi-city uses the `flight` block; see below.
curl
# 2. Availability against that venue — the id, and nothing else. The venue IS the
# route on a day (the date is part of its identity), and it remembers the party
# it was searched for. Send datetime or party_size only to change them.
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[].itinerary.itinerary_key — the stable id. Book with THIS.
# availability_id is regenerated on every search; itinerary_key is not.
curl
# 3. Optional, and the question a stay never has to ask: is the flight I quoted
# still there, at that price? The price check is answered ONLY on the route
# form — a `flight` block instead of venue_id. With venue_id, quoted_price is
# ignored and no `requested` block comes back; itinerary_key (plus "fare")
# there adds that flight's seat map to its slot instead.
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-10-20",
"adults": 1
},
"itinerary_key": "1_F91659.EWR20261020",
"quoted_price": 74.98
}'
# → requested: { still_offered, price, quoted_price, price_changed, difference, summary }
curl
# 4. Drive the checkout and park in front of the pay control. This BUYS NOTHING
# and answers 202. The route is NOT restated — it comes from the venue. Note
# insurance and max_total: see the two callouts below, because the defaults
# for both are the expensive ones. Send an Idempotency-Key so a retried POST
# gets this checkout back instead of driving a second one.
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.EWR20261020",
"insurance": "decline",
"max_total": 80.00,
"seat": "cheapest",
"seat_max_price": 20,
"hold": true
}
}'
# → 202 "status": "requires_action", id "fchk_…", parked at the payment screen
The booker fills the traveller gaps

The checkout asks the first traveller for an email and a phone. If you put them in customer — where every other vertical puts them — they are copied onto traveller one and nowhere else, because traveller two is a different person. Send them on the traveller instead and yours are used.

The next call presses a button that cannot be un-pressed. confirm: true buys the ticket. At the payment park, confirm: false (or omitted) fills the card into the payment form and parks again in front of the control, which is how you check the total before committing. Once the card is in, any answer without confirm: true ends the checkout (not_confirmed) and it cannot be resumed. There is no modify and no refund path on this vertical, and /cancel must not be called on a flight booking: see Modifying or cancelling at the foot of this page.
curl
# 5. Answer the parked checkout. The card and the press are ONE reply: the runner
# reads both off the same line. Card shape here is nested — not hotels' flat
# card_number / exp_month / exp_year / cvv. The booking above sent no card, so
# it parked at the payment screen and this answer MUST carry one.
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.
Trip protection is taken unless you decline it

The runner's own default is to accept the checkout's insurance offer. Measured on 2026-09-23, a $74.98 fare became $93.98 — a $19.00 Flight Protection Plan, per traveller — and the only report of it was a log line and the insurance block on the response. Send insurance explicitly on every booking, and compare checkout_totalagainst the trip price you quoted before you press. The checkout will not submit until one or the other is set, so there is no "leave it alone" option — only a chosen one and an assumed one.

max_total is the only thing that caps the press

Send it. Without a ceiling confirm: true pays whatever the screen says, and the screen moves after the card step — which is exactly how the $74.98 above became $93.98. The total is re-read immediately before the press and a breach parks rather than fails: both numbers come back, the checkout stays answerable, and you raise the ceiling with the total in front of you or let it expire. A ceiling given and a total that cannot be read refuses rather than pressing blind: nothing is pressed and the checkout ends with total_unreadable. On POST /v1/bookings, absent and 0 both mean no limit. On /continue, 0 is a ceiling of $0 and parks anything that costs money.

Send a complete billing address, or none at all

With no billingblock the runner fills its own test address — 1 Market Street, San Francisco — and reports nothing. That is harmless under a test PAN and a decline waiting to happen under a real card, because AVS checks this against the address the cardholder's bank holds. A half-filled address is worse than none: the fallback is at least internally consistent, while a mix of the two matches nothing. Send all of street, city, state and zipcode or omit the block.

hold: true costs every other flights call while it waits

The flights provider runs at most two browsers at once, each on its own profile. A held checkout keeps one of them until it is answered or the session expires (10 minutes): one hold halves flights capacity, and two make every other flight call — searches included — queue until one is released. Held checkouts live in the API process's memory and do not survive a restart. Ask for it only when you intend to come back and answer: without it the browser closes when the drive ends, session_held comes back false, and the checkout cannot be continued at all.

Send an Idempotency-Key

A repeat of the same POST /v1/bookings with the same Idempotency-Key gets the first answer back (same fchk_ id, header Idempotent-Replayed: true) instead of driving a second checkout; one sent while the first is still driving waits for it. The key is remembered for as long as the checkout is held, and only a request that started a checkout is remembered, so a retry after an error runs again. The same key with a different body is a new request. Without a key, every POST drives a new checkout.

Run it against your own key

Type a route and the first step's body is built from it; search and availability run on their own, and the checkout drive waits for an explicit click. Every call here is real, costs a browser drive against the live site, and is billed — but none of them can buy anything: /continue is deliberately not in this playground, so there is no press to click. Read the warnings above first; they are the ones that cost money on the call that is here.

V1 · Flights
Enter to send · Shift+Enter for new line

POST /v1/venues/search

Resolves a route into a venue every later call is addressed to. There is no term or location here. A one-way or a return is sent at the request root, as the fields below are; a multi-city trip goes in a flight object instead (last rows). Sending root route fields and a flight object is refused rather than merged.

FieldTypeNotes
vertical
string
"travel". Filled in for you when a route is sent; send it anyway.
providers
string[]
Optional in the schema and always worth sending. ["flights-com"] (flights.com, via Expedia) is the only flights provider live. Unnamed, a route resolves to it by category. ["flights"] (booking.com) is not registered and answers 400 "none of the requested providers are registered".
trip_type
string
"oneway", "roundtrip" or "multicity". Optional — the shape already says it: a return_date means a round trip, two or more flights mean multi-city, neither means one-way. One that contradicts the shape is refused rather than resolved.
origin
string
required
A city or an airport code. A city means the whole metro area; metadata.resolved reports which. The origin is also geocoded first, and one that cannot be resolved answers 400 "could not resolve location".
destination
string
required
Same rule as origin: a city name searches the metro area, an airport code searches that airport.
date
date
required
Outbound day, 2006-01-02. Send datetime instead and its day is used. One or the other is needed — a route is never guessed a day.
return_date
date
Makes it a round trip, and must be after date. Required when trip_type is roundtrip.
cabin
string
ECONOMY, PREMIUM_ECONOMY, BUSINESS or FIRST. Empty means economy.
party_size
integer
Total travelling; absent means one adult. Adults are party_size − children, the way a stay counts guests across rooms.
children
integer
Travellers under 18. Must leave at least one adult: more children than party_size, or children with no adult, is refused.
direct_only
boolean
Nonstop itineraries only.
flight
object
The block form, and the only shape that can say a multi-city trip: flight.flights[], each {origin, destination, date}. It also takes the one-way and return fields above (origin, destination, date, return_date, trip_type, cabin, children, direct_only).
flight.adults
integer
Inside the block the party is adults (18+, zero means one), not party_size — a party_size inside it is ignored.
Response
json
{
"venues": [
{
"id": "ven_ff91fdf3",
"name": "New York-Orlando 2026-10-20",
"provider": "flights-com",
"bookable": false,
"metadata": {
"resolved": {
"from": {
"asked": "New York",
"asked_as": "place name",
"took": "NYC",
"kind": "metro",
"airports_in_answer": ["EWR", "JFK", "LGA"]
},
"to": {
"asked": "Orlando",
"asked_as": "place name",
"took": "ORL",
"kind": "metro",
"airports_in_answer": ["MCO"]
}
},
"wire_rows": 67,
"read_from": "wire"
}
}
],
"count": 1
}

Each end of resolved also carries a one-line why, and resolved.page_title is the site's own title for the search. metadata also reports how much of the answer this is — wire_rows, dom_cards, site_total — plus results_url and filters_available. bookable is false because a route is not a reservation; the itinerary is what gets booked. When nothing flies the route that day, the answer is 200 with no venues and a message, not an error.

POST /v1/availability/search

One endpoint answering three questions, depending on what you send with it. Which form you use matters: the seat map is answered on both, but the still-offered-at-that-price check (requested) only on the flight-block form.

FieldTypeNotes
venue_id
string
required
From the search response. Or send a flight block instead (same shape as at search, plus provider), skipping step 1: you lose metadata.resolved, and gain the requested check.
datetime
RFC 3339
The venue's own day is used when absent — a one-way or return venue carries its date in its identity. Send it to ask the same route about a different day. A multi-city venue's id carries no single day, so there it is still required (400 datetime_required).
party_size
integer
The number the route was searched for is used when absent.
itinerary_key
string
Opens that itinerary and puts its seat map on its slot (slots[].seat_map, or seat_message when it has no seat step); the other rows still come back. With a flight block it also adds a requested block.
quoted_price
number
flight-block form only, with a key: answers whether it is still offered at that price, and by how much it moved, compared against the trip total. Ignored with venue_id — compare slots[].itinerary.price yourself there.
fare
integer
With a key: prices the seat map for that fare (0 is the cheapest).
Response — with venue_id
json
{
"venue_id": "ven_ff91fdf3",
"slots": [
{
"availability_id": "avail_9c2f7b1a4e",
"kind": "itinerary",
"venue_id": "ven_ff91fdf3",
"party_size": 1,
"status": "available",
"itinerary": {
"itinerary_key": "1_F91659.EWR20261020",
"summary": "One way · New York (EWR) → Orlando (MCO) · Tue, Oct 20 · 1 traveller · Economy · Nonstop · $74.98",
"carriers": [{ "code": "F9", "name": "Frontier" }],
"price": { "display": "$74.98", "amount": 74.98, "currency": "USD", "basis": "trip" },
"price_per_traveller": { "display": "$74.98", "amount": 74.98, "currency": "USD", "basis": "traveller" },
"total_travel_time": { "minutes": 178, "display": "2h 58m" },
"source": "wire"
}
}
]
}
Added with a flight block, itinerary_key and quoted_price
json
{
"count": 67,
"provider": "flights-com",
"slots": [ … ],
"requested": {
"itinerary_key": "1_F91659.EWR20261020",
"still_offered": true,
"price": { "display": "$74.98", "amount": 74.98, "currency": "USD", "basis": "trip" },
"quoted_price": { "display": "$74.98", "amount": 74.98, "currency": "USD", "basis": "trip" },
"summary": "Still available at the price you quoted ($74.98)."
}
}

price_changed and difference appear only when the price moved; difference is now minus quoted. still_offered: false means the itinerary is no longer in the results — an answer, not an error.

price is the trip; price_per_traveller is one person

On an itinerary read from the site's own response (source: "wire"), price is the trip total (basis: "trip") and price_per_traveller is the per-person fare (basis: "traveller"). One read off the page instead (source: "dom") carries only price_per_traveller. With more than one traveller, reading price_per_traveller as the total understates it by a factor of the party size.

POST /v1/bookings

Drives the provider's own checkout — travellers, seat, insurance, billing, card — and stops in front of the pay control. It answers 202 and creates no booking. The body is the envelope every other vertical uses: venue_id, party_size, customer and travellers at the root, and the flight-specific fields under metadata, as the table shows — a metadata.itinerary_key is what marks it a flight booking. The runner refuses before a browser opens if a traveller is incomplete, so validate first: a missing date of birth costs you a minute otherwise.

A flight object is accepted instead, carrying the same fields flat — flight.itinerary_key, flight.insurance, flight.travellers and so on, with no metadata inside it — plus the route fields (origin, destination, date, flights, adults) when there is no venue. It has no customer, so put the email and phone on traveller one.

FieldTypeNotes
venue_id
string
required
The route, from the search. Origin, destination, date, trip type, cabin and children all come from it. In this envelope, party_size, cabin and children at the root override it; to change the route or the day, search it again (or use the flight object).
metadata.itinerary_key
string
required
Which flight, from the availability response — and what marks this a flight booking. Not availability_id, which is regenerated every search.
customer
object
name, email, phone_number. Fills traveller one's email and phone when they are absent.
party_size
integer
Overrides the number the route was searched for.
travellers
object[]
required
One per seat. Each needs first_name, last_name, gender, birth_day, birth_month, birth_year and phone; the first also needs email.
metadata.insurance
string
"accept" or "decline". Optional, and omitting it is a purchase. The checkout will not submit until one is set, so if you omit it the runner chooses — and its choice is to take the plan.
metadata.billing
object
All of street, city, state, zipcode — or omit entirely. AVS is checked against it. A country is not filled.
metadata.fare
integer
Which fare from the itinerary's fare list to take, as on availability (0 is the cheapest, and the default). A number or a numeric string.
metadata.max_total
number
The ceiling on the press, checked against the site's own total immediately before it. Over it, the checkout parks with both numbers rather than paying. Absent or 0 means no limit.
metadata.seat_max_price
integer
The most a paid seat may cost, in whole dollars (a fraction is dropped). Required for any seat that is not free — without it a seat that costs anything is refused, and the refusal names the price.
metadata.seat
string
"free", "cheapest", or a seat id. Absent means none is chosen and the airline assigns one — on a basic fare every selectable seat costs money, so there is no safe default.
metadata.hold
boolean
Keeps the checkout answerable at /continue for ten minutes, and blocks other flights calls for the duration.
metadata.card
object
Omit it and the checkout parks at the payment screen (the response below), to be answered at /continue with a card. Sent, it is filled here and the checkout parks one press from paying. Any of number, expirationDate, cvc you leave out is filled from a test card, so send all three. See the Vault warning at the top of this page.
confirm
boolean
Not honoured here: the adapter holds it false on this call, so a request sending true still comes back parked. The press is on /continue.
Response
json
{
"object": "flight_checkout",
"id": "fchk_9c2f7b1a4e",
"status": "requires_action",
"provider": "flights-com",
"itinerary_key": "1_F91659.EWR20261020",
"message": "the payment screen is reached and needs a card",
"session_held": true,
"session_expires_at": "2026-10-20T18:56:19Z",
"checkout_url": "https://www.expedia.com/Checkout/V1/FlightCheckout?tripid=…",
"checkout_total": { "total_text": "$74.98" },
"quoted_price": 74.98,
"price_drift": 0,
"filled": [
"t0.first_name", "t0.last_name", "t0.phone", "t0.birth_month",
"t0.birth_day", "t0.birth_year", "t0.gender", "booker.email",
"billing.street", "billing.city", "billing.zipcode", "billing.state"
],
"insurance": { "changed_by_us": true, "declined": { "text": "No protection" } },
"three_ds": true
}

The body also carries pay_control(the button's own label, once the card is in) and card_filled. reason says why it parked: payment (send a card), confirm (the card is in; one press from paying) or over_max_total (over your ceiling). Without hold: true there is no id, session_held is false and a note says the browser has closed.

quoted_price here is one traveller's fare

On the checkout, quoted_price is the per-traveller fare of the row you picked, and price_driftis the checkout total minus that. With one traveller the drift is the add-ons; with more, it also includes every other traveller's fare. Compare checkout_total against the trip price from availability instead.

filled names fields, not values

A billing.* entry in filled means the runner typed something into that input — it reads identically whether the address was yours or the test fallback. Do not treat it as confirmation that your billing block was used.

POST /v1/bookings/{id}/continue

Answers a checkout that parked, addressed by its fchk_ id. The card and the press ride on one request: the runner reads both off the same line. At the payment park, a card without confirm fills the payment form and parks again in front of the control. Once the card is in, an answer without confirm: true ends the checkout. This is where flights diverges from Hotels twice over — the card object is nested rather than flat, and the request is not itself the charge unless confirm is true. An id that was already answered, or whose session expired, cannot be resumed.

FieldTypeNotes
card.number
string
Required when the checkout parked for payment (the booking sent no card). Answering that park without a card ends the checkout with no_card_supplied. Any of number, expiry or CVC left out is filled from a test card.
card.expirationDate
string
MM/YY. Note the shape: hotels uses flat exp_month / exp_year.
card.cvc
string
Hotels calls this cvv.
card.name
string
Name on the card. The site usually prefills it from the traveller; it is typed only when what is there does not match.
confirm
boolean
true presses the pay control and buys the ticket. False or absent: at the payment park, fills the card and parks; at any later park, ends the checkout with not_confirmed.
max_total
number
Raises or sets the ceiling for this answer. This is how an over_max_total park is cleared: send a higher one with confirm: true. Absent leaves whatever the booking set; 0 here is a $0 ceiling.
Response fields that decide what you do next
FieldTypeNotes
status
string
"succeeded" (HTTP 200) when the site confirmed. "requires_action" (202) when it parked again, or when the press went out and the outcome is unknown. "failed" (502) with error_code, e.g. not_confirmed.
pressed
boolean
Present only on the unknown-outcome answer, as true beside outcome_known: false. Absent on a confirmed answer.
outcome_known
boolean
pressed: true with outcome_known: false is the one state that must never be retried — see below.
booking_reference
string
Present when the site confirmed: the provider’s own reference.
booking_id
string
Present on an answer to confirm: true: bkg_fchk_…, a booking record readable at GET /v1/bookings/{id}. Do not cancel it — see the foot of this page.
checkout_total
object
What the screen says is payable. Compare against the trip price you quoted; price_drift is measured from the per-traveller quoted_price.
three_ds
boolean
True on every checkout measured. Unhandled: a challenge cannot be answered through this API.
If pressed is true and outcome_known is false, stop. The button was pressed and the site did not answer. A second press can buy a second ticket. Open checkout_url, or the airline's own records, and establish what happened before issuing any further call against that checkout. The API cannot tell you — that is what the field is confessing. A 3-D Secure challenge after the press comes back this way too, as a 202 with reason: "verify_outcome" and bank_challenge: "3ds". Any non-2xx after confirm: true, or a dropped connection, is an unknown outcome too; do not retry. Read the booking record (GET /v1/bookings/{booking_id}): it stays requires_action with error_code: "outcome_unknown" until someone has checked.

When the total is over your ceiling

The checkout parks instead of paying, and stays answerable. Nothing was pressed. The response carries reason: "over_max_total", with payable_total and max_total.

json
{
"object": "flight_checkout",
"provider": "flights-com",
"status": "requires_action",
"message": "the checkout totals $93.98 and max_total is $80.00 — the pay control was NOT pressed. Answer with a higher max_total and confirm to proceed, or leave it to expire.",
"payable_total": 93.98,
"max_total": 80.00,
"id": "fchk_9c2f7b1a4e",
"session_held": true,
"checkout_total": { "total_text": "$93.98" },
"quoted_price": 74.98,
"price_drift": 19.00
}

Answer it at /continue with a higher max_total and confirm: true to proceed, or send nothing and let the session expire. An answer without confirm: true ends the checkout. The difference between quoted_price and payable_total is usually trip protection.

Confirmed — the shape the code returns
json
{
"object": "flight_checkout",
"provider": "flights-com",
"status": "succeeded",
"booking_reference": "7301234567890",
"booking_id": "bkg_fchk_9c2f7b1a4e",
"pay_control": "Complete Booking",
"checkout_total": { "total_text": "$74.98" },
"quoted_price": 74.98,
"price_drift": 0,
"three_ds": true
}

Built from the API's response code, not from a captured production run: no successful /continue body has been captured and redacted for publication yet. The values are illustrative; the field names and the 200 are what the code sends.

Errors

Two shapes. Checks the API makes itself answer with error_code. Failures from the browser drive answer with code (not error_code), retryable, and sometimes hint and problems. A drive failure on a held checkout ends it.

CodeHTTPMeaning
placeholder_customer
400
error_code. The customer or a traveller is a placeholder — a reserved email domain such as example.com, a North American number in the 555 exchange (212-555-…), or a label like "Customer Name". field names which.
venue_not_usable
400
error_code. The venue_id is not on file, or is not a flight route.
itinerary_key_required
400
error_code. The booking named no itinerary.
travellers_required
400
error_code. Fewer travellers than people in the party.
datetime_required
400
error_code. Availability on a multi-city venue without a datetime.
not_confirmed
502
error_code, with status: "failed". An answer without confirm: true after the card was filled. Nothing was pressed; the checkout has ended.
blocked
503
The site served a challenge. Retryable.
timeout · no_output
503
The drive did not finish. Retryable.
missing_traveller · missing_travellers
502
A traveller lacks a required field, or the party has too few. Refused before the browser opens; fix the request.
itinerary_gone
502
The itinerary is no longer offered. Search again.
no_card_supplied
502
The payment park was answered without a card.
total_unreadable
502
A max_total was set and the checkout total could not be read. Nothing was pressed.
payment_declined
502
After the press: the site said the payment was declined.
ask_not_honoured
502
The site searched something other than what was asked; problems says what. Not retryable.
checkout_not_reached · card_fill_incomplete · no_pay_control · runner_error · …
502
Other drive failures, each explained in error. Not retryable unchanged.
availability_failed
502
error_code. Failures outside that list — among them a /continue on an id that was already answered or has expired, and a held checkout refused because too many are already held.

These are the shapes of availability, bookings and /continue. POST /v1/venues/search reports a failed drive differently: 502 with error_code: "search_failed" and the provider's message in providers_failed. Nothing flying that day is not an error: search answers 200 with no venues, and availability with no slots.

What a run costs in wall-clock

Every call here drives a real browser against a live site. Measured against production on 2026-09-23:

CallMeasuredNotes
POST /v1/venues/search
27–38s
The route, resolved.
POST /v1/availability/search
35–43s
67 itineraries on the measured route.
…with itinerary_key + fare
48s
Opens that flight and walks to its seat step.
POST /v1/bookings
81–87s
Checkout reached around 78s in.
POST /…/continue
37.5s / 187s
37.5s when the site answered; 187s once when it did not, ending in an unknown outcome. A continue that sits for three minutes is the shape of a press you will not learn the result of.
Set your client timeout above 300 seconds

A serverless default will kill the drive halfway and leave a checkout open on the provider with nothing on your side holding its id. On Vercel a hobby plan caps a function at 10 seconds; the reference implementation declares maxDuration = 300 on every flights route.

Modifying or cancelling a booking

Flights cannot be cancelled or modified through Ophelia. A press writes a booking record (bkg_fchk_…). POST /v1/bookings/{id}/cancel on it returns the record unchanged with error_code: "cancellation_unsupported" and sends no webhook; /modify is refused the same way (modify_unsupported).
No modify, cancel or refund for flights

There is no way to change, cancel or refund a flight through the API. A ticket bought by confirm: true is changed or refunded through the airline or the travel agent that issued it, under their fare rules — which for a basic economy fare usually means it cannot be. Treat the press as final, because on your side it is.