openapi: 3.1.0 info: title: LetsFG API version: 0.2.16 description: | Agent-native flight and hotel booking API. Hundreds of airlines with virtual interlining, plus real bookable hotel inventory — built for AI agents (OpenClaw, Perplexity Computer, Claude, Cursor, Windsurf) and developers. Hotels accept either a Developer API key (X-API-Key) or the free PFS token — the same card authorises both. Searching needs no card; booking needs one on file. Booking holds the full price on the connected card and captures it only once the hotel confirms; a failed booking releases the hold. There is no reservation fee, no deposit and no pay link. ## How It Works 1. **Register** — `POST /agents/register` returns your API key. No card needed. 2. **Connect a card** — `POST /agents/connect-payment` returns a one-time `connect_url`. Open it in a browser and save a card or Revolut Pay. **Nothing is charged to connect.** 3. **Search** — `POST /flights/search`. Every offer carries `booking_url` (a letsfg.co page for that exact offer) and `book_url` (the API route that books it). 4. **Book** — `POST /flights/book` with the `search_id`, the offer `id` and your passengers. The connected method is **held, not charged**; a LetsFG booking agent buys the ticket and the hold is captured only against a real airline PNR. A failed booking releases the hold. 5. **Poll** — `GET /flights/bookings/{booking_id}` until `terminal` is true (4–11 minutes). ## Pricing Flight search is **look-to-book**, not per search: **200 searches are free after every booking you make**, and booking resets the counter. Past that, searches come in blocks of 500 for $5.00 ($0.01 each) from your prepaid balance — `POST /agents/top-up`. Running out answers `402 search_allowance_exhausted` with the numbers. **There is no booking fee and no transaction fee.** LetsFG's margin is already inside the price on every offer the search returns, so the amount shown is the amount charged. ## Authentication Two paths: **PFS Bearer token** (free search + booking): one tap at , no card. The card is asked for at the first booking — a zero-amount Revolut setup, nothing charged. Obtained by connecting the MCP server at `https://letsfg.co/mcp`, or via `letsfg auth`. **Developer API key** (look-to-book search, direct booking, hotels): ``` pip install letsfg letsfg register --name my-agent --email you@example.com ``` > **Stripe was retired on 2026-09-08.** `/agents/setup-payment`, `/agents/hosted-checkout`, > `/agents/billing-portal` and the whole `/bookings/*` unlock-then-book lane now answer > `410 Gone` naming their replacement. Connect a Revolut method with > `POST /agents/connect-payment` and book with `POST /flights/book`. contact: name: LetsFG Support email: contact@letsfg.co url: https://letsfg.co license: name: MIT url: https://opensource.org/licenses/MIT servers: - url: https://letsfg.co/developers/api/v1 description: Production security: - ApiKeyAuth: [] components: securitySchemes: ApiKeyAuth: type: apiKey in: header name: X-API-Key description: "LetsFG API key (prefix: trav_)" schemas: FlightSearchRequest: type: object required: [origin, destination, date_from] properties: origin: type: string minLength: 3 maxLength: 3 description: "IATA airport or city code (e.g. LHR, LON, JFK)" example: "LHR" destination: type: string minLength: 3 maxLength: 3 description: "IATA airport or city code" example: "JFK" date_from: type: string format: date description: "Departure date (YYYY-MM-DD)" example: "2026-04-15" date_to: type: string format: date description: "Return date for round-trip (YYYY-MM-DD). Omit for one-way." adults: type: integer minimum: 1 maximum: 9 default: 1 description: "Number of adult passengers" children: type: integer minimum: 0 maximum: 8 default: 0 description: "Number of children (2-11 years)" cabin: type: string enum: [M, W, C, F] description: "Cabin class: M=economy, W=premium economy, C=business, F=first" max_stops: type: integer minimum: 0 maximum: 4 default: 2 description: "Maximum stopovers per direction" currency: type: string minLength: 3 maxLength: 3 default: "EUR" description: "3-letter currency code" example: "EUR" limit: type: integer minimum: 1 maximum: 100 default: 20 description: "Maximum number of results" sort: type: string enum: [price, duration] default: "price" FlightSearchResponse: type: object properties: search_id: type: string description: "Unique search identifier" passenger_ids: type: array items: type: string description: "Passenger IDs to use when booking (e.g. pas_0, pas_1)" total_results: type: integer description: "Total number of matching offers" offers: type: array items: $ref: '#/components/schemas/FlightOffer' FlightOffer: type: object properties: id: type: string description: "Offer ID. Pass it to POST /flights/book together with the search_id." example: "off_abc123" price: type: number format: float description: | Price per person, INCLUDING LetsFG's margin — this is the amount that gets charged, not a base the fee is added to later. There is no booking fee and no transaction fee. example: 189.50 currency: type: string example: "EUR" airlines: type: array items: type: string description: "Airlines operating the flights" example: ["Ryanair"] owner_airline: type: string description: "Primary operating airline" route: type: string description: "Route summary (e.g. STN → BCN)" duration_seconds: type: integer description: "Total journey duration in seconds" stopovers: type: integer description: "Number of stops" booking_url: type: string format: uri description: | A letsfg.co page for THIS offer — safe to show a person or open in a browser. Never a seller deep link: the seller link stays server-side and only LetsFG's booking agent follows it. example: "https://letsfg.co/en/offer/srch_123/off_abc123" book_url: type: string format: uri description: "The API route that books this offer on your connected method." example: "https://letsfg.co/developers/api/v1/flights/book" outbound: $ref: '#/components/schemas/FlightRoute' inbound: $ref: '#/components/schemas/FlightRoute' description: "Present only for round-trip searches" conditions: type: object properties: refund_before_departure: type: string enum: [allowed, allowed_with_fee, not_allowed] change_before_departure: type: string enum: [allowed, allowed_with_fee, not_allowed] starlink: type: string nullable: true enum: [confirmed_all, confirmed_some, likely_all, likely_some] description: >- Starlink in-flight Wi-Fi across the whole itinerary. 'confirmed_all' = every leg is on a subfleet the carrier has fully fitted; 'confirmed_some' = at least one leg confirmed and at least one leg with none; 'likely_all' / 'likely_some' = same, but for subfleets where installation is underway and incomplete. Per-leg detail is on each segment's `starlink`. Only present 'confirmed_*' to an end user as a fact. Omitted/null means no information for any leg. example: "confirmed_all" is_locked: type: boolean description: "Whether this offer has been unlocked" source: type: string description: "Data source (e.g. ryanair, amadeus, duffel)" FlightRoute: type: object properties: segments: type: array items: $ref: '#/components/schemas/FlightSegment' total_duration_seconds: type: integer stopovers: type: integer route_str: type: string description: "Human-readable route (e.g. LHR → BCN)" FlightSegment: type: object properties: airline: type: string flight_no: type: string origin: type: string destination: type: string departure: type: string format: date-time arrival: type: string format: date-time duration_seconds: type: integer cabin: type: string aircraft: type: string description: "Aircraft type as reported by the source, when known (e.g. 'Boeing 737MAX 8')" starlink: type: string nullable: true enum: [confirmed, likely] description: >- Starlink in-flight Wi-Fi on this leg. 'confirmed' = the carrier has fitted every aircraft of this type. 'likely' = installation on this type is underway but incomplete, so this airframe may not have it. Omitted/null means no information — NOT an absence of Wi-Fi. example: "confirmed" UnlockRequest: type: object required: [offer_id] properties: offer_id: type: string description: "Offer ID from search results" UnlockResponse: type: object properties: offer_id: type: string confirmed_price: type: number format: float confirmed_currency: type: string offer_expires_at: type: string format: date-time description: "Offer reserved until this time (30 min window)" booking_url: type: string format: uri description: "Direct booking link (if available)" BookRequest: type: object required: [offer_id, passengers, contact_email] properties: offer_id: type: string passengers: type: array items: $ref: '#/components/schemas/Passenger' contact_email: type: string format: email idempotency_key: type: string description: "Prevent double-booking on retry" Passenger: type: object required: [id, given_name, family_name, born_on, gender, title] properties: id: type: string description: "Passenger ID from search response (e.g. pas_0)" given_name: type: string family_name: type: string born_on: type: string format: date description: "Date of birth (YYYY-MM-DD)" gender: type: string enum: [m, f] title: type: string enum: [mr, mrs, ms, miss, dr] BookingResponse: type: object properties: booking_id: type: string booking_reference: type: string description: "Airline PNR / confirmation code" status: type: string enum: [confirmed, pending, failed] total_price: type: number format: float currency: type: string RegisterRequest: type: object required: [agent_name, email] properties: agent_name: type: string email: type: string format: email RegisterResponse: type: object properties: agent_id: type: string api_key: type: string description: "Your API key (prefix: trav_). Store securely — it is shown once." payment_ready: type: boolean description: "False until a Revolut method is connected via POST /agents/connect-payment." tier: type: string message: type: string AgentProfile: type: object properties: agent_id: type: string agent_name: type: string email: type: string payment_status: type: string enum: [active, inactive, pending] search_count: type: integer booking_count: type: integer created_at: type: string format: date-time payment: type: object description: "The connected Revolut method." properties: connected: type: boolean brand: type: string last4: type: string type: type: string description: "card | revolut_pay" developer_api: type: object properties: flight_search: $ref: '#/components/schemas/FlightSearchAllowance' FlightSearchAllowance: type: object description: "Where this account stands against the look-to-book allowance." properties: model: type: string example: "look_to_book" free_searches_per_booking: type: integer description: "Free searches granted after every booking." example: 200 searches_since_booking: type: integer searches_remaining: type: integer description: "Free allowance plus purchased blocks, minus what you have used." blocks_purchased: type: integer block_size: type: integer example: 500 block_price_cents: type: integer example: 500 ConnectPaymentResponse: type: object properties: status: type: string example: "connect_required" connect_url: type: string format: uri description: "Open in a browser to save a card or Revolut Pay. Nothing is charged." example: "https://letsfg.co/connect?dev=sess_abc123" expires_in_seconds: type: integer example: 3600 payment: type: object description: "The currently connected method, if any." message: type: string DeveloperPassenger: type: object description: | The same shape the MCP `book_flight` tool takes, field for field, so an integration built against either lane feeds the other. Only `given_name` is required by the schema — the server decides what an airline form still needs and answers `400 missing_fields` naming exactly those, before anything is charged. required: [given_name] properties: given_name: type: string description: "First/given name exactly as on the travel document" family_name: type: string description: "Surname exactly as on the travel document" no_family_name: type: boolean description: "True when the traveller genuinely has no surname" born_on: type: string description: "YYYY-MM-DD" gender: type: string description: "'m' or 'f' — the airline form's title is derived from it" email: type: string format: email description: "The lead passenger's email receives the airline confirmation" phone_number: type: string example: "+48501234567" phone_country: type: string description: "ISO-3166 alpha-2, e.g. PL" nationality: type: string description: "ISO-3166 alpha-2" address_line1: type: string address_line2: type: string address_city: type: string address_state: type: string address_postal: type: string address_country: type: string description: "ISO-3166 alpha-2 country of residence" passport_number: type: string description: "Optional. Asked for, never required." passport_country: type: string passport_expiry: type: string description: "YYYY-MM-DD" passport_issue: type: string description: "YYYY-MM-DD" passenger_type: type: string enum: [adult, child, infant] default: adult DeveloperBookRequest: type: object required: [search_id, offer_id, passengers] properties: search_id: type: string description: "`search_id` from the search response. An offer is bookable only inside its own search." offer_id: type: string description: "`id` of one offer from that search" passengers: type: array minItems: 1 maxItems: 9 items: $ref: '#/components/schemas/DeveloperPassenger' contact_email: type: string format: email description: "Where the confirmation goes. Defaults to the lead passenger's email." idempotency_key: type: string maxLength: 64 description: "Retrying with the same key returns the same booking instead of opening a second hold." want_seat: type: boolean default: false description: "Pause at the airline's seat map and ask (see the booking's `question`)." BookAccepted: type: object properties: ok: type: boolean booking_id: type: string state: type: string example: "authorised" dispatched: type: boolean held: type: object description: "What is HELD on the connected method — not taken." properties: amount: type: number currency: type: string card: type: object charged: type: number description: "Always 0 at this point. Capture happens only against a real PNR." offer: type: object poll_url: type: string format: uri message: type: string BookingStatus: type: object properties: booking_id: type: string state: type: string enum: [authorised, card_issued, booking_in_progress, awaiting_settlement, completed, failed, needs_attention] terminal: type: boolean description: "True for completed, failed and needs_attention." pnr: type: string currency: type: string held_amount: type: number charged_amount: type: number description: "Set once the hold is captured against a real PNR." card_last4: type: string failure_reason: type: string decline_reason: type: string updated_at_ms: type: integer offer: type: object question: type: object nullable: true description: | Present only while `booking_in_progress` and something needs you. `kind` is `seat`, `extra` or `price_change`; `round` must be echoed to the answer route. message: type: string BookingAnswerRequest: type: object required: [round] properties: round: type: integer minimum: 1 description: "Echo the `round` of the question you are answering. A stale round is refused." seats: type: array items: type: object description: "Seat choices for a `seat` question, one per passenger." confirm: type: boolean description: "Accept a `price_change` or `extra` question." skip: type: boolean description: "Decline the extra, or skip seat selection." LocationResult: type: object properties: iata_code: type: string name: type: string type: type: string enum: [airport, city] city: type: string country: type: string ErrorResponse: type: object properties: error: type: string message: type: string status_code: type: integer paths: /api/v1/agents/register: post: operationId: registerAgent summary: Register a new agent description: "Create an account and get an API key. No authentication required." security: [] tags: [Authentication] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RegisterRequest' responses: '201': description: Agent registered successfully content: application/json: schema: $ref: '#/components/schemas/RegisterResponse' '409': description: Agent name or email already registered /api/v1/agents/me: get: operationId: getAgentProfile summary: Get agent profile description: "View your account info, payment status, and usage stats." tags: [Authentication] responses: '200': description: Agent profile content: application/json: schema: $ref: '#/components/schemas/AgentProfile' '401': description: Invalid or missing API key /api/v1/agents/connect-payment: post: operationId: connectPayment summary: Connect a Revolut payment method description: | Returns a one-time `connect_url`. Open it in a browser — yourself, or hand it to whoever pays for this account — and save a card or Revolut Pay. The page saves the method for merchant use with a zero-amount order: **nothing is charged to connect**. The link is valid for one hour. `GET /agents/me` → `payment.connected` turns true once the method is saved. Connecting again replaces the previous method. A connected method is what opens flight search; it is also what bookings, top-ups and auto-refills are charged to, customer not present. tags: [Authentication] requestBody: required: false content: application/json: schema: type: object properties: return_url: type: string format: uri description: "Where to send the browser after the method is saved." responses: '200': description: Connect link minted content: application/json: schema: $ref: '#/components/schemas/ConnectPaymentResponse' '401': description: Invalid or missing API key '503': description: Card connection temporarily unavailable /api/v1/agents/setup-payment: post: operationId: setupPaymentRetired deprecated: true summary: "RETIRED — use POST /agents/connect-payment" description: | Retired on 2026-09-08 together with Stripe. Answers `410 Gone` with the replacement path. Kept in the spec so an older integration is told what to call instead of getting a 404. tags: [Authentication] responses: '410': description: "Gone. Call POST /agents/connect-payment." /api/v1/flights/search: post: operationId: searchFlights summary: Search flights description: | Search hundreds of airlines. City codes (LON, NYC, PAR) expand to every airport in that city. **Billing is look-to-book**: 200 searches free after every booking, then blocks of 500 for $5.00 from prepaid balance. A search that returns no offers is never metered. Requires a connected payment method (`POST /agents/connect-payment`) — a `402` names which condition failed (`payment_method_required` or `search_allowance_exhausted`). **Prices already include LetsFG's margin**, so the amount shown is the amount charged. Every offer carries `booking_url` — a letsfg.co page for that exact offer, safe to hand to a human — and `book_url`, the API route that books it on the connected method. tags: [Flights] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/FlightSearchRequest' responses: '200': description: Search results content: application/json: schema: $ref: '#/components/schemas/FlightSearchResponse' '401': description: Invalid or missing API key '402': description: | No connected payment method, or the look-to-book allowance is used up. The body's `error` is `payment_method_required` or `search_allowance_exhausted`; the latter carries `free_searches_per_booking`, `searches_since_booking`, `block_size` and `block_price_cents`. '422': description: Invalid request (bad IATA code, date in past, etc.) /api/v1/flights/book: post: operationId: bookFlightOffer summary: Book a flight offer description: | Book one offer from one of your own searches, on the Revolut method connected to this key. The method is **held, not charged**, for the fare plus the payment markup. A LetsFG booking agent then buys the ticket from the seller and the hold is captured **only against a real airline PNR**. If the booking fails the hold is released and nothing is taken. Answers `202` within seconds with a `booking_id`; the booking itself takes 4–11 minutes. Poll `GET /flights/bookings/{booking_id}` until `terminal` is true. A completed booking also resets your free-search allowance. Retrying with the same `idempotency_key` returns the existing booking rather than opening a second hold. Nothing is charged on any refusal. tags: [Bookings] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DeveloperBookRequest' responses: '202': description: Hold placed and the booking agent dispatched content: application/json: schema: $ref: '#/components/schemas/BookAccepted' '400': description: | `missing_fields` (the lead passenger is missing details an airline checkout requires — the list names them), `party_too_large`, `currency_unsupported`, `offer_not_bookable`. '402': description: "`payment_method_required`, or `payment_declined` with a `decline_reason`." '404': description: "`search_not_found` or `offer_not_found` — searches stay bookable for 6 hours." '409': description: "`package_blocked` — this traveller booked in the last 24h (package-travel rules)." '503': description: "`temporarily_unavailable`." /api/v1/flights/bookings/{booking_id}: get: operationId: getFlightBooking summary: Poll a booking description: | The state of a booking you opened. Poll every few seconds; the poll is also how LetsFG knows you are still there, which keeps a paused booking alive. While `booking_in_progress`, `question` may carry a seat map, a paid extra, or a fare increase the run is holding a cart for — answer it through `POST /flights/bookings/{booking_id}/answer` within its window, or the run continues without it. tags: [Bookings] parameters: - name: booking_id in: path required: true schema: type: string responses: '200': description: Booking state content: application/json: schema: $ref: '#/components/schemas/BookingStatus' '404': description: No such booking on this account /api/v1/flights/bookings/{booking_id}/answer: post: operationId: answerBookingQuestion summary: Answer an open booking question description: | Answer the open `question` on a booking. Echo its `round` — a stale round is refused with `409` rather than guessed at. Seat question: `{round, seats: [...]}` to choose, or `{round, skip: true}`. Price change or paid extra: `{round, confirm: true}` to accept, `{round, skip: true}` to decline. Declining an extra still completes the booking without it. tags: [Bookings] parameters: - name: booking_id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BookingAnswerRequest' responses: '200': description: Answer accepted '409': description: "`no_open_question`, or the `round` is stale." /api/v1/flights/resolve-location: get: operationId: resolveLocation summary: Resolve city/airport name to IATA codes description: "Convert city names or airport names to IATA codes. Use before search to resolve ambiguous locations." tags: [Flights] parameters: - name: query in: query required: true schema: type: string description: "City or airport name (e.g. 'London', 'JFK')" example: "London" responses: '200': description: Matching locations content: application/json: schema: type: array items: $ref: '#/components/schemas/LocationResult' /api/v1/hotels/destinations: post: operationId: resolveHotelCity summary: Resolve a place name to a supplier city id description: | Convert a place name into the `city_id` that hotel search needs. Take `Id` from the first result as `city_id` and `Name` as `city_name`. tags: [Hotels] requestBody: required: true content: application/json: schema: type: object required: [text] properties: text: type: string example: "Warsaw" responses: '200': description: Matching destinations, best first '402': description: No payment method on file. Hotels require a card for every call, search included. /api/v1/hotels/search: post: operationId: searchHotels summary: Search bookable hotel rates description: | Search real, bookable hotel inventory. Every rate type is returned, refundable and non-refundable. Each offer carries `refundable` and, on a refundable rate, `free_cancellation_until`. `price` is the all-in total the guest pays, in `currency` (USD unless requested otherwise): the supplier's cost plus 6.4% for Revolut Pay or an EEA-issued card, 8.3% for a card issued elsewhere (`markup_rate`). `expected_cost` is the supplier's own figure, there only to be sent back to booking. Keep the chosen offer's `session_id` and `combination_id_v2` — together they identify that exact rate, and booking needs both. Look-to-book: 1,000 searches are free after every hotel booking, then blocks of 1,000 for $5.00 from prepaid balance. Searching needs no payment method on file; booking does (`402` without one). An account with no payment method gets a daily number of hotel searches. tags: [Hotels] requestBody: required: true content: application/json: schema: type: object required: [city_id, city_name, check_in, check_out] properties: city_id: { type: integer, example: 148614 } city_name: { type: string, example: "Warsaw, Poland" } check_in: { type: string, format: date, example: "2026-11-10" } check_out: { type: string, format: date, example: "2026-11-12" } adults: { type: integer, default: 2 } children: { type: integer, default: 0 } child_ages: type: array items: { type: integer } description: Required by the supplier to price children. nationality: type: string default: "PL" description: Two-letter code. Rates and taxes genuinely differ by nationality. currency: type: string default: "USD" description: ISO code every `price` is quoted in. limit: { type: integer, default: 40, maximum: 200 } responses: '200': description: Hotels with bookable offers '402': description: No payment method on file '504': description: The supplier did not answer in time /api/v1/hotels/book: post: operationId: bookHotel summary: Start a hotel booking (asynchronous) description: | Returns a `booking_job_id`, **not** a booking. The full price is **held** on the connected card (not taken), LetsFG books the room and pays the supplier, and the hold is captured only once the hotel confirms. A failed booking releases the hold. There is no reservation fee, no deposit and no pay link. A real booking takes minutes. Poll `GET /api/v1/hotels/booking/{booking_job_id}` until `status` is `succeeded`, `failed` or `attention`. Send `expected_price`, `expected_cost`, `currency` and `fx_rate` back exactly as the chosen offer returned them. `currency` defaults to PLN here, so a USD offer sent without it is refused. The booking is also refused if the supplier's price has moved, so a guest is never charged a price they did not agree to. Guest names, phone and e-mail are checked before anything is held. Never call this again while its job is running — poll it; a retry with the same `idempotency_key` returns the existing job (`duplicate: true`) instead of booking twice. tags: [Hotels] requestBody: required: true content: application/json: schema: type: object required: [session_id, hotel_code, combination_id_v2, expected_price, expected_cost, city_id, city_name, check_in, check_out, guests, email, phone] properties: session_id: { type: string } hotel_code: { type: integer } combination_id_v2: type: string description: Identifies the exact rate. Room name alone is ambiguous. combination_id: { type: integer } expected_price: type: number description: The offer's `price`, verbatim. expected_cost: type: number description: The offer's `expected_cost`, verbatim (the supplier's own figure, PLN). currency: type: string default: "PLN" description: The offer's `currency`, verbatim. A non-PLN offer sent without it is refused. fx_rate: type: [number, "null"] description: The offer's `fx_rate`, verbatim. hotel_name: { type: string } city_id: { type: integer } city_name: { type: string } check_in: { type: string, format: date } check_out: { type: string, format: date } adults: { type: integer, default: 2 } guests: type: array description: >- One entry per guest in the room, children included: adults first, then children in the search's child_ages order. Fewer names than the searched party fails the booking before anything is submitted, and the hold is released. items: type: object required: [title, first_name, last_name] properties: title: { type: string, example: "Mr" } first_name: { type: string } last_name: { type: string } email: type: string description: The confirmation, and any message about the booking, go here. A typo loses the booking. phone: { type: string } phone_country_code: { type: string, default: "48" } special_requests: type: array items: { type: string } idempotency_key: type: string maxLength: 128 description: The same key on a retry returns the booking it already opened. responses: '202': description: Booking started — poll the returned booking_job_id '400': description: "`price_mismatch` (offer not copied verbatim) or `invalid_details` (a guest detail was rejected). Nothing is held." '402': description: No payment method connected, or the connected method declined the hold. Nothing is booked. '409': description: "`package_blocked` — refused under package-travel rules" /api/v1/hotels/booking/{booking_job_id}: get: operationId: getHotelBooking summary: Collect a hotel booking result description: | Poll every ~20s. `status` is `in_progress`, `succeeded`, `failed` or `attention`; the last three are final. `succeeded`: `confirmation`, `booking_id`, `hotel`, `room`, `total_price` + `currency` (what the guest is charged), `supplier_paid` + `supplier_currency`, `payment_status`, `refundable`, `free_cancellation_until`, `cancellation_ladder` and `terms`. `failed`: `error`, written for the guest. The hold has been released and nothing was charged. `attention`: `error` (and `confirmation` when known). The outcome could not be settled automatically and a person at LetsFG is confirming it with the supplier. The hold is kept and nothing is charged — do not book again. The guest is e-mailed however the booking ends. tags: [Hotels] parameters: - name: booking_job_id in: path required: true schema: { type: string } responses: '200': description: Booking status, and the booking itself once it settles '404': description: No such booking job for this account /api/v1/hotels/cancel: post: operationId: cancelHotelBooking summary: Release a hotel reservation description: | Cancels a booking this account made. A refundable rate cancelled before its `free_cancellation_until` is refunded in full. A cancellation the hotel would charge for (a non-refundable rate, or past that date) is refused with `409`; the hotel's own ladder ships in the booking's `terms`, so the cost is knowable first. Drives a browser at the supplier and takes over a minute. If it times out, do not assume it failed — re-check before retrying. tags: [Hotels] requestBody: required: true content: application/json: schema: type: object required: [confirmation] properties: confirmation: { type: string, example: "8BXJPX" } responses: '200': description: Cancelled; `refund` describes the money returned '402': description: No payment method on file '404': description: No such booking for this account '409': description: The cancellation would cost money, so it was refused /api/v1/bookings/unlock: post: operationId: unlockOffer deprecated: true summary: "RETIRED — there is no unlock step any more" description: | Retired on 2026-09-08 and answers `410 Gone`. There is no unlock step: every offer a search returns is bookable directly with `POST /flights/book`, which holds the fare on your connected method and captures only against a real PNR — so a moved price can never become a surprise charge, which is what unlock existed to prevent. tags: [Bookings] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UnlockRequest' responses: '200': description: Offer unlocked and reserved content: application/json: schema: $ref: '#/components/schemas/UnlockResponse' '401': description: Invalid or missing API key '402': description: No payment method set up '410': description: Offer expired (search again) /api/v1/bookings/book: post: operationId: bookFlightRetired deprecated: true summary: "RETIRED — use POST /flights/book" description: | Retired on 2026-09-08 together with Stripe and answers `410 Gone`. Use `POST /flights/book`, which takes the `search_id` alongside the offer id, charges the connected Revolut method, and returns `202` with a `booking_id` to poll. tags: [Bookings] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/BookRequest' responses: '200': description: Booking confirmed content: application/json: schema: $ref: '#/components/schemas/BookingResponse' '401': description: Invalid or missing API key '410': description: "Gone — this route is retired. Use POST /flights/book." '422': description: Invalid passenger data