Flights
A route is the venue · the irreversible press is on /continue · test cards only
/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.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.
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
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.
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.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.
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.
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.
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.
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.
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.
vertical"travel". Filled in for you when a route is sent; send it anyway.providers["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"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.originmetadata.resolved reports which. The origin is also geocoded first, and one that cannot be resolved answers 400 "could not resolve location".destinationorigin: a city name searches the metro area, an airport code searches that airport.date2006-01-02. Send datetime instead and its day is used. One or the other is needed — a route is never guessed a day.return_datedate. Required when trip_type is roundtrip.cabinECONOMY, PREMIUM_ECONOMY, BUSINESS or FIRST. Empty means economy.party_sizeparty_size − children, the way a stay counts guests across rooms.childrenparty_size, or children with no adult, is refused.direct_onlyflightflight.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.adultsadults (18+, zero means one), not party_size — a party_size inside it is ignored.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.
venue_idflight block instead (same shape as at search, plus provider), skipping step 1: you lose metadata.resolved, and gain the requested check.datetimedatetime_required).party_sizeitinerary_keyslots[].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_priceflight-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.fareprice_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.
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.
venue_idparty_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_keyavailability_id, which is regenerated every search.customername, email, phone_number. Fills traveller one's email and phone when they are absent.party_sizetravellersfirst_name, last_name, gender, birth_day, birth_month, birth_year and phone; the first also needs email.metadata.insurance"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.billingstreet, city, state, zipcode — or omit entirely. AVS is checked against it. A country is not filled.metadata.fare0 is the cheapest, and the default). A number or a numeric string.metadata.max_total0 means no limit.metadata.seat_max_pricemetadata.seat"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/continue for ten minutes, and blocks other flights calls for the duration.metadata.card/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/continue.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.
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.
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.
card.numberno_card_supplied. Any of number, expiry or CVC left out is filled from a test card.card.expirationDateexp_month / exp_year.card.cvccvv.card.nameconfirmnot_confirmed.max_totalover_max_total park is cleared: send a higher one with confirm: true. Absent leaves whatever the booking set; 0 here is a $0 ceiling.status"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.pressedtrue beside outcome_known: false. Absent on a confirmed answer.outcome_knownpressed: true with outcome_known: false is the one state that must never be retried — see below.booking_referencebooking_idconfirm: true: bkg_fchk_…, a booking record readable at GET /v1/bookings/{id}. Do not cancel it — see the foot of this page.checkout_totalprice_drift is measured from the per-traveller quoted_price.three_dscheckout_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.
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.
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.
placeholder_customererror_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_usableerror_code. The venue_id is not on file, or is not a flight route.itinerary_key_requirederror_code. The booking named no itinerary.travellers_requirederror_code. Fewer travellers than people in the party.datetime_requirederror_code. Availability on a multi-city venue without a datetime.not_confirmederror_code, with status: "failed". An answer without confirm: true after the card was filled. Nothing was pressed; the checkout has ended.blockedtimeout · no_outputmissing_traveller · missing_travellersitinerary_goneno_card_suppliedtotal_unreadablemax_total was set and the checkout total could not be read. Nothing was pressed.payment_declinedask_not_honouredproblems says what. Not retryable.checkout_not_reached · card_fill_incomplete · no_pay_control · runner_error · …availability_failederror_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:
POST /v1/venues/searchPOST /v1/availability/search…with itinerary_key + farePOST /v1/bookingsPOST /…/continueA 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
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).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.