# FX-Port API documentation > FX-Port is a B2B travel platform. One REST API gives travel agencies and developers flights (GDS, NDC, LCC and direct airlines), hotels (RateHawk / WorldOTA and more) and eSIMs, with the best available prices and no per-supplier code. Requests are JSON with snake_case fields; every price is returned in the currency of the agency that owns the API key. This file contains every page of the documentation (last updated 2026-11-01). The index is at https://fx-port.com/llms.txt. --- # Introduction > FX-Port is a unified travel API for flights, hotels, eSIMs and more: one integration, many suppliers, best available prices. Source: https://fx-port.com/docs **FX-Port** is a unified travel API. One integration gives you **flights, hotels, eSIMs, and more**, sourced across many suppliers and returned with the best available prices — no per-supplier code. - [Flights](https://fx-port.com/docs/flights.md): Search, price and book across GDS, NDC, LCC and direct airlines; hold and pay later, tickets and ACD documents. - [Hotels](https://fx-port.com/docs/hotels.md): Geo search around a point, hotel content, rates, and bookings that confirm at once; vouchers and cancellation. - [eSIMs](https://fx-port.com/docs/esims.md): Data packages in 190+ countries: order, top up, usage and installation instructions. - [Account](https://fx-port.com/docs/account.md): Balances, the financial ledger, booking history across products and the supplier list. More products are on the way. > [!WARNING] > **Work in progress.** FX-Port is actively expanding: more suppliers, more GDS/NDC connections, > and new products. We aim to **avoid breaking changes** — any breaking or major change ships under > a **new API version** so your current integration keeps working. Have feedback, a feature request, > or found a bug? Email us at [dev@fx-port.com](mailto:dev@fx-port.com). > [!CAUTION] > **API keys can trigger real financial debits.** A Read+Write key creates bookings and charges your > agency balance. **Never** expose keys in front-end code, mobile apps, or public repos — always call > the API from your backend. See [Security](https://fx-port.com/docs/security.md) for the full guide. ## Get API access in minutes There is **no approval queue, no agreement to sign, and no deposit** required to start building: the sandbox is self-serve from day one. (Live keys work once your agency is approved.) **Step 1** **Create an account** — sign up at [fx-port.com/register](https://fx-port.com/register). Takes about a minute. **Step 2** **Generate your API keys** — open the [API page](https://fx-port.com/dashboard/api) in your dashboard and create a key. Start with a sandbox key (`fxp_test_`) to build and test for free. **Step 3** **Start building** — your first search call works immediately. See the [quick start](#quick-start-flights-in-3-calls) below. > [!NOTE] > **Sandbox is free and unlimited to explore.** You only need a funded balance when you want to > issue real bookings with a `fxp_live_` key — see [Environments](https://fx-port.com/docs/environments.md). ## Base URL ~~~ https://api.fx-port.com ~~~ All paths are prefixed with `/api/v1/`. **One URL for all environments** — live vs sandbox is determined by your API key prefix (`fxp_live_` or `fxp_test_`). ## Where to start - [Quickstart](https://fx-port.com/docs/quickstart.md): Get a sandbox key, then search, price and book a flight in about ten minutes. - [Security](https://fx-port.com/docs/security.md): Key handling, booking-ownership authorization, and API security best practices. Read this first. - [Authentication](https://fx-port.com/docs/authentication.md): API keys, permission levels, and environments. - [Conventions and naming](https://fx-port.com/docs/conventions.md): Names, ids, money, dates, and the legacy names that still work. - [Errors](https://fx-port.com/docs/errors.md): One error body for every endpoint, every code, and how to retry safely. - [Rate limits](https://fx-port.com/docs/rate-limits.md): The limits per key, what a 429 means and how to stay below them. - [Streaming](https://fx-port.com/docs/streaming.md): Receive flight offers supplier by supplier as server-sent events. - [Environments & Postman](https://fx-port.com/docs/environments.md): Live vs sandbox, and downloadable Postman collections. - [Suppliers](https://fx-port.com/docs/suppliers.md): Discover every active supplier and its capabilities. ## Quick start (flights, in 3 calls) **Step 1** **Search** — `POST /api/v1/get_flights`. Offers are in `suppliers[].results.offers[]`. Save the `searchId` of the supplier entry and the `id` of the offer. **Step 2** **Price** — `POST /api/v1/flights/price_flight` with that `search_id` and `offer_id`. Required before booking. **Step 3** **Book** — `POST /api/v1/flights/book_flight` with `booking_type: "issue"`, the passengers, and contact details. Tickets are issued immediately. ~~~bash # 1. Search curl --request POST \ --url https://api.fx-port.com/api/v1/get_flights \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --header 'Content-Type: application/json' \ --data '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ~~~ Continue with the [Quickstart](https://fx-port.com/docs/quickstart.md) (a complete, tested program) and the full [Flight booking flow →](https://fx-port.com/docs/flights/booking-flows.md) ## Quick start (eSIMs, in 3 calls) **Step 1** **List packages** — `GET /api/v1/esims/packages?country=france`. Save a package `id` (also returned as `package_id`). **Step 2** **Create the order** — `POST /api/v1/esims/orders` with that `package_id` plus the traveler's `email` and `phone` (both required — the traveler receives the QR code and setup instructions by email). Returns the eSIM `iccid` immediately (`data.iccid`, also `data.sim.iccid`). **Step 3** **Top up later** — `GET /api/v1/esims/topup-packages/{iccid}` to see what's available for that exact eSIM, then `POST /api/v1/esims/topups` with one of the returned package IDs. ~~~bash # 1. List packages curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/packages?country=france' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ Continue with the full [eSIMs overview →](https://fx-port.com/docs/esims.md) ## Built with AI tools? Point your AI coding assistant at these machine-readable files for accurate, up-to-date context: | URL | Content | |---|---| | [`/llms.txt`](https://fx-port.com/llms.txt) | Page index — titles, URLs, descriptions (lightweight) | | [`/llms-full.txt`](https://fx-port.com/llms-full.txt) | Full documentation text | | [`/openapi.json`](https://fx-port.com/openapi.json) | OpenAPI 3.1 description of every endpoint | | `.md` | Any page as Markdown, for example `/docs/flights/book.md` | See [Use these docs with AI](https://fx-port.com/docs/ai-tools.md) for a ready-to-paste prompt and how to build an agent on the API. ## Contact Questions, feedback, or issues? Email the developer team at [dev@fx-port.com](mailto:dev@fx-port.com). Get your API keys at [fx-port.com/dashboard/api](https://fx-port.com/dashboard/api). --- # Quickstart > Get a sandbox key, search, price and book a flight, then check the booking, in about ten minutes. Source: https://fx-port.com/docs/quickstart This guide takes about ten minutes. You will get a sandbox key, make a first call, then **search, price and book a flight** and read the booking back. Nothing here costs money: sandbox flight bookings are made on the suppliers' test systems, so no balance is debited and no real ticket exists. **Step 1: Create an account and a sandbox key** Sign up at [fx-port.com/register](https://fx-port.com/register), open the [API page](https://fx-port.com/dashboard/api) of the dashboard and create a key. Choose **Read + Write** for this guide, because booking needs it. A sandbox key starts with `fxp_test_`. Keep the key on your server, in an environment variable, never in front-end code: ~~~bash export FXP_KEY="fxp_test_YOUR_KEY" ~~~ **Step 2: Check that the key works** `GET /suppliers` is free and quick. It answers with the suppliers your key can reach, which proves the key, the environment and the network path in one call. **Request** ```bash curl https://api.fx-port.com/api/v1/suppliers \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ~~~json { "environment": "sandbox", "success": true, "products": [ { "product": "flights", "suppliers": [ { "id": "amadeus_aqc_dz_1", "name": "Amadeus Algeria", "technology": "amadeus_aqc", "routes": ["DZ"], "capabilities": { "hold": true, "void": true, "flexible_dates": true, "ancillaries": false } }, { "id": "duffel_1", "name": "Duffel", "technology": "duffel", "routes": ["*"], "capabilities": { "hold": false, "void": false, "flexible_dates": false, "ancillaries": true } } ] } ] } ~~~ `"environment": "sandbox"` is in every JSON answer. It tells you which environment your key is in, so you cannot confuse the two. **Step 3: Search, price, book and read the booking back** The program below does the whole flight flow. It searches Algiers to Paris 45 days ahead on Duffel, takes the cheapest offer, prices it, books it as an instant ticket and reads the booking back. **Python** ~~~python import os from datetime import date, timedelta import requests API = "https://api.fx-port.com/api/v1" HEADERS = {"Authorization": f"Bearer {os.environ['FXP_KEY']}", "Content-Type": "application/json"} def post(path, body): response = requests.post(API + path, headers=HEADERS, json=body, timeout=90) data = response.json() if not response.ok: raise SystemExit(f"{response.status_code} {data.get('errors') or data}") return data # 1. Search (Duffel works on every sandbox key; leave supplier_id out to search every supplier) search = post("/get_flights", { "origin": "ALG", "destination": "CDG", "departure_date": (date.today() + timedelta(days=45)).isoformat(), "cabin_class": "economy", "passengers": {"adults": 1}, "supplier_id": "duffel_1", }) supplier = search["suppliers"][0] offer = min(supplier["results"]["offers"], key=lambda o: o["pricing"]["finalPrice"]) search_id = supplier.get("search_id") or supplier["searchId"] print("cheapest offer:", offer["id"], offer["pricing"]["finalPrice"], offer["pricing"]["agencyCurrency"]) # 2. Price: confirms the fare with the supplier before you book priced = post("/flights/price_flight", {"search_id": search_id, "offer_id": offer["id"]}) print("priced at:", priced["results"]["offers"][0]["pricing"]["finalPrice"]) # 3. Book (sandbox bookings never debit money; send_email false keeps the traveler's inbox quiet) booking = post("/flights/book_flight", { "search_id": search_id, "offer_id": offer["id"], "booking_type": "issue", "send_email": False, "passengers": [{ "type": "adult", "gender": "MALE", "first_name": "John", "last_name": "Smith", "date_of_birth": "1988-06-15", "identity_documents": [{"type": "passport", "number": "A12345678", "issuing_country": "DZ", "expiry_date": "2031-04-18", "nationality": "DZ"}], }], "contact_details": {"email": "traveler@example.com", "phone_number": "+213554657687"}, }) pnr = booking["booking"]["pnr"] print("booked:", pnr, booking["booking"]["status"]) # 4. Read the booking back details = post("/flights/get_booking", {"booking_id": pnr}) print("stored booking:", details["booking"]["status"], details["booking"]["total_price"], details["booking"]["currency"]) ~~~ **JavaScript** ~~~js // Node 18 or later (fetch is built in); run it as an ES module: node quickstart.mjs const API = "https://api.fx-port.com/api/v1"; const headers = { Authorization: `Bearer ${process.env.FXP_KEY}`, "Content-Type": "application/json" }; async function post(path, body) { const response = await fetch(API + path, { method: "POST", headers, body: JSON.stringify(body) }); const data = await response.json(); if (!response.ok) throw new Error(`${response.status} ${JSON.stringify(data.errors ?? data)}`); return data; } // 1. Search (Duffel works on every sandbox key; leave supplier_id out to search every supplier) const departure = new Date(Date.now() + 45 * 86_400_000).toISOString().slice(0, 10); const search = await post("/get_flights", { origin: "ALG", destination: "CDG", departure_date: departure, cabin_class: "economy", passengers: { adults: 1 }, supplier_id: "duffel_1", }); const supplier = search.suppliers[0]; const offer = supplier.results.offers.reduce((best, o) => (o.pricing.finalPrice < best.pricing.finalPrice ? o : best)); const searchId = supplier.search_id ?? supplier.searchId; console.log("cheapest offer:", offer.id, offer.pricing.finalPrice, offer.pricing.agencyCurrency); // 2. Price: confirms the fare with the supplier before you book const priced = await post("/flights/price_flight", { search_id: searchId, offer_id: offer.id }); console.log("priced at:", priced.results.offers[0].pricing.finalPrice); // 3. Book (sandbox bookings never debit money; send_email false keeps the traveler's inbox quiet) const booking = await post("/flights/book_flight", { search_id: searchId, offer_id: offer.id, booking_type: "issue", send_email: false, passengers: [ { type: "adult", gender: "MALE", first_name: "John", last_name: "Smith", date_of_birth: "1988-06-15", identity_documents: [{ type: "passport", number: "A12345678", issuing_country: "DZ", expiry_date: "2031-04-18", nationality: "DZ" }], }, ], contact_details: { email: "traveler@example.com", phone_number: "+213554657687" }, }); const pnr = booking.booking.pnr; console.log("booked:", pnr, booking.booking.status); // 4. Read the booking back const details = await post("/flights/get_booking", { booking_id: pnr }); console.log("stored booking:", details.booking.status, details.booking.total_price, details.booking.currency); ~~~ **cURL** ~~~bash # 1. Search. Copy "searchId" of the supplier and the "id" of the offer you want from the answer. curl -s -X POST https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer $FXP_KEY" -H "Content-Type: application/json" \ -d '{"origin":"ALG","destination":"CDG","departure_date":"2026-12-20","cabin_class":"economy","passengers":{"adults":1},"supplier_id":"duffel_1"}' # 2. Price curl -s -X POST https://api.fx-port.com/api/v1/flights/price_flight \ -H "Authorization: Bearer $FXP_KEY" -H "Content-Type: application/json" \ -d '{"search_id":"fs_dfx_...","offer_id":"fx_offer_..."}' # 3. Book curl -s -X POST https://api.fx-port.com/api/v1/flights/book_flight \ -H "Authorization: Bearer $FXP_KEY" -H "Content-Type: application/json" \ -d '{"search_id":"fs_dfx_...","offer_id":"fx_offer_...","booking_type":"issue","send_email":false, "passengers":[{"type":"adult","gender":"MALE","first_name":"John","last_name":"Smith","date_of_birth":"1988-06-15", "identity_documents":[{"type":"passport","number":"A12345678","issuing_country":"DZ","expiry_date":"2031-04-18","nationality":"DZ"}]}], "contact_details":{"email":"traveler@example.com","phone_number":"+213554657687"}}' # 4. Read the booking back (booking_id is the "pnr" of step 3) curl -s -X POST https://api.fx-port.com/api/v1/flights/get_booking \ -H "Authorization: Bearer $FXP_KEY" -H "Content-Type: application/json" \ -d '{"booking_id":"J2RRA3"}' ~~~ Run it and you get something like: ~~~text cheapest offer: fx_offer_a7df9202 21708.29 DZD priced at: 21708.29 booked: 2LL5ZF confirmed stored booking: confirmed 21708.29 DZD ~~~ Prices are in the currency of your agency (here DZD), whatever currency the supplier charges. ## The identifiers that travel from call to call | You get it from | Field | You send it to | As | |---|---|---|---| | Search | `suppliers[].searchId` (twin `search_id`) | price and book | `search_id` | | Search | `suppliers[].results.offers[].id` (twin `offer_id`) | price and book | `offer_id` | | Book | `booking.pnr` | get booking, tickets, cancel, ACD documents | `booking_id` | | Book | `booking.reference` (the supplier's order id) | pay, cancel a hold | `order_id` | A search is valid for a limited time and a fare can change or sell out, so always **price right before you book**. Read [Conventions and naming](https://fx-port.com/docs/conventions.md) for the rules every endpoint follows (names, ids, money, dates, pagination) and [Errors](https://fx-port.com/docs/errors.md) for what to do when a call fails. ## Hotels and eSIMs in the sandbox **Hotels.** You can search and price any hotel in the sandbox, but you can only **book the supplier's test hotels**. Booking a random hotel does not work. Use `test_hotel_do_not_book` (any residency) or `test_hotel` (residency `dz`, `ru` or `us`) and cancel what you book. The [hotel sandbox guide](https://fx-port.com/docs/hotels/sandbox-testing.md) has a complete, tested script, and [Hotel data: mini and full](https://fx-port.com/docs/hotels/hotel-data.md) shows which hotel payload to use for a result list and which for a details page. **eSIMs.** List the packages of a country, order one with the traveler's e-mail and phone, then top it up later: **Request** ```bash curl 'https://api.fx-port.com/api/v1/esims/packages?country=france&limit=2' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` Continue with the [eSIM guide](https://fx-port.com/docs/esims.md). ## Going live The code you wrote does not change. Swap the key for a `fxp_live_` key and the same base URL answers with **live** data. From then on a Read + Write key issues real tickets and debits your agency balance, so read [Security](https://fx-port.com/docs/security.md) first, keep keys on your server, and check [Environments](https://fx-port.com/docs/environments.md) for what differs (live needs a funded balance, the sandbox does not). ## What to read next - [Conventions and naming](https://fx-port.com/docs/conventions.md): Names, ids, money, dates and the rules every endpoint follows. - [Errors](https://fx-port.com/docs/errors.md): The error body, every code and what to do about each kind. - [Rate limits and fair use](https://fx-port.com/docs/rate-limits.md): The limits per minute and how to back off. - [Streaming search results](https://fx-port.com/docs/streaming.md): First offers in about two seconds instead of waiting for the slowest supplier. - [Flight booking flows](https://fx-port.com/docs/flights/booking-flows.md): Instant, hold and ACD flows with every status. - [API reference](https://fx-port.com/docs/api.md): Every endpoint with examples in nine languages. - [Use these docs with AI](https://fx-port.com/docs/ai-tools.md): llms.txt, OpenAPI and Markdown for assistants. --- # Security > How to keep your FX-Port integration secure: key handling, booking authorization and general API security hygiene. Source: https://fx-port.com/docs/security > [!WARNING] > An FX-Port API key with **Read+Write** permission can create bookings and trigger real financial > debits from your agency balance. Treat it with the same care as a payment credential. ## Always keep keys server-side **Never expose your FX-Port API key in:** - Browser JavaScript (React client components, Vue, Angular, plain JS) - Mobile apps (React Native, Expo, Flutter, native iOS/Android) - Public repositories, commit history, or CI/CD environment variables visible in build logs - Client-side `.env` files shipped with a compiled app Your app's **backend** (Node.js, Python, Go, etc.) should be the only code that ever reads and uses the FX-Port API key. If your front-end needs to trigger a booking, it should call **your own authenticated backend**, which then calls FX-Port on the user's behalf. ~~~ Browser/App → Your backend → FX-Port API ↑ API key lives here only ~~~ ## Minimal-permission keys Use a **Read-only** key for integrations that only display search results, balances, or status. Reserve the **Read+Write** key for the backend service that actually creates bookings. See [Authentication](https://fx-port.com/docs/authentication.md) for what each permission level allows. ## Key rotation Rotate keys immediately if you suspect a leak. The dashboard lets you invalidate and replace a key without downtime. Rotate on a routine schedule too (e.g. every 90 days) rather than only after an incident — routine rotation limits how long a silently-leaked key stays useful to an attacker. ## Enforce booking ownership in your own backend > [!WARNING] > FX-Port scopes every response to the agency that owns the API key — one agency's key can never > return another agency's bookings, balances, or account data. But that only protects the FX-Port > API boundary. **If your own application lets multiple end-clients share one FX-Port key** > (a common pattern), you are responsible for authorization **inside your own backend** so one > client cannot view, modify, or cancel another client's booking. A booking reference, order ID, ICCID, or PNR is **not a secret** — it can be guessed, enumerated, logged, or leaked through a shared link. Never treat "the caller supplied a valid booking ID" as proof that the caller is entitled to see it. Concretely: - Store which of **your own** end-users/clients created or is entitled to each booking (a `client_id` / `user_id` column alongside the FX-Port `booking_id` you save). - On every read or mutation your backend exposes (get booking, get ticket, cancel, pay, request a refund, view usage, etc.), check that the authenticated caller in **your** system owns that booking **before** forwarding the request to FX-Port — do not rely on the booking ID alone as an access-control token. - Apply the same rule to balances, transaction/financial history, and account data: scope every query by the authenticated caller's own identity, never by an ID taken directly from the request path or query string. - If you use a database with row-level security (Supabase, Postgres RLS, etc.), enforce this at the database layer as well as in application code — defense in depth. A backend bug that forgets an `owner_id` filter should not be enough, on its own, to expose another client's data. - Test this explicitly: as client A, try to fetch/cancel a booking that belongs to client B by ID. It should fail with `403`/`404`, not succeed. Treat this as a required test case for every booking, balance, and account endpoint you expose — for flights, hotels, eSIMs, and any other product — not an edge case to defer. This is Broken Object Level Authorization (OWASP API Security Top 10, API1:2023) — the single most common vulnerability class in booking/travel APIs. It is entirely preventable with a per-request ownership check and is far cheaper to build in from day one than to retrofit after a data leak. ## Validate and sanitize callback payloads FX-Port does not send webhooks to API customers: poll a booking for its status. But if your application receives callbacks from anyone (a payment provider, your own front-end, a partner), treat every incoming payload as untrusted input, not as fact: - Verify a signature/secret on every callback before acting on it, rather than trusting the sender because the request "looks right". - Re-fetch authoritative state from the FX-Port API before making a financial or booking decision based on a callback payload, instead of trusting fields inside the callback body directly. - Make callback handlers idempotent — a retried or duplicated delivery should not double-book, double-refund, or double-charge. ## Links to tickets, vouchers and documents are not private The links the API returns for files (`bucket_link` and `voucher_url` for tickets and vouchers, `download_url` for uploaded ACD documents) are plain HTTPS links that open **without a key**. Anyone who has the link can read the file, and a ticket, a voucher or a passport scan holds personal data. - Treat each link like a secret: do not put it in a public page, an e-mail to a third party, a URL that gets logged or an analytics event. - Serve files to your own users through your authenticated backend (fetch the link server-side and stream it, after your ownership check) instead of handing the link to a browser. - Do not keep the links for longer than you need them: fetch the booking again when you need a fresh one. ## Rate limiting and abuse protection FX-Port limits each key per minute (see [Rate limits](https://fx-port.com/docs/rate-limits.md)) and refuses malformed requests before they reach a supplier. Search and pricing endpoints are inexpensive to call but not free to abuse: - Apply your own rate limiting in front of any public-facing feature that triggers an FX-Port call on user input (a search box, a price checker), so a single abusive user or bot can't exhaust your key's rate limit or run up unnecessary supplier calls. - Cache read-only, non-personalized responses (e.g. [suppliers](https://fx-port.com/docs/suppliers.md) or [airports](https://fx-port.com/docs/flights/airports.md)) client-side or at your edge where appropriate, instead of re-querying on every page load. ## Logging and data retention - Never log full API keys, even in debug/error logs — log a short prefix (`fxp_live_ab12…`) if you need to identify which key made a call. - Log the `request_id` of every FX-Port call (the `X-Request-Id` header, also in every body) next to your own ids: it is what support needs to find a request. - Avoid logging full traveler PII (passport numbers, full card/payment details) in plaintext application logs. If you must retain it for support, encrypt it at rest and restrict access. - Apply a retention policy to logs and cached search/pricing responses containing personal data, rather than keeping it indefinitely by default. ## Broader security hygiene > [!NOTE] > These rules apply to every sensitive credential in your application stack, not just FX-Port keys. - **Supabase / database API keys** — never expose the `service_role` key in a browser or mobile app. Only the `anon` key can be used client-side, and even that should be behind Row-Level Security policies. - **Third-party vendor keys** (Stripe, Twilio, etc.) — always consumed server-side. A leaked Stripe secret key or Twilio auth token can cause direct financial loss. - **WordPress and open-source CMS plugins** — themes and plugins are a frequent attack vector. Credentials stored in a WordPress database are regularly exfiltrated through plugin vulnerabilities. If you run a CMS alongside your booking integration, keep credentials in separate, isolated infrastructure and audit installed plugins regularly. - **Dependency CVEs** — keep your runtime and framework versions current. Subscribe to security advisories for your stack ([nodejs.org/en/security](https://nodejs.org/en/security), [github.com/advisories](https://github.com/advisories)) and apply patches promptly. - **Least privilege everywhere** — the same "minimal-permission key" principle above applies to cloud IAM roles, CI/CD service accounts, and internal admin tooling. A credential should only be able to do what its specific job requires. ## Questions? Found a vulnerability or want a security review of your integration? Email [dev@fx-port.com](mailto:dev@fx-port.com). --- # Authentication > How to authenticate with the FX-Port API using Bearer tokens, the key permissions, the key prefixes and the environments. Source: https://fx-port.com/docs/authentication Every request carries an API key as a bearer token in the `Authorization` header: ~~~http Authorization: Bearer fxp_test_YOUR_KEY ~~~ Get your API keys at [fx-port.com/dashboard/api](https://fx-port.com/dashboard/api). The word `Bearer` is not case-sensitive. > [!WARNING] > Keys belong on your server. A Read+Write key can create bookings and debit your balance. Never put a key in a browser, a mobile app or a public repository: see [Security](https://fx-port.com/docs/security.md). ## The key A key is a prefix followed by 32 lowercase hexadecimal characters (41 characters in all). FX-Port stores only a hash of it, so a lost key cannot be recovered: create a new one. ~~~text fxp_live_<32 lowercase hexadecimal characters> fxp_test_<32 lowercase hexadecimal characters> ~~~ | Prefix | Environment | What it does | |---|---|---| | `fxp_live_` | **Live** | Real agency data, real bookings, your real balance | | `fxp_test_` | **Sandbox** | Isolated test bookings on the suppliers' test systems, no balance debit | There is **one base URL** (`https://api.fx-port.com`) for both environments: the key decides. Every JSON body carries `"environment": "sandbox"` or `"live"`, and so does the `X-Environment` response header. A key resolves your agency by itself, so you never send an agency id. See [Environments and Postman](https://fx-port.com/docs/environments.md) and [Sandbox](https://fx-port.com/docs/sandbox.md). ## Permission levels Each key is either **Read** or **Read+Write**. | Permission | What it allows | |---|---| | **Read** | Everything that does not create, pay for or cancel something: searching, pricing, hotel content and rates, retrieving bookings and tickets, eSIM catalogue, usage and instructions, balances, history, suppliers | | **Read+Write** | All of the above **plus** the endpoints that change data or money, listed below | These endpoints need a Read+Write key. A Read key gets `403 read_write_permission_required`: | Product | Endpoints | |---|---| | Flights | `POST /flights/book_flight`, `POST /flights/pay_order`, `POST /flights/cancel_order`, `POST /flights/confirm_cancel`, `POST /flights/upload_acd_documents` | | Hotels | `POST /hotels/pre_book`, `POST /hotels/book_hotel`, `POST /hotels/cancel_hotel` | | eSIMs | `POST /esims/orders`, `POST /esims/topups` | Use a Read key wherever the code only displays results. ## IP restrictions In the dashboard you can restrict a key to a list of IPv4 or IPv6 addresses or CIDR ranges. This works for any key, live or sandbox, and is recommended for production Read+Write keys. A request from another address gets `403 ip_not_allowed`, and the message names the address FX-Port saw. ## Your agency must be approved for live A `fxp_live_` key only works once your agency is approved. Until then it answers `403 agency_not_active` and you keep building with the sandbox key, which works while the agency is pending. ## Changes take up to five minutes Keys, permissions, IP lists and the agency status are cached for 5 minutes. After you revoke a key, change its permission or edit its IP list, allow up to five minutes before the change applies everywhere. ## When authentication fails | Status | Code | Cause | |---|---|---| | 401 | `authentication_required` | No key was sent | | 401 | `invalid_api_key_format` | The key is not shaped like an FX-Port key: `fxp_live_` or `fxp_test_` followed by 16 to 64 letters and digits | | 401 | `invalid_api_key` | The key is unknown, revoked or expired | | 403 | `ip_not_allowed` | The key has an IP list and the request came from another address | | 403 | `agency_not_active` | The agency is not approved for this environment yet | | 403 | `read_write_permission_required` | A Read key called an endpoint that needs Read+Write | ~~~json { "environment": "sandbox", "success": false, "errors": [ { "code": "read_write_permission_required", "type": "permission_error", "title": "Read+Write key required", "message": "This endpoint requires an API key with read_write permission.", "field": null, "details": {}, "documentation_url": "https://fx-port.com/docs/errors#read_write_permission_required" } ], "meta": { "request_id": "req_8f3a9c1d2e4b", "status": 403 }, "request_id": "req_8f3a9c1d2e4b" } ~~~ When the key is missing or invalid, the body says `"environment": "live"`, because FX-Port cannot tell which environment a key it does not know belongs to. [Errors](https://fx-port.com/docs/errors.md) lists every code and the older bodies some endpoints still return next to `errors`. ## What not to send - **`X-API-Key`** is reserved for FX-Port's own applications. If you send it, even by mistake, it takes precedence over your bearer key and the request fails with 401. Use `Authorization: Bearer` only. - **`X-Environment`** is ignored for API keys: the key prefix decides. - **A browser.** CORS is enabled for FX-Port's own sites only, so a browser app on another origin cannot read the answers. Call the API from your server. ## Security Key handling, minimal-permission keys, booking-ownership checks and general API security hygiene have their own page: [Security](https://fx-port.com/docs/security.md). --- # Environments and Postman > Live and sandbox on one base URL, decided by the API key prefix, and the Postman collections to try every endpoint. Source: https://fx-port.com/docs/environments There is **one base URL** for all environments: ~~~ https://api.fx-port.com ~~~ The environment is determined entirely by the **API key prefix** — there are no separate URLs. Every JSON answer starts with `"environment": "sandbox"` or `"live"` and every response carries the `X-Environment` header, so your code can assert which environment it is talking to. (An answer to a request with a missing or unknown key says `live`, because the environment of an unknown key cannot be known.) | Key prefix | Environment | Behaviour | |---|---|---| | `fxp_live_` | **Live** | Real agency data; debits real balance; real bookings | | `fxp_test_` | **Sandbox** | Isolated test bookings; no real charges; balance endpoints return empty | ## Getting API keys Create keys in the FX-Port dashboard at [fx-port.com/dashboard/api](https://fx-port.com/dashboard/api). Both sandbox and live keys can have **Read** or **Read+Write** permissions: | Permission | Sandbox key use | Live key use | |---|---|---| | Read | Search, price, get quotes, retrieve bookings | Same | | Read+Write | Search + **create test bookings, pay, cancel** | Search + **real bookings, real balance debit** | ## Postman collections Pre-built collections with every endpoint, auto-wired test scripts (a search saves `search_id` and `offer_id` for the price and book requests that follow), and randomised Latin passenger names. Download, import into Postman, set your API key as the `api_key` variable, and every request is ready to run. **The collections contain no API keys** — add your own. They are generated from the same description of the API as the [reference](https://fx-port.com/docs/api.md), so they always match it. Two environment files set `base_url` and `api_key` for you: [FX-Port-Sandbox.postman_environment.json](https://fx-port.com/docs/postman/FX-Port-Sandbox.postman_environment.json) and [FX-Port-Live.postman_environment.json](https://fx-port.com/docs/postman/FX-Port-Live.postman_environment.json). To import the whole API at once instead, give Postman (or Insomnia, Bruno, Swagger UI ...) the OpenAPI file: [`https://fx-port.com/openapi.json`](https://fx-port.com/openapi.json). - [Flights collection](https://fx-port.com/docs/postman/FX-Port-Flights.postman_collection.json): Search, price, book, hold/pay, cancel, get booking, get ticket, airports. - [Hotels collection](https://fx-port.com/docs/postman/FX-Port-Hotels.postman_collection.json): Location/name lookup, availability search, rates, pre-book, booking, voucher, and cancellation. - [eSIMs collection](https://fx-port.com/docs/postman/FX-Port-eSIMs.postman_collection.json): Package catalog, compatible devices, orders, topups, usage, bookings, and installation instructions. - [Account collection](https://fx-port.com/docs/postman/FX-Port-Account.postman_collection.json): Balances, financial ledger, and booking history. - [Suppliers collection](https://fx-port.com/docs/postman/FX-Port-Suppliers.postman_collection.json): Discover enabled suppliers and capabilities. Direct links: - [FX-Port-Flights.postman_collection.json](https://fx-port.com/docs/postman/FX-Port-Flights.postman_collection.json) - [FX-Port-Hotels.postman_collection.json](https://fx-port.com/docs/postman/FX-Port-Hotels.postman_collection.json) - [FX-Port-eSIMs.postman_collection.json](https://fx-port.com/docs/postman/FX-Port-eSIMs.postman_collection.json) - [FX-Port-Account.postman_collection.json](https://fx-port.com/docs/postman/FX-Port-Account.postman_collection.json) - [FX-Port-Suppliers.postman_collection.json](https://fx-port.com/docs/postman/FX-Port-Suppliers.postman_collection.json) Get your API keys at [fx-port.com/dashboard/api](https://fx-port.com/dashboard/api) — sandbox keys are free. --- # Sandbox > How the sandbox works, the limits of the suppliers' own test systems and what to expect while testing. Source: https://fx-port.com/docs/sandbox Sandbox keys (`fxp_test_...`) connect to the **test systems of our suppliers**: Amadeus test, Duffel test, the hotel supplier's test contract and the eSIM supplier's sandbox. Nothing you do with a sandbox key moves your real balance, and sandbox data is kept apart from live data: a sandbox key never sees a live booking and the other way round. > [!WARNING] > The suppliers' test systems are **not under FX-Port's control**. They can have short outages, and some routes, airlines or hotels fail during pricing or booking in the sandbox although they work on live. When one supplier fails, the answer of FX-Port still arrives and the entry of that supplier says `success: false`. Wait and retry, or try another route. ## What the sandbox does | Behaviour | Detail | |---|---| | **No balance debit** | Financial operations are skipped: bookings do not charge your balance and `GET /balances` has nothing to show. | | **Real data structures, test fares** | Flight offers have the shape of live offers but come from test systems. Not every route exists. | | **Same limits as live** | The [rate limits](https://fx-port.com/docs/rate-limits.md) are the same, so load tests belong on a schedule, not in a loop. | | **No webhooks** | FX-Port does not call a URL of yours, in the sandbox or on live. Poll the booking: [get booking](https://fx-port.com/docs/flights/get-booking.md) for flights, [booking detail](https://fx-port.com/docs/account/booking-detail.md) for everything else. | | **`environment` everywhere** | Every JSON body says `"environment": "sandbox"` and every response has the `X-Environment: sandbox` header. | `GET /balances` answers an envelope with an empty list, not a bare array: ~~~json { "environment": "sandbox", "success": true, "data": [], "meta": { "agencyId": "00000000-0000-0000-0000-000000000000", "environment": "sandbox", "financialOperationsEnabled": false, "message": "Sandbox bookings do not debit live balances; no sandbox balance ledger is exposed." } } ~~~ ## Flights The sandbox has two flight suppliers (see [Suppliers](https://fx-port.com/docs/suppliers.md)): | Supplier | Routes | Notes | |---|---|---| | `duffel_1` | Worldwide | Instant ticketing only. Duffel allows **one booking per search**: to book a second offer, search again (a second booking on the same search fails with `offer_request_already_booked`). Offers are randomised and expire: an offer that searched fine can be gone when you price it, so search again. | | `amadeus_aqc_dz_1` | Routes that touch Algeria | Supports hold and same-day void. Some airlines or routes reject at the booking step even though they were in the results: try another route (for example `ALG → ORN`) or another offer. The cheapest holds fail more often than the others. | To test an ACD booking, see [ACD documents](https://fx-port.com/docs/flights/acd-documents.md): `CDG → ALG` on `amadeus_aqc_dz_1` returns offers with ACD pricing. Set `send_email` to `false` on bookings while you test, so no message goes to the address you typed. ## Hotels > [!CAUTION] > In the sandbox you can **search and price any hotel, but you can only book the supplier's test hotels.** Booking a random hotel does not work: `pre_book` answers `409 hotel_not_bookable_in_sandbox` and lists the test hotels (before 2026-11-01 it answered `200` with an empty booking form, and `book_hotel` then failed with `order_not_found`). Use `test_hotel_do_not_book` (works with every residency) or `test_hotel` (works only with residency `dz`, `ru` or `us`), and **cancel every booking you make**: sandbox hotel bookings are real orders on a supplier test contract. See [Hotel sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md) for the ids, a copy-paste script that books and cancels a test hotel, the date rules and the known quirks. ## eSIMs - A sandbox order really provisions an eSIM on the supplier's sandbox, with a dummy LPA string and a QR code image hosted by the sandbox. - Order and top-up answers carry three flags that make the "no money moved" rule visible: `balanceDebited`, `transactionCreated` and `payoutCreated` (twins `balance_debited`, `transaction_created`, `payout_created`), all `false`. - Usage is static: a sandbox eSIM reports `ACTIVE` with its full allowance and the figures do not move. - A sandbox top-up works end to end and shows a balance of `0.0`. - Sandbox bookings appear in your [history](https://fx-port.com/docs/account/history.md) and booking detail like any other. ## Testing checklist Before you switch to live keys, verify in the sandbox: 1. **Search**: your request is accepted and returns offers. Try one bad input on purpose (a wrong airport code) and check that you handle the [error](https://fx-port.com/docs/errors.md). 2. **Price**: re-pricing an offer works and the pricing fields are what you expect. 3. **Book (issue)**: the instant booking completes and returns a PNR and an e-ticket. 4. **Book (hold)**: a hold on `amadeus_aqc_dz_1` creates a reservation with `status: awaiting_payment`. 5. **Pay**: `pay_order` with the `order_id` of the hold succeeds. 6. **Get booking**: `get_booking` returns the booking, and `bucket_link` (the PDF) once it exists. 7. **Cancel a hold**: `cancel_order` releases an unpaid held PNR. Voiding or refunding an issued ticket, exchanges and re-issues stay manual support operations (Duffel orders can use the self-service [cancel quote](https://fx-port.com/docs/flights/cancel-quote.md)). 8. **Hotels**: run the whole flow on `test_hotel_do_not_book` and cancel it. 9. **eSIMs**: order a package, read its instructions and usage, top it up. 10. **Failure paths**: a read-only key on a write endpoint (`403`), an unknown id (`404`), a missing field (`400`), and a retry after a `429`. --- # Conventions and naming > Request and response conventions, the canonical snake_case names, and the legacy camelCase names that still work and how to move off them. Source: https://fx-port.com/docs/conventions The rules below hold for every endpoint of flights, hotels, eSIMs and the account. Learn them once. ## Transport and format | | | |---|---| | **Base URL** | `https://api.fx-port.com/api/v1`. One host for sandbox and live: the key decides ([Authentication](https://fx-port.com/docs/authentication.md)). | | **Protocol** | HTTPS only. `http://` is redirected with a `301`. | | **Bodies** | JSON in UTF-8 with `Content-Type: application/json`. The one exception is the ACD document upload, which is `multipart/form-data`. A JSON body sent without that content type is not read as JSON. | | **Methods** | `GET` for lookups, catalogues and the account; `POST` for searches, prices, bookings and everything that takes a body. | | **Trailing slash** | Do not add one. `/esims/packages/` answers `307` with an empty body. | | **Compression** | Send `Accept-Encoding: gzip`. Flight searches are 0.7 to 14 MB of JSON, ten times smaller compressed. Streams are never compressed. | | **Caching** | Responses are `Cache-Control: private, no-store`. Cache on your side what you can ([Rate limits](https://fx-port.com/docs/rate-limits.md#stay-comfortably-below-the-limits)). The airport and destination typeaheads are the only cacheable answers. | | **Browsers** | Do not call the API from a browser: CORS is enabled for FX-Port's own sites only, and a key in a browser is a leaked key. | | **User-Agent** | Send your own. Cloudflare blocks the default agents of Python's `urllib` and Perl's `libwww-perl` ([details](https://fx-port.com/docs/errors.md#errors-that-do-not-come-from-the-api)). | | **Health** | `GET https://api.fx-port.com/health` and `/health/ready` need no key and are outside `/api/v1`. | | **Headers you can read** | `X-Request-Id`, `X-Environment`, `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset`, `ratelimit-policy`, `Retry-After`, `Deprecation`, `Link`, `X-FX-Naming`, `X-FX-Deprecated-Fields`, `X-FX-Ignored-Fields`. | | **API host pages** | The interactive documentation of the API host is off. This site is the documentation, and the OpenAPI file is at [/openapi.json](https://fx-port.com/openapi.json). | Every JSON object answer starts with `"environment": "sandbox"` or `"live"`, and every response carries `X-Environment` and `X-Request-Id`. ## Naming **Names you send are `snake_case`.** `search_id`, `offer_id`, `booking_type`, `contact_details`, `phone_number`, `first_name`, `date_of_birth`, `cabin_class`. The guides and the reference show these names and only these. Over the years some endpoints took, or returned, other spellings: `searchId`, `offerId`, `bookingType`, `contactDetails`. **Nothing was removed.** Every previous spelling still works, and the API now tells you which one to use. ### A previous spelling still works, and says so When a request uses a previous spelling of a field, or a field the endpoint does not read, the answer carries a `warnings` array and headers that name what to use instead. The status code is not affected. ~~~bash curl -s -X POST https://api.fx-port.com/api/v1/flights/price_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" -H "Content-Type: application/json" \ -d '{"searchId": "fs_dfx_441e54ae-d7fe-414f-9fba-5415ee5ffb76", "offerId": "fx_offer_f3d89262"}' ~~~ ~~~http HTTP/1.1 200 OK Deprecation: true Link: ; rel="deprecation" X-FX-Deprecated-Fields: searchId->search_id,offerId->offer_id ~~~ ~~~json { "environment": "sandbox", "success": true, "warnings": [ { "code": "deprecated_field", "field": "searchId", "replacement": "search_id", "message": "`searchId` is a previous spelling; use `search_id`. The old name keeps working.", "documentation_url": "https://fx-port.com/docs/conventions#naming" }, { "code": "deprecated_field", "field": "offerId", "replacement": "offer_id", "message": "..." } ], "results": { "...": "..." } } ~~~ | Warning `code` | Meaning | Header | |---|---|---| | `deprecated_field` | The request used a previous spelling of a field. `replacement` is the name to use. | `Deprecation`, `X-FX-Deprecated-Fields` | | `ignored_field` | The request carried a field the endpoint does not read. It had no effect. | `X-FX-Ignored-Fields` | | `coerced_value` | A value was changed to a supported one; the message says how. | none | If a field you meant to use shows up as `ignored_field`, that is usually a typo or a wrong spelling: the API never rejects unknown fields, so this is how you find out. ### Responses keep their keys and add snake_case twins Response keys that were camelCase stay exactly as they were. Next to the keys you feed back into later calls, or compute with, the response adds the `snake_case` **twin**, with the same value: ~~~json { "requestId": "11fa601e", "request_id": "11fa601e", "suppliers": [{ "searchId": "fs_dfx_441e54ae-d7fe-414f-9fba-5415ee5ffb76", "search_id": "fs_dfx_441e54ae-d7fe-414f-9fba-5415ee5ffb76" }] } ~~~ Read the twin when it is there and fall back to the older key, so your code works with both: ~~~python search_id = supplier.get("search_id") or supplier["searchId"] ~~~ The reference marks every previous spelling as deprecated and names its replacement. The complete lists, generated from the API itself, are in [Legacy names and twins](https://fx-port.com/docs/legacy-names.md). Machine readers can fetch them from `GET /api/v1/conventions/naming` (no key needed). ### Get only snake_case Add `?naming=snake` to any request, or the header `X-FX-Naming: snake`, and every key of the answer is `snake_case` (`requestId` becomes `request_id`, `b2bPrice` becomes `b2b_price`). Values are never touched. The answer carries `X-FX-Naming: snake`. This is the simplest choice for a new integration written in a language that likes `snake_case`. Enumerated values keep the spelling documented for each endpoint. ### One concept, one name | Concept | Name | Notes | |---|---|---| | A flight search | `search_id` | Of the supplier entry the offer came from. Needed by price and book. | | A flight offer | `offer_id` | The `id` of the offer object. | | A supplier | `supplier_id` | For example `duffel_1`. Only searches take it as input; price and book derive it from the offer. | | A booking | `booking_id` | Endpoints that look a booking up accept the PNR, the supplier's order id or the FX-Port id. | | The airline reservation | `pnr` | The reference you give the traveler. | | The supplier's order | `order_id` | Needed to pay or cancel a held order. | | A hotel | `hotel_id` | The `id` of the hotel in the content, for example `test_hotel_do_not_book`. | | A hotel rate | `book_hash` | The token of one rate, from `get_rates` to `pre_book`. | | A hotel booking | `booking_id` | The same value as the `partner_order_id` of `pre_book` and `book_hotel`: it identifies the booking when you poll it, fetch its voucher and cancel it. | | An eSIM | `iccid` | The id of the SIM card. | | A request | `request_id` | Quote it to support. | ## Identifiers Ids are opaque strings. Do not parse them, do not assume a length, and do not treat one as a secret: a booking reference can be guessed or leaked, so [check ownership in your own backend](https://fx-port.com/docs/security.md#enforce-booking-ownership-in-your-own-backend). The API only ever returns objects of your own agency: asking for the id of another agency is answered with `403` or `404`, never with the data. ## Money - Amounts are JSON **numbers** in the **currency of your agency**, with two decimals of precision (`21679.66`). Do not use floating point arithmetic for money: round to the currency's minor unit and compare in cents. - The supplier's own currency and the rate used are next to the prices (`supplierCurrency`, `agencyCurrency`, `exchangeRate`), so you can show or audit both. - Prices come in the same names across products: `base_price` (before taxes), `taxes_and_fees`, `final_price` (what the traveler pays), `agency_commission` (the commission your agency earns on it) and `b2b_price` (what your agency is debited: `final_price` minus `agency_commission`). eSIM catalogues expose the same idea as `balance_debit_amount`. - In the sandbox no balance is debited and `GET /balances` is empty. ## Dates and times - Dates are `YYYY-MM-DD` and are the **local date** at the place they describe (a departure date is the date at the departure airport). - Timestamps are ISO 8601 in **UTC**, for example `2026-11-01T15:18:23Z`. A few supplier-derived values come without a zone and are UTC too (the eSIM order and SIM `created_at`, `usage` expiry). The one exception is `cached_at` in the eSIM compatible-devices `meta`, which is in server local time and only tells you how old the cache is. - Flight times in itineraries are the local time of the airport and carry no zone. ## Pagination | Where | Parameters | Answer | |---|---|---| | Account history, balance history | `page`, `limit` (1 to 100) | `pagination: { page, limit, total, hasMore }` | | eSIM bookings | `page`, `limit` (1 to 200), `search`, `all` | `pagination: { total, page, limit, pages }` | | Flight and hotel searches | none | Everything comes back in one answer. Narrow it with the filters of the endpoint. | Both pagination objects also carry `total_pages` and `has_more`, so you can read the same names everywhere. `total` is the real number of matching rows (for the account history it counts the rows the request can reach, up to a ceiling documented on its page). ## Enumerations Send the spellings the guides show (`cabin_class: "economy"`, `booking_type: "issue"`, `type: "adult"`). Where a value is not recognised the API answers `400` with a code, instead of silently replacing it by a default (older versions of the flight search did the latter). Responses use the spelling of each endpoint, which is documented with it. ## Request ids Every response carries an `X-Request-Id` header (the brief hand-over `503` during a deployment is the only exception), and every JSON body a `request_id`. Send your own `X-Request-Id` (8 to 64 characters of `A-Z a-z 0-9 . _ -`) and it is echoed back, which lets you join your logs with ours; otherwise one is generated (`req_` and 12 hex characters). Quote it when you contact support. Flight answers keep the 8-character `requestId` they always had, and their `request_id` repeats it. For every other endpoint, and for the `meta.request_id` of an error, the id is the one in the header. Either finds your request: send the header when you have it, and the body value when you only have the answer. ## Safe retries There is no `Idempotency-Key` header. Searches, prices and lookups can be repeated freely. Calls that create, pay or cancel must be repeated only after you have read the object back: see [Retrying safely](https://fx-port.com/docs/errors.md#retrying-safely). --- # Legacy names and twins > Every previous spelling a request still accepts and every snake_case twin a response adds, per endpoint, listed from the API's own map. Source: https://fx-port.com/docs/legacy-names FX-Port's names are `snake_case`. Older endpoints took, or returned, other spellings (`searchId`, `offerId`, `bookingType` ...). Nothing was removed: this page lists, **per endpoint**, every previous spelling a request still accepts and the `snake_case` twin a response adds next to its older key. The tables are generated from the API's own map, so they are always exact. The rules behind them are in [Conventions and naming](https://fx-port.com/docs/conventions.md#naming). > [!TIP] > New code should send the names in the second column of the request tables, and read the twins of the response tables. A request that uses a previous spelling still works and is answered with a `warnings` array that names the spelling to use. Add `?naming=snake` to any request to get every response key in `snake_case`. Machine readers can fetch the same map as JSON from `GET /api/v1/conventions/naming` (no key needed). ## Flights ### POST /api/v1/flights/book_flight **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `agentId` | `agent_id` | | `bookingType` | `booking_type` | | `commissionOverride` | `commission_override` | | `contact` | `contact_details` | | `contact_details.emailAddress` | `contact_details.email` | | `contact_details.mobile` | `contact_details.phone_number` | | `contact_details.phone` | `contact_details.phone_number` | | `contact_details.phoneNumber` | `contact_details.phone_number` | | `contactDetails` | `contact_details` | | `contacts` | `contact_details` | | `offerId` | `offer_id` | | `passengers[].born_on` | `passengers[].date_of_birth` | | `passengers[].bornOn` | `passengers[].date_of_birth` | | `passengers[].dateOfBirth` | `passengers[].date_of_birth` | | `passengers[].dob` | `passengers[].date_of_birth` | | `passengers[].documents` | `passengers[].identity_documents` | | `passengers[].family_name` | `passengers[].last_name` | | `passengers[].familyName` | `passengers[].last_name` | | `passengers[].firstName` | `passengers[].first_name` | | `passengers[].given_name` | `passengers[].first_name` | | `passengers[].givenName` | `passengers[].first_name` | | `passengers[].identity_documents[].documentNumber` | `passengers[].identity_documents[].number` | | `passengers[].identity_documents[].expires_on` | `passengers[].identity_documents[].expiry_date` | | `passengers[].identity_documents[].expiryDate` | `passengers[].identity_documents[].expiry_date` | | `passengers[].identity_documents[].issuing_country_code` | `passengers[].identity_documents[].issuing_country` | | `passengers[].identity_documents[].issuingCountry` | `passengers[].identity_documents[].issuing_country` | | `passengers[].identity_documents[].issuingCountryCode` | `passengers[].identity_documents[].issuing_country` | | `passengers[].identity_documents[].unique_identifier` | `passengers[].identity_documents[].number` | | `passengers[].identityDocuments` | `passengers[].identity_documents` | | `passengers[].lastName` | `passengers[].last_name` | | `passengers[].passengerType` | `passengers[].type` | | `passengers[].surname` | `passengers[].last_name` | | `searchId` | `search_id` | | `sendEmail` | `send_email` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `booking.reference` | `order_id` | | `exchangeRate` | `exchange_rate` | | `meta.supportHold` | `support_hold` | | `meta.supportVoid` | `support_void` | | `meta.voidDeadline` | `void_deadline` | | `offerId` | `offer_id` | | `passengers[].bornOn` | `date_of_birth` | | `passengers[].familyName` | `last_name` | | `passengers[].givenName` | `first_name` | | `passengers[].id` | `passenger_id` | | `passengers[].phoneNumber` | `phone_number` | | `passengers[].type` | `passenger_type` | | `pricing.agencyCommission` | `agency_commission` | | `pricing.b2bPrice` | `b2b_price` | | `pricing.basePrice` | `base_price` | | `pricing.finalPrice` | `final_price` | | `pricing.taxesAndFees` | `taxes_and_fees` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `searchId` | `search_id` | | `supplier.id` | `supplier_id` | | `supplier.name` | `supplier_name` | | `supplier.offerId` | `supplier_offer_id` | | `travelerPricing[].travelerId` | `passenger_id` | | `travelerPricing[].travelerType` | `passenger_type` | ### POST /api/v1/flights/cancel_order **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `order_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `meta.supportHold` | `support_hold` | | `meta.supportVoid` | `support_void` | | `meta.voidDeadline` | `void_deadline` | | `orderId` | `order_id` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `supplier.id` | `supplier_id` | | `supplier.name` | `supplier_name` | | `supplier.offerId` | `supplier_offer_id` | ### POST /api/v1/flights/cancel_quote **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `order_id` | | `supplierId` | `supplier_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `cancellation.cancellationId` | `cancellation_id` | | `cancellation.orderId` | `order_id` | | `exchangeRate` | `exchange_rate` | | `orderId` | `order_id` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | ### POST /api/v1/flights/confirm_cancel **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `cancellationId` | `cancellation_id` | | `orderId` | `order_id` | | `supplierId` | `supplier_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `confirmation.cancellationId` | `cancellation_id` | | `confirmation.orderId` | `order_id` | | `exchangeRate` | `exchange_rate` | | `orderId` | `order_id` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | ### POST /api/v1/flights/flexible_search **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `acdExemptCompanyId` | `acd_exempt_company_id` | | `agencyId` | `agency_id` | | `agentId` | `agent_id` | | `airline_exclude` | `excluded_airlines` | | `airline_include` | `included_airlines` | | `airlineExclude` | `excluded_airlines` | | `airlineInclude` | `included_airlines` | | `airlines` | `preferred_airlines` | | `cabinClass` | `cabin_class` | | `checkedBags` | `checked_bags` | | `departureDate` | `departure_date` | | `direct` | `nonstop` | | `exclude_airline` | `excluded_airlines` | | `exclude_airlines` | `excluded_airlines` | | `excludeAirlines` | `excluded_airlines` | | `excluded_airline` | `excluded_airlines` | | `excludedAirlines` | `excluded_airlines` | | `filters.airline_exclude` | `filters.excluded_airlines` | | `filters.airline_include` | `filters.included_airlines` | | `filters.airlineExclude` | `filters.excluded_airlines` | | `filters.airlineInclude` | `filters.included_airlines` | | `filters.airlines` | `filters.preferred_airlines` | | `filters.exclude_airline` | `filters.excluded_airlines` | | `filters.exclude_airlines` | `filters.excluded_airlines` | | `filters.excludeAirlines` | `filters.excluded_airlines` | | `filters.excluded_airline` | `filters.excluded_airlines` | | `filters.excludedAirlines` | `filters.excluded_airlines` | | `filters.include_airline` | `filters.included_airlines` | | `filters.include_airlines` | `filters.included_airlines` | | `filters.includeAirlines` | `filters.included_airlines` | | `filters.included_airline` | `filters.included_airlines` | | `filters.includedAirlines` | `filters.included_airlines` | | `filters.preferredAirlines` | `filters.preferred_airlines` | | `flexibleOnly` | `flexible_only` | | `fromCc` | `from_cc` | | `include_airline` | `included_airlines` | | `include_airlines` | `included_airlines` | | `includeAirlines` | `included_airlines` | | `included_airline` | `included_airlines` | | `includedAirlines` | `included_airlines` | | `loyalty_program_accounts[].accountNumber` | `loyalty_program_accounts[].account_number` | | `loyalty_program_accounts[].airline_code` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].airlineCode` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].airlineIataCode` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].family_name` | `loyalty_program_accounts[].last_name` | | `loyalty_program_accounts[].familyName` | `loyalty_program_accounts[].last_name` | | `loyalty_program_accounts[].firstName` | `loyalty_program_accounts[].first_name` | | `loyalty_program_accounts[].given_name` | `loyalty_program_accounts[].first_name` | | `loyalty_program_accounts[].givenName` | `loyalty_program_accounts[].first_name` | | `loyalty_program_accounts[].lastName` | `loyalty_program_accounts[].last_name` | | `loyalty_program_accounts[].membership_number` | `loyalty_program_accounts[].account_number` | | `loyalty_program_accounts[].membershipNumber` | `loyalty_program_accounts[].account_number` | | `loyalty_program_accounts[].program_owner` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].programOwner` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].surname` | `loyalty_program_accounts[].last_name` | | `loyaltyProgramAccounts` | `loyalty_program_accounts` | | `maxPrice` | `max_price` | | `maxSuppliers` | `max_suppliers` | | `passengers.childAges` | `passengers.child_ages` | | `passengers.infantAges` | `passengers.infant_ages` | | `passengers.seatedInfantAges` | `passengers.seated_infant_ages` | | `passengers.seatedInfants` | `passengers.seated_infants` | | `passengers.toddlers` | `passengers.seated_infants` | | `passengers.young` | `passengers.young_adults` | | `passengers.youngAdultAges` | `passengers.young_adult_ages` | | `passengers.youngAdults` | `passengers.young_adults` | | `preferredAirlines` | `preferred_airlines` | | `preferredSuppliers` | `preferred_suppliers` | | `returnDate` | `return_date` | | `segments[].date` | `segments[].departure_date` | | `segments[].departure` | `segments[].departure_date` | | `segments[].departureDate` | `segments[].departure_date` | | `supplierId` | `supplier_id` | | `supplierIds` | `supplier_ids` | | `toCc` | `to_cc` | | `travelers` | `passengers` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.exchangeRate` (events: supplier_result, supplier_start) | `exchange_rate` | | `data.meta.filtersApplied` (events: supplier_result, supplier_start) | `filters_applied` | | `data.meta.supplierId` (events: supplier_result, supplier_start) | `supplier_id` | | `data.meta.supportHold` (events: supplier_result, supplier_start) | `support_hold` | | `data.meta.supportVoid` (events: supplier_result, supplier_start) | `support_void` | | `data.meta.voidDeadline` (events: supplier_result, supplier_start) | `void_deadline` | | `data.processingTime` (events: supplier_result, supplier_start) | `processing_time` | | `data.requestId` (events: supplier_result, supplier_start) | `request_id` | | `data.results.offers[].id` (events: supplier_result, supplier_start) | `offer_id` | | `data.results.offers[].pricing.agencyCommission` (events: supplier_result, supplier_start) | `agency_commission` | | `data.results.offers[].pricing.b2bPrice` (events: supplier_result, supplier_start) | `b2b_price` | | `data.results.offers[].pricing.basePrice` (events: supplier_result, supplier_start) | `base_price` | | `data.results.offers[].pricing.finalPrice` (events: supplier_result, supplier_start) | `final_price` | | `data.results.offers[].pricing.taxesAndFees` (events: supplier_result, supplier_start) | `taxes_and_fees` | | `data.results.offers[].supportHold` (events: supplier_result, supplier_start) | `support_hold` | | `data.results.offers[].supportVoid` (events: supplier_result, supplier_start) | `support_void` | | `data.results.offers[].travelerPricing[].travelerId` (events: supplier_result, supplier_start) | `passenger_id` | | `data.results.offers[].travelerPricing[].travelerType` (events: supplier_result, supplier_start) | `passenger_type` | | `data.results.offers[].voidDeadline` (events: supplier_result, supplier_start) | `void_deadline` | | `data.searchId` (events: supplier_result, supplier_start) | `search_id` | | `data.searchType` (events: supplier_result, supplier_start) | `search_type` | | `failures[].supplierId` | `supplier_id` | | `offers[].id` (events: offers_chunk) | `offer_id` | | `offers[].pricing.agencyCommission` (events: offers_chunk) | `agency_commission` | | `offers[].pricing.b2bPrice` (events: offers_chunk) | `b2b_price` | | `offers[].pricing.basePrice` (events: offers_chunk) | `base_price` | | `offers[].pricing.finalPrice` (events: offers_chunk) | `final_price` | | `offers[].pricing.taxesAndFees` (events: offers_chunk) | `taxes_and_fees` | | `offers[].supportHold` (events: offers_chunk) | `support_hold` | | `offers[].supportVoid` (events: offers_chunk) | `support_void` | | `offers[].travelerPricing[].travelerId` (events: offers_chunk) | `passenger_id` | | `offers[].travelerPricing[].travelerType` (events: offers_chunk) | `passenger_type` | | `offers[].voidDeadline` (events: offers_chunk) | `void_deadline` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `searchType` | `search_type` | | `successfulSuppliers[].supplierId` (events: summary) | `supplier_id` | | `supplierId` (events: error, flex_prices, offers_chunk, supplier_complete, supplier_result, supplier_start) | `supplier_id` | | `suppliers[].exchangeRate` | `exchange_rate` | | `suppliers[].filtersApplied` | `filters_applied` | | `suppliers[].flexPrices.matrix[].agencyCommission` | `agency_commission` | | `suppliers[].flexPrices.matrix[].b2bPrice` | `b2b_price` | | `suppliers[].flexPrices.matrix[].basePrice` | `base_price` | | `suppliers[].flexPrices.matrix[].finalPrice` | `final_price` | | `suppliers[].flexPrices.matrix[].taxesAndFees` | `taxes_and_fees` | | `suppliers[].meta.processingTime` | `processing_time` | | `suppliers[].meta.supplierId` | `supplier_id` | | `suppliers[].meta.supplierName` | `supplier_name` | | `suppliers[].results.offers[].id` | `offer_id` | | `suppliers[].results.offers[].pricing.agencyCommission` | `agency_commission` | | `suppliers[].results.offers[].pricing.b2bPrice` | `b2b_price` | | `suppliers[].results.offers[].pricing.basePrice` | `base_price` | | `suppliers[].results.offers[].pricing.finalPrice` | `final_price` | | `suppliers[].results.offers[].pricing.taxesAndFees` | `taxes_and_fees` | | `suppliers[].results.offers[].supportHold` | `support_hold` | | `suppliers[].results.offers[].supportVoid` | `support_void` | | `suppliers[].results.offers[].travelerPricing[].travelerId` | `passenger_id` | | `suppliers[].results.offers[].travelerPricing[].travelerType` | `passenger_type` | | `suppliers[].results.offers[].voidDeadline` | `void_deadline` | | `suppliers[].searchId` | `search_id` | | `suppliers[].supportHold` | `support_hold` | | `suppliers[].supportVoid` | `support_void` | | `suppliers[].voidDeadline` | `void_deadline` | ### POST /api/v1/flights/get_acd_documents **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `acd.booking_id` | `pnr` | | `booking_id` | `pnr` | | `requestId` | `request_id` | ### POST /api/v1/flights/get_booking **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `includeApiResponse` | `include_api_response` | | `orderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `booking.amadeus_id` | `order_id` | | `booking.booking_id` | `pnr` | | `booking.bucket_link` | `pdf_url` | | `booking.bucket_link_no_price` | `pdf_url_no_price` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | ### POST /api/v1/flights/get_order_quote **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `order_id` | | `supplierId` | `supplier_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `exchangeRate` | `exchange_rate` | | `orderId` | `order_id` | | `orderQuote.bookingReference` | `pnr` | | `orderQuote.orderId` | `order_id` | | `orderQuote.pricing.agencyCommission` | `agency_commission` | | `orderQuote.pricing.b2bPrice` | `b2b_price` | | `orderQuote.pricing.basePrice` | `base_price` | | `orderQuote.pricing.finalPrice` | `final_price` | | `orderQuote.pricing.taxesAndFees` | `taxes_and_fees` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `supplier.id` | `supplier_id` | | `supplier.name` | `supplier_name` | ### POST /api/v1/flights/get_ticket **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `download_url` | `pdf_url` | | `download_url_no_price` | `pdf_url_no_price` | | `requestId` | `request_id` | ### POST /api/v1/flights/get_ticket_number **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `booking_id` | `pnr` | | `requestId` | `request_id` | | `supplier_order_id` | `order_id` | ### POST /api/v1/flights/pay_order **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `orderId` | `order_id` | | `paymentType` | `payment_type` | | `priceGuaranteed` | `price_guaranteed` | | `supplierId` | `supplier_id` | | `threeDSecureSessionId` | `three_d_secure_session_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `booking.reference` | `order_id` | | `exchangeRate` | `exchange_rate` | | `meta.supportHold` | `support_hold` | | `meta.supportVoid` | `support_void` | | `meta.voidDeadline` | `void_deadline` | | `orderId` | `order_id` | | `passengers[].bornOn` | `date_of_birth` | | `passengers[].familyName` | `last_name` | | `passengers[].givenName` | `first_name` | | `passengers[].id` | `passenger_id` | | `passengers[].phoneNumber` | `phone_number` | | `passengers[].type` | `passenger_type` | | `pricing.agencyCommission` | `agency_commission` | | `pricing.b2bPrice` | `b2b_price` | | `pricing.basePrice` | `base_price` | | `pricing.finalPrice` | `final_price` | | `pricing.taxesAndFees` | `taxes_and_fees` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `supplier.id` | `supplier_id` | | `supplier.name` | `supplier_name` | | `supplier.offerId` | `supplier_offer_id` | | `travelerPricing[].travelerId` | `passenger_id` | | `travelerPricing[].travelerType` | `passenger_type` | ### POST /api/v1/flights/price_flight **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `offerId` | `offer_id` | | `searchId` | `search_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `exchangeRate` | `exchange_rate` | | `offerId` | `offer_id` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `results.offers[].id` | `offer_id` | | `results.offers[].pricing.agencyCommission` | `agency_commission` | | `results.offers[].pricing.b2bPrice` | `b2b_price` | | `results.offers[].pricing.basePrice` | `base_price` | | `results.offers[].pricing.finalPrice` | `final_price` | | `results.offers[].pricing.taxesAndFees` | `taxes_and_fees` | | `results.offers[].supplier.id` | `supplier_id` | | `results.offers[].supplier.name` | `supplier_name` | | `results.offers[].supplier.originalId` | `supplier_offer_id` | | `results.offers[].supportHold` | `support_hold` | | `results.offers[].supportVoid` | `support_void` | | `results.offers[].travelerPricing[].travelerId` | `passenger_id` | | `results.offers[].travelerPricing[].travelerType` | `passenger_type` | | `results.offers[].voidDeadline` | `void_deadline` | ### POST /api/v1/flights/upload_acd_documents **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `agencyId` | `agency_id` | | `bookingId` | `booking_id` | | `document` | `documents` | | `file` | `documents` | | `files` | `documents` | | `orderId` | `booking_id` | | `traveler_id` | `traveler_ids` | | `travelerId` | `traveler_ids` | | `travelerIds` | `traveler_ids` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `acd.booking_id` | `pnr` | | `booking_id` | `pnr` | | `requestId` | `request_id` | ### POST /api/v1/get_flights **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `acdExemptCompanyId` | `acd_exempt_company_id` | | `agencyId` | `agency_id` | | `agentId` | `agent_id` | | `airline_exclude` | `excluded_airlines` | | `airline_include` | `included_airlines` | | `airlineExclude` | `excluded_airlines` | | `airlineInclude` | `included_airlines` | | `airlines` | `preferred_airlines` | | `cabinClass` | `cabin_class` | | `checkedBags` | `checked_bags` | | `departureDate` | `departure_date` | | `direct` | `nonstop` | | `exclude_airline` | `excluded_airlines` | | `exclude_airlines` | `excluded_airlines` | | `excludeAirlines` | `excluded_airlines` | | `excluded_airline` | `excluded_airlines` | | `excludedAirlines` | `excluded_airlines` | | `filters.airline_exclude` | `filters.excluded_airlines` | | `filters.airline_include` | `filters.included_airlines` | | `filters.airlineExclude` | `filters.excluded_airlines` | | `filters.airlineInclude` | `filters.included_airlines` | | `filters.airlines` | `filters.preferred_airlines` | | `filters.exclude_airline` | `filters.excluded_airlines` | | `filters.exclude_airlines` | `filters.excluded_airlines` | | `filters.excludeAirlines` | `filters.excluded_airlines` | | `filters.excluded_airline` | `filters.excluded_airlines` | | `filters.excludedAirlines` | `filters.excluded_airlines` | | `filters.include_airline` | `filters.included_airlines` | | `filters.include_airlines` | `filters.included_airlines` | | `filters.includeAirlines` | `filters.included_airlines` | | `filters.included_airline` | `filters.included_airlines` | | `filters.includedAirlines` | `filters.included_airlines` | | `filters.preferredAirlines` | `filters.preferred_airlines` | | `flexibleOnly` | `flexible_only` | | `fromCc` | `from_cc` | | `include_airline` | `included_airlines` | | `include_airlines` | `included_airlines` | | `includeAirlines` | `included_airlines` | | `included_airline` | `included_airlines` | | `includedAirlines` | `included_airlines` | | `loyalty_program_accounts[].accountNumber` | `loyalty_program_accounts[].account_number` | | `loyalty_program_accounts[].airline_code` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].airlineCode` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].airlineIataCode` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].family_name` | `loyalty_program_accounts[].last_name` | | `loyalty_program_accounts[].familyName` | `loyalty_program_accounts[].last_name` | | `loyalty_program_accounts[].firstName` | `loyalty_program_accounts[].first_name` | | `loyalty_program_accounts[].given_name` | `loyalty_program_accounts[].first_name` | | `loyalty_program_accounts[].givenName` | `loyalty_program_accounts[].first_name` | | `loyalty_program_accounts[].lastName` | `loyalty_program_accounts[].last_name` | | `loyalty_program_accounts[].membership_number` | `loyalty_program_accounts[].account_number` | | `loyalty_program_accounts[].membershipNumber` | `loyalty_program_accounts[].account_number` | | `loyalty_program_accounts[].program_owner` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].programOwner` | `loyalty_program_accounts[].airline_iata_code` | | `loyalty_program_accounts[].surname` | `loyalty_program_accounts[].last_name` | | `loyaltyProgramAccounts` | `loyalty_program_accounts` | | `maxPrice` | `max_price` | | `maxSuppliers` | `max_suppliers` | | `passengers.childAges` | `passengers.child_ages` | | `passengers.infantAges` | `passengers.infant_ages` | | `passengers.seatedInfantAges` | `passengers.seated_infant_ages` | | `passengers.seatedInfants` | `passengers.seated_infants` | | `passengers.toddlers` | `passengers.seated_infants` | | `passengers.young` | `passengers.young_adults` | | `passengers.youngAdultAges` | `passengers.young_adult_ages` | | `passengers.youngAdults` | `passengers.young_adults` | | `preferredAirlines` | `preferred_airlines` | | `preferredSuppliers` | `preferred_suppliers` | | `returnDate` | `return_date` | | `segments[].date` | `segments[].departure_date` | | `segments[].departure` | `segments[].departure_date` | | `segments[].departureDate` | `segments[].departure_date` | | `supplierId` | `supplier_id` | | `supplierIds` | `supplier_ids` | | `toCc` | `to_cc` | | `travelers` | `passengers` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.exchangeRate` (events: supplier_result, supplier_start) | `exchange_rate` | | `data.meta.filtersApplied` (events: supplier_result, supplier_start) | `filters_applied` | | `data.meta.supplierId` (events: supplier_result, supplier_start) | `supplier_id` | | `data.meta.supportHold` (events: supplier_result, supplier_start) | `support_hold` | | `data.meta.supportVoid` (events: supplier_result, supplier_start) | `support_void` | | `data.meta.voidDeadline` (events: supplier_result, supplier_start) | `void_deadline` | | `data.processingTime` (events: supplier_result, supplier_start) | `processing_time` | | `data.requestId` (events: supplier_result, supplier_start) | `request_id` | | `data.results.offers[].id` (events: supplier_result, supplier_start) | `offer_id` | | `data.results.offers[].pricing.agencyCommission` (events: supplier_result, supplier_start) | `agency_commission` | | `data.results.offers[].pricing.b2bPrice` (events: supplier_result, supplier_start) | `b2b_price` | | `data.results.offers[].pricing.basePrice` (events: supplier_result, supplier_start) | `base_price` | | `data.results.offers[].pricing.finalPrice` (events: supplier_result, supplier_start) | `final_price` | | `data.results.offers[].pricing.taxesAndFees` (events: supplier_result, supplier_start) | `taxes_and_fees` | | `data.results.offers[].supportHold` (events: supplier_result, supplier_start) | `support_hold` | | `data.results.offers[].supportVoid` (events: supplier_result, supplier_start) | `support_void` | | `data.results.offers[].travelerPricing[].travelerId` (events: supplier_result, supplier_start) | `passenger_id` | | `data.results.offers[].travelerPricing[].travelerType` (events: supplier_result, supplier_start) | `passenger_type` | | `data.results.offers[].voidDeadline` (events: supplier_result, supplier_start) | `void_deadline` | | `data.searchId` (events: supplier_result, supplier_start) | `search_id` | | `data.searchType` (events: supplier_result, supplier_start) | `search_type` | | `failures[].supplierId` | `supplier_id` | | `offers[].id` (events: offers_chunk) | `offer_id` | | `offers[].pricing.agencyCommission` (events: offers_chunk) | `agency_commission` | | `offers[].pricing.b2bPrice` (events: offers_chunk) | `b2b_price` | | `offers[].pricing.basePrice` (events: offers_chunk) | `base_price` | | `offers[].pricing.finalPrice` (events: offers_chunk) | `final_price` | | `offers[].pricing.taxesAndFees` (events: offers_chunk) | `taxes_and_fees` | | `offers[].supportHold` (events: offers_chunk) | `support_hold` | | `offers[].supportVoid` (events: offers_chunk) | `support_void` | | `offers[].travelerPricing[].travelerId` (events: offers_chunk) | `passenger_id` | | `offers[].travelerPricing[].travelerType` (events: offers_chunk) | `passenger_type` | | `offers[].voidDeadline` (events: offers_chunk) | `void_deadline` | | `processingTime` | `processing_time` | | `requestId` | `request_id` | | `searchType` | `search_type` | | `successfulSuppliers[].supplierId` (events: summary) | `supplier_id` | | `supplierId` (events: error, flex_prices, offers_chunk, supplier_complete, supplier_result, supplier_start) | `supplier_id` | | `suppliers[].exchangeRate` | `exchange_rate` | | `suppliers[].filtersApplied` | `filters_applied` | | `suppliers[].flexPrices.matrix[].agencyCommission` | `agency_commission` | | `suppliers[].flexPrices.matrix[].b2bPrice` | `b2b_price` | | `suppliers[].flexPrices.matrix[].basePrice` | `base_price` | | `suppliers[].flexPrices.matrix[].finalPrice` | `final_price` | | `suppliers[].flexPrices.matrix[].taxesAndFees` | `taxes_and_fees` | | `suppliers[].meta.processingTime` | `processing_time` | | `suppliers[].meta.supplierId` | `supplier_id` | | `suppliers[].meta.supplierName` | `supplier_name` | | `suppliers[].results.offers[].id` | `offer_id` | | `suppliers[].results.offers[].pricing.agencyCommission` | `agency_commission` | | `suppliers[].results.offers[].pricing.b2bPrice` | `b2b_price` | | `suppliers[].results.offers[].pricing.basePrice` | `base_price` | | `suppliers[].results.offers[].pricing.finalPrice` | `final_price` | | `suppliers[].results.offers[].pricing.taxesAndFees` | `taxes_and_fees` | | `suppliers[].results.offers[].supportHold` | `support_hold` | | `suppliers[].results.offers[].supportVoid` | `support_void` | | `suppliers[].results.offers[].travelerPricing[].travelerId` | `passenger_id` | | `suppliers[].results.offers[].travelerPricing[].travelerType` | `passenger_type` | | `suppliers[].results.offers[].voidDeadline` | `void_deadline` | | `suppliers[].searchId` | `search_id` | | `suppliers[].supportHold` | `support_hold` | | `suppliers[].supportVoid` | `support_void` | | `suppliers[].voidDeadline` | `void_deadline` | ## Hotels ### POST /api/v1/hotels/book_hotel **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `bookHash` | `book_hash` | | `bookingId` | `booking_id` | | `hotelId` | `hotel_id` | | `partner_order_id` | `booking_id` | | `partnerOrderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `balanceDebited` | `balance_debited` | | `bookingId` | `booking_id` | | `checkinDate` | `checkin` | | `checkoutDate` | `checkout` | | `hotelId` | `hotel_id` | | `partnerOrderId` | `partner_order_id` | | `payoutCreated` | `payout_created` | | `postProcessing.bucketLink` | `voucher_url` | | `pricing.agencyCommission` | `agency_commission` | | `pricing.currency` | `agency_currency` | | `pricing.supplierCurrency` | `supplier_currency` | | `pricing.totalPrice` | `total_price` | | `processingTime` | `processing_time` | | `transactionCreated` | `transaction_created` | ### POST /api/v1/hotels/cancel_hotel **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `bookingId` | `booking_id` | | `partner_order_id` | `booking_id` | | `partnerOrderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `bookingId` | `booking_id` | | `bookingStatus` | `booking_status` | | `processingTime` | `processing_time` | | `refund.agencyAmount` | `agency_refund_amount` | | `refund.cancellationType` | `cancellation_type` | | `refund.currency` | `agency_currency` | | `refund.providerAmount` | `supplier_refund_amount` | | `refund.providerCurrency` | `supplier_currency` | | `refund.travelerAmount` | `traveler_refund_amount` | | `refundProcessed` | `refund_processed` | ### POST /api/v1/hotels/details **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `detailLevel` | `detail_level` | | `hotelIds` | `hotel_ids` | | `ids` | `hotel_ids` | | `imagesLimit` | `images_limit` | ### POST /api/v1/hotels/get_hotel_info **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `detailLevel` | `detail_level` | | `hid` | `hotel_id` | | `hotelId` | `hotel_id` | | `hotelIds` | `hotel_ids` | | `id` | `hotel_id` | | `ids` | `hotel_ids` | | `imagesLimit` | `images_limit` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data[].checkIn` | `check_in_time` | | `data[].checkOut` | `check_out_time` | | `data[].hotelChain` | `hotel_chain` | | `data[].isClosed` | `is_closed` | | `data[].postalCode` | `postal_code` | | `data[].region.countryCode` | `country_code` | | `data[].starCertificate` | `star_certificate` | | `data[].starRating` | `star_rating` | ### POST /api/v1/hotels/get_rates **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `check_in` | `checkin` | | `check_out` | `checkout` | | `checkin_date` | `checkin` | | `checkinDate` | `checkin` | | `checkout_date` | `checkout` | | `checkoutDate` | `checkout` | | `hotelId` | `hotel_id` | | `id` | `hotel_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.hotelId` | `hotel_id` | | `data.rates[].bookHash` | `book_hash` | | `data.rates[].cancellationPolicy.freeCancellationBefore` | `free_cancellation_before` | | `data.rates[].dailyPrices` | `daily_prices` | | `data.rates[].exchangeRate` | `exchange_rate` | | `data.rates[].matchHash` | `match_hash` | | `data.rates[].pricing.agencyCommission` | `agency_commission` | | `data.rates[].pricing.agencyCurrency` | `agency_currency` | | `data.rates[].pricing.supplierCurrency` | `supplier_currency` | | `data.rates[].pricing.totalPrice` | `total_price` | | `processingTime` | `processing_time` | ### GET /api/v1/hotels/get_voucher **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `bookingId` | `booking_id` | | `partner_order_id` | `booking_id` | | `partnerOrderId` | `booking_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.bookingId` | `booking_id` | | `data.voucherUrl` | `voucher_url` | | `data.voucherUrlNoPrice` | `voucher_url_no_price` | | `data.voucherUrlWithPrice` | `voucher_url_with_price` | ### POST /api/v1/hotels/pre_book **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `bookHash` | `book_hash` | | `hash` | `book_hash` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.hotelId` | `hotel_id` | | `data.rates[].bookHash` | `book_hash` | | `data.rates[].cancellationPolicy.freeCancellationBefore` | `free_cancellation_before` | | `data.rates[].dailyPrices` | `daily_prices` | | `data.rates[].exchangeRate` | `exchange_rate` | | `data.rates[].matchHash` | `match_hash` | | `data.rates[].pricing.agencyCommission` | `agency_commission` | | `data.rates[].pricing.agencyCurrency` | `agency_currency` | | `data.rates[].pricing.supplierCurrency` | `supplier_currency` | | `data.rates[].pricing.totalPrice` | `total_price` | | `formData.partnerOrderId` | `partner_order_id` | | `partnerOrderId` | `booking_id` | | `partnerOrderId` | `partner_order_id` | | `priceChanged` | `price_changed` | ### POST /api/v1/hotels/search **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `detailLevel` | `detail_level` | | `hotelIds` | `hotel_ids` | | `ids` | `hotel_ids` | | `imagesLimit` | `images_limit` | ### POST /api/v1/hotels/search_advanced **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `check_in` | `checkin` | | `check_out` | `checkout` | | `checkin_date` | `checkin` | | `checkinDate` | `checkin` | | `checkout_date` | `checkout` | | `checkoutDate` | `checkout` | | `hotelId` | `hotel_id` | | `id` | `hotel_id` | ## eSIMs ### GET /api/v1/esims/bookings **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `pagination.pages` | `total_pages` | ### GET /api/v1/esims/bookings/{booking_id}/setup-instructions **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.code` | `order_id` | | `data.total_price` | `retail_price` | ### GET /api/v1/esims/bookings/{booking_id}/verify **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `isOwner` | `is_owner` | ### POST /api/v1/esims/orders **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `customer_email` | `email` | | `customer_first_name` | `first_name` | | `customer_last_name` | `last_name` | | `customer_phone` | `phone` | | `user_id` | `agent_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `balanceDebited` | `balance_debited` | | `data.order.code` | `order_id` | | `data.order.total_price` | `retail_price` | | `payoutCreated` | `payout_created` | | `transactionCreated` | `transaction_created` | ### GET /api/v1/esims/packages **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `filter[type]` | `type` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data[].balance_debit_amount` | `b2b_price` | | `data[].id` | `package_id` | | `data[].price` | `base_price` | | `meta.agency_currency` | `currency` | ### GET /api/v1/esims/topup-packages/{iccid} **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.data[].balance_debit_amount` | `b2b_price` | | `data.data[].id` | `package_id` | | `data.data[].total_price` | `retail_price` | ### POST /api/v1/esims/topups **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `customer_email` | `email` | | `customer_first_name` | `first_name` | | `customer_last_name` | `last_name` | | `customer_phone` | `phone` | | `user_id` | `agent_id` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `balanceDebited` | `balance_debited` | | `data.topup.code` | `order_id` | | `data.topup.total_price` | `retail_price` | | `payoutCreated` | `payout_created` | | `transactionCreated` | `transaction_created` | ## Account and suppliers ### GET /api/v1/balances **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data[].currency_code` | `currency` | | `meta.agencyId` | `agency_id` | | `meta.financialOperationsEnabled` | `financial_operations_enabled` | ### GET /api/v1/balances/{balance_id}/history **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `from` | `date_from` | | `from_date` | `date_from` | | `to` | `date_to` | | `to_date` | `date_to` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data[].balanceId` | `balance_id` | | `data[].bookingRecordId` | `booking_record_id` | | `data[].createdAt` | `created_at` | | `meta.agencyId` | `agency_id` | | `meta.balanceCurrency` | `balance_currency` | | `meta.balanceId` | `balance_id` | | `meta.financialOperationsEnabled` | `financial_operations_enabled` | | `pagination.hasMore` | `has_more` | ### GET /api/v1/bookings/{booking_id} **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data.agencyCommission` | `agency_commission` | | `data.agencyId` | `agency_id` | | `data.agentId` | `agent_id` | | `data.arrivalDate` | `arrival_date` | | `data.balanceOperations` | `balance_operations` | | `data.balanceOperations[].createdAt` | `created_at` | | `data.bookingId` | `booking_id` | | `data.bookingMode` | `booking_mode` | | `data.checkinDate` | `checkin` | | `data.checkoutDate` | `checkout` | | `data.createdAt` | `created_at` | | `data.customerEmail` | `customer_email` | | `data.flightNumber` | `flight_number` | | `data.hotelId` | `hotel_id` | | `data.hotelName` | `hotel_name` | | `data.netPrice` | `b2b_price` | | `data.orderId` | `order_id` | | `data.packageName` | `package_name` | | `data.providerName` | `provider_name` | | `data.receiptUrl` | `receipt_url` | | `data.requireAcd` | `require_acd` | | `data.supplierId` | `supplier_id` | | `data.totalPrice` | `total_price` | | `data.travelerPricing` | `traveler_pricing` | | `data.updatedAt` | `updated_at` | | `data.voucherUrl` | `voucher_url` | | `data.voucherUrlNoPrice` | `voucher_url_no_price` | | `data.voucherUrlWithPrice` | `voucher_url_with_price` | | `meta.agencyId` | `agency_id` | ### GET /api/v1/history **Request**: the previous spelling still works; use the name in the second column. | Previous spelling | Use | |---|---| | `from` | `date_from` | | `from_date` | `date_from` | | `to` | `date_to` | | `to_date` | `date_to` | **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `data[].agencyCommission` | `agency_commission` | | `data[].agencyId` | `agency_id` | | `data[].agentId` | `agent_id` | | `data[].arrivalDate` | `arrival_date` | | `data[].bookingId` | `booking_id` | | `data[].bookingMode` | `booking_mode` | | `data[].checkinDate` | `checkin` | | `data[].checkoutDate` | `checkout` | | `data[].createdAt` | `created_at` | | `data[].customerEmail` | `customer_email` | | `data[].flightNumber` | `flight_number` | | `data[].hotelId` | `hotel_id` | | `data[].hotelName` | `hotel_name` | | `data[].netPrice` | `b2b_price` | | `data[].orderId` | `order_id` | | `data[].packageName` | `package_name` | | `data[].providerName` | `provider_name` | | `data[].receiptUrl` | `receipt_url` | | `data[].requireAcd` | `require_acd` | | `data[].supplierId` | `supplier_id` | | `data[].totalPrice` | `total_price` | | `data[].updatedAt` | `updated_at` | | `data[].voucherUrl` | `voucher_url` | | `data[].voucherUrlNoPrice` | `voucher_url_no_price` | | `data[].voucherUrlWithPrice` | `voucher_url_with_price` | | `meta.agencyId` | `agency_id` | | `pagination.hasMore` | `has_more` | ### GET /api/v1/suppliers **Response**: the key stays, and the snake_case twin is added next to it. | Key | Twin | |---|---| | `meta.permissionRequired` | `permission_required` | | `products[].suppliers[].voidDeadline` | `void_deadline` | --- # Errors > The error shapes of the API, every error code you can meet, request ids and what to do about each kind of error. Source: https://fx-port.com/docs/errors Every failed call answers an HTTP status of 400 or more and a JSON body of the same shape, whichever endpoint it came from. Write one error handler against this page and it works for flights, hotels, eSIMs and the account. ## The error body ~~~json { "environment": "sandbox", "success": false, "errors": [ { "code": "invalid_airport_code", "type": "validation_error", "title": "Invalid airport code", "message": "origin must be a 3-letter IATA code; got 'ALGG'.", "field": "origin", "details": {}, "documentation_url": "https://fx-port.com/docs/errors#invalid_airport_code" } ], "meta": { "request_id": "req_8f3a9c1d2e4b", "status": 400 }, "request_id": "req_8f3a9c1d2e4b" } ~~~ | Key | Meaning | |---|---| | `environment` | `sandbox` or `live`: the environment of your key. | | `success` | Always `false` for an error. | | `errors` | The errors, most important first. **This is what you parse.** | | `errors[].code` | A stable, machine-readable code in `snake_case`. Branch on this, never on the message. | | `errors[].type` | The family of the error: it tells you [how to react](#what-to-do-about-each-type). | | `errors[].title` | A short label. | | `errors[].message` | A sentence for a human: what is wrong and how to fix it. The wording can change, the code does not. | | `errors[].field` | The request field the error is about, or `null`. | | `errors[].details` | Structured extras, or `null`: for example `available`, `required` and `currency` on an insufficient balance. | | `errors[].documentation_url` | The entry of this code in the [catalogue](#every-error-code) below. | | `meta`, `request_id` | The id of the request: `meta.request_id` is the `X-Request-Id` header. Quote it when you write to support (see [Request ids](https://fx-port.com/docs/conventions.md#request-ids)). | > [!NOTE] > The older keys of each endpoint (`detail`, `error`, `message`, `code`, `requestId` ...) stay in the body next to `errors`, so integrations written before `errors` existed keep working. Code that you write now should read `errors` only. The [older shapes](#older-shapes) are listed below. ## Check `success`, not only the status A few post-booking endpoints (paying an order, an order quote, a cancellation quote, confirming a cancellation) answer a refusal of the supplier with **HTTP 200 and `"success": false`**. The body then carries the same `errors` array. Treat a response as successful only when the status is 2xx **and** `success` is not `false`. ~~~python data = response.json() if not response.ok or data.get("success") is False: error = (data.get("errors") or [{}])[0] raise ApiError(response.status_code, error.get("code"), error.get("message"), data.get("request_id")) ~~~ ## What to do about each type | `type` | What it means | What to do | |---|---|---| | `authentication_error` | The key is missing, malformed, unknown or revoked. | Fix the key or the way it is sent. Retrying the same request fails again. | | `permission_error` | The key is valid but may not do this: it needs Read+Write permission, an allowed IP address or an approved agency. | Do not retry. Use the right key. | | `validation_error` | The request is wrong. | Read `errors[].field` and `message`, fix the request, send it again. Do not retry unchanged. | | `not_found` | The object does not exist for your agency (or the id is wrong). | Do not retry. Check the id and the environment of your key. | | `conflict` | The request is valid but cannot be done in the current state: already booked, already uploaded, duplicate. | Read the code and `details`. Do not repeat it blindly. | | `payment_error` | Your agency balance cannot cover the request (`402`, live only). | Top up the balance and repeat the request. `details` has `available`, `required` and `currency`. | | `rate_limit_error` | Too many requests. | Wait for the seconds in `Retry-After`, then retry with exponential backoff and jitter. See [Rate limits](https://fx-port.com/docs/rate-limits.md). | | `supplier_error` | The airline, hotel or eSIM supplier refused or could not answer. `code` is then often the supplier's own code (for example `4926` or `validation_type`), and `details.supplier_details` has what the supplier said. | Some are permanent (fare gone, sold out, invalid passenger data): fix the request or search again. Some are temporary: retry once or twice with backoff. | | `internal_error` | Something failed on our side. | Retry once after a short wait. If it persists, write to [dev@fx-port.com](mailto:dev@fx-port.com) with the `request_id`. | ## Codes A code is a short, stable `snake_case` word (`invalid_airport_code`, `read_write_permission_required`, `hotel_not_bookable_in_sandbox`). Three kinds exist: - **Specific codes** name the problem. They are listed in the [catalogue](#every-error-code) below and on the page of each endpoint. New ones can be added; the ones that exist are never renamed. - **Generic codes** (`bad_request`, `not_found`, `conflict`, `internal_error` ...) stand for the HTTP status when no more specific code applies. Read the `message`. - **Supplier codes** pass through what the airline, hotel or eSIM supplier answered, with `type: "supplier_error"`: sometimes a number (`"4926"`), sometimes a word (`validation_type`). They are not in the catalogue because the suppliers own them. Handle them by `type`, and show the `message` when you need to tell a human. Write your handler with a `switch` on the codes you care about and a default that falls back on `type`. ## HTTP statuses | Status | Meaning | |---|---| | `200` | Success. Creations answer 200 too, not 201. A few post-booking endpoints also use it for supplier refusals: check `success`. | | `202` | The eSIM catalogue or device list is still being prepared (`success: false`, empty `data`). Retry in a few seconds. | | `207` | Partial success: an ACD document upload stored some files and not others. `success` is `true`; read `results`. | | `400` | The request is not valid (invalid JSON, a missing or wrong field, a business rule). The bad value is rejected before any supplier is called. | | `401` | No key, a malformed key or an unknown key. | | `402` | Insufficient balance (live only). | | `403` | The key may not do this (permission, IP address, agency not approved). | | `404` | No such route or object for your agency. | | `405` | The route exists but not for this HTTP method. | | `409` | The request conflicts with the state of the object (duplicate, already done). | | `410` | An offer has expired: search and price again. | | `413` | The body is larger than the endpoint accepts. | | `415` | The body is not in a format the endpoint accepts (for example an ACD upload that is not multipart). | | `422` | A query or path parameter is missing or has the wrong type or range. One entry per problem in `errors`. | | `429` | Too many requests. Wait for `Retry-After`. | | `500` | An unexpected error on our side. | | `502`, `503`, `504` | A supplier or the service is temporarily unavailable. Retry with backoff. | ## Errors that do not come from the API The API is served through Cloudflare. A few answers are generated before your request reaches FX-Port, and they are not JSON: - **`403` with an HTML page and "error code: 1010"**: Cloudflare blocked the default `User-Agent` of your HTTP library. The default agents of Python's `urllib` and of Perl's `libwww-perl` are blocked. Send a `User-Agent` of your own (for example `my-company-booking/1.0`). The other clients we tested work with their default agent: curl, Wget, Python `requests`, `httpx` and `aiohttp`, Node `fetch` and axios, Go, Java, OkHttp, Apache HttpClient, PHP Guzzle, Ruby, Dart, Postman, Insomnia and HTTPie. - **`301` from `http://`**: use `https://` only. - **`307` without a body** on a URL that ends with a slash: remove the trailing slash. - **`502`, `504` or `524` with an HTML page**: the edge could not reach the API in time. Treat them like any other temporary failure and retry with backoff. Parse the body only when the `Content-Type` is `application/json`. ## Retrying safely | Call | Safe to repeat? | |---|---| | Searches, prices, rates, lookups, listings (`GET` and read `POST`) | Yes. | | `book_flight`, `pay_order`, `cancel_order`, `confirm_cancel`, `book_hotel`, `cancel_hotel`, eSIM `orders` and `topups` | **No, not blindly.** The first call may have succeeded although the answer never reached you. Read the booking back first (`get_booking`, `GET /bookings/{id}`) and repeat only if it does not exist. FX-Port rejects an obvious duplicate with `409`, but that is a safety net, not an idempotency key: there is no `Idempotency-Key` header. | For everything you may repeat, use exponential backoff with jitter and honour `Retry-After`: **Python** ~~~python import random import time import requests RETRYABLE = {429, 500, 502, 503, 504} def call_with_retry(method, url, attempts=5, **kwargs): for attempt in range(attempts): response = requests.request(method, url, timeout=90, **kwargs) if response.status_code not in RETRYABLE or attempt == attempts - 1: return response wait = float(response.headers.get("Retry-After", 0)) or min(30, 2 ** attempt) time.sleep(wait + random.uniform(0, 1)) ~~~ **JavaScript** ~~~js const RETRYABLE = new Set([429, 500, 502, 503, 504]); async function callWithRetry(url, options, attempts = 5) { for (let attempt = 0; attempt < attempts; attempt++) { const response = await fetch(url, options); if (!RETRYABLE.has(response.status) || attempt === attempts - 1) return response; const wait = Number(response.headers.get("retry-after")) || Math.min(30, 2 ** attempt); await new Promise((resolve) => setTimeout(resolve, (wait + Math.random()) * 1000)); } } ~~~ ## Older shapes Before `errors` existed, each part of the API had its own error body. They are all still returned, next to `errors`: | Shape | Seen on | |---|---| | `{"detail": "text"}` | Many flight and hotel errors, `404`, `405` | | `{"detail": {"error": "code_or_text", "message": "..."}}` | Authentication, permissions, account, flights, hotels | | `{"detail": [{"loc": [...], "msg": "...", "type": "..."}]}` | Validation of query and path parameters (`422`) | | `{"success": false, "error": "text"}` | eSIM endpoints | | `{"success": false, "error": "code", "message": "...", "details": {...}}` | eSIM errors that have a code (for example `insufficient_balance`) | | `{"success": false, "status": "failed", "requestId": "...", "error": "code", "code": "CODE", "message": "..."}` | ACD document endpoints | | `{"success": false, "error": {"message": "...", "code": "...", "type": "...", "details": [...]}}` | A supplier's own refusal passed through (flights) | | `{"detail": {"errors": [...], "meta": {...}}}` | `429`, nested under `detail` | ## Report a problem Write to [dev@fx-port.com](mailto:dev@fx-port.com) and include the `X-Request-Id` header (or the `request_id` of the body), the environment, the endpoint and the time. With the request id we can find your request immediately. ## Every error code The catalogue below lists every code the API can answer, its HTTP status, its type and what it means. | Code | HTTP status | Type | Meaning | | --- | --- | --- | --- | | `already_cancelled` | 200 | `supplier_error` | The order was cancelled before (a `200` with `success: false`). | | `order_not_cancellable` | 200 | `supplier_error` | The airline or the fare does not allow cancelling the order through the API (a `200` with `success: false`). | | `order_type_not_eligible_for_payment` | 200 | `supplier_error` | A Duffel order that was bought instantly cannot be paid later (a `200` with `success: false`). | | `request_failed` | 200 | `supplier_error` | The call answered HTTP 200 with success false and no more specific code: read the message. | | `agency_id_not_allowed` | 400 | `validation_error` | An API key sent `agency_id`. The agency is taken from the key: remove the parameter. | | `bad_request` | 400 | `validation_error` | The request is not valid and no more specific code is available; read the message. | | `booking_mode_not_available` | 400 | `validation_error` | The body of `book_hotel` has a `booking_mode` other than `standard`. A booking made with an API key always books and confirms the rate: do not send `booking_mode`. | | `checkout_before_checkin` | 400 | `validation_error` | `checkout` is not after `checkin`. | | `date_in_past` | 400 | `validation_error` | `checkin` is before yesterday (yesterday is accepted for time zones). | | `date_too_far` | 400 | `validation_error` | `checkin` is more than 400 days ahead. `errors[0].details.max_days_ahead` is 400. | | `document_count_mismatch` | 400 | `validation_error` | `traveler_ids` and `documents` have different lengths: they are matched by order. The body has `received_traveler_ids` and `received_documents`. | | `duplicate_traveler_id` | 400 | `validation_error` | A traveler appears twice in one upload. The body has `duplicate_traveler_ids`. | | `empty_file` | 400 | `validation_error` | A file has no content. | | `hold_not_supported` | 400 | `validation_error` | `booking_type: hold` on an offer that cannot be held: read `supportHold` on the priced offer. | | `incorrect_guests_number` | 400 | `supplier_error` | The guests of `rooms` do not match the searched occupancy, or a child lacks `is_child` and `age`. | | `invalid_adult_count` | 400 | `validation_error` | A room has fewer than 1 or more than 6 adults, or `adults` is not a whole number (a whole number written as text is read). | | `invalid_agent_id` | 400 | `validation_error` | `agent_id` is not the UUID of a user of your agency. | | `invalid_airline_filter` | 400 | `validation_error` | An airline list has more than 20 entries or an entry that is not an IATA airline code. | | `invalid_airport_code` | 400 | `validation_error` | An airport or city code is not three ASCII letters (`origin` and `destination` take up to five codes, as `"ALG,ORN"` or a list; a segment takes one), or the destination equals the origin. | | `invalid_body` | 400 | `validation_error` | The body is valid JSON but not the kind of value the endpoint expects (for example an array instead of an object). | | `invalid_booking_id` | 400 | `validation_error` | The booking reference is not a valid booking id: it is empty, too long (100 characters on the eSIM and account endpoints, 256 on the hotel ones) or has characters a booking id never has (letters, digits and `.`, `_`, `:`, `-` are allowed, `=`, `+` and `~` too on the eSIM and account endpoints). | | `invalid_booking_type` | 400 | `validation_error` | `booking_type` is not `issue` or `hold` (any letter case). An unknown value never becomes an order. | | `invalid_cabin_class` | 400 | `validation_error` | `cabin_class` is not one of `economy`, `premium_economy`, `business`, `first`, `any`, once case, spaces, dashes and underscores are ignored (`Premium Economy`, `premium-economy` and `premium` are read as `premium_economy`). `errors[0].details.allowed` lists the values. | | `invalid_cancellation_id` | 400 | `validation_error` | `cancellation_id` is not a valid token. | | `invalid_child_age` | 400 | `validation_error` | A child age is not a whole number from 0 to 17 (a whole number written as text is read). | | `invalid_commission_override` | 400 | `validation_error` | `commission_override` is not `{ type: percentage \| fixed, value >= 0 }`. | | `invalid_country` | 400 | `validation_error` | `country` is neither a country slug such as `france` nor a country name such as `United States`: it has punctuation, control characters or non-Latin letters. | | `invalid_country_code` | 400 | `validation_error` | `from_cc`, `to_cc` or an entry of `countries` is not exactly two ASCII letters; or the `issuing_country` of an identity document is no country value (2 to 4 letters, optionally `-` and 1 to 3 letters or digits: `DZ`, `GB-SCT`, `USPR`). | | `invalid_currency` | 400 | `validation_error` | A currency override is not a three-letter ISO 4217 code. | | `invalid_date` | 400 | `validation_error` | A date is not a real calendar date written `YYYY-MM-DD`. A date written with a midnight time (`2026-12-01T00:00:00Z`) is read as that day, not refused; any other time of day is. | | `invalid_date_range` | 400 | `validation_error` | `date_from` is after `date_to`. | | `invalid_description` | 400 | `validation_error` | `description` is not a string of at most 500 characters. | | `invalid_detail_level` | 400 | `validation_error` | `detail_level` is not `minimal` (alias `mini`) or `full`. | | `invalid_email` | 400 | `validation_error` | `email` is not an email address (at most 254 characters). | | `invalid_field_type` | 400 | `validation_error` | A field has the wrong JSON type. `errors[0].details` names the expected and the received type. | | `invalid_fields` | 400 | `validation_error` | `fields` is not a list of at most 40 top-level field names separated by commas. | | `invalid_gender` | 400 | `validation_error` | A passenger `gender` is none of `M`, `F`, `MALE`, `FEMALE` (any letter case), `X`, `U` and `UNSPECIFIED` (a passport marked X). | | `invalid_guests` | 400 | `validation_error` | `guests` is not a non-empty list with one `{adults, children}` object per room. | | `invalid_hotel_id` | 400 | `validation_error` | A hotel id is not 1 to 128 characters: letters, digits and `_ . : -` (spaces around it are trimmed; a positive JSON integer, the numeric supplier id, is read as its text). | | `invalid_hotel_ids` | 400 | `validation_error` | `hotel_ids` is not a list of hotel ids. | | `invalid_iccid` | 400 | `validation_error` | The ICCID is not 18 to 22 digits. Groups of spaces or hyphens are dropped first, but any other character makes it invalid. | | `invalid_identifier` | 400 | `validation_error` | An id is not a valid token: letters, digits and `- _ . : = ~`, 1 to 128 characters. | | `invalid_images_limit` | 400 | `validation_error` | `images_limit` is not an integer from 0 to 30 (`get_hotel_info`, `POST /hotels/search` and `POST /hotels/details`). `errors[0].details` carries `min` and `max`. | | `invalid_is_child` | 400 | `validation_error` | `is_child` of a guest of `book_hotel` is not `true` or `false` (the text `true` or `false`, `1` and `0` are read as the boolean). `errors[0].field` names the guest, for example `rooms[0].guests[1].is_child`. | | `invalid_json` | 400 | `validation_error` | The body is not valid JSON. | | `invalid_language` | 400 | `validation_error` | `language` is not a language code such as `en`, `fr` or `zh_CN`. A longer tag such as `zh-Hans-CN` or `es-419` is read; `en-USA`, a 3-letter code or a text with spaces is refused. | | `invalid_latitude` | 400 | `validation_error` | `latitude` is not a number from -90 to 90 (a number written as text is read). | | `invalid_limit` | 400 | `validation_error` | `limit` of the name search is not a whole number from 1 to 100 (a whole number written as text is read). `errors[0].details` carries `min` and `max` when the number is too large. `POST /hotels/search-by-name` caps a number above 100 (with a `coerced_value` warning); `GET /hotels/name-search` refuses it. | | `invalid_locale` | 400 | `validation_error` | `locale` is not a language code or locale such as `en`, `zh-Hans`, `pt_BR` or `fr_FR.UTF-8` (or an `Accept-Language` value). | | `invalid_longitude` | 400 | `validation_error` | `longitude` is not a number from -180 to 180 (a number written as text is read). | | `invalid_min_score` | 400 | `validation_error` | `min_score` of the name search is not a number from 0 to 100 (a number written as text is read). `errors[0].details` carries `min` and `max`. | | `invalid_multipart` | 400 | `validation_error` | The multipart body cannot be read, or has more than 40 files, 64 fields or a text field over 64 KiB. | | `invalid_name` | 400 | `validation_error` | `first_name` or `last_name` is not a string of at most 100 characters. | | `invalid_package_id` | 400 | `validation_error` | `package_id` is not a package id: 1 to 128 letters, digits, `-`, `_` or `.`. | | `invalid_passenger_age` | 400 | `validation_error` | A passenger age is not a whole number from 0 to 17. Numeric text, integral floats and a fraction (`0.5`, read as the years completed) are read with a `coerced_value` warning; words, booleans, negatives and ages above 17 are refused. | | `invalid_passenger_count` | 400 | `validation_error` | A passenger count is not a whole number from 0 to 99. Numeric text and integral floats (`"2"`, `2.0`) are read with a `coerced_value` warning; words, fractions, booleans, negatives and bigger numbers are refused. | | `invalid_passenger_type` | 400 | `validation_error` | A passenger `type` is not one of `adult`, `senior`, `young_adult`, `child`, `seated_infant`, `infant` (or an accepted synonym). | | `invalid_payment_type` | 400 | `validation_error` | `payment_type` is not `balance`, `card` or `arc_bsp_cash`. | | `invalid_phone` | 400 | `validation_error` | `phone` has fewer than 3 digits or more than 32 characters. | | `invalid_phone_number` | 400 | `validation_error` | `contact_details.phone_number` is not a phone number: digits with spaces, dots, dashes, slashes and brackets, one leading `+`, 5 to 24 digits and 32 characters at most. A dial code alone (`+213`) is refused. | | `invalid_price_increase` | 400 | `validation_error` | `price_increase_percent` is not a number from 0 to 100 (a number written as text is read; `null` means not set). | | `invalid_product` | 400 | `validation_error` | `product` is not one of `all`, `esims`, `flights`, `hotels`, `transfers` (any case; the singular names are read as the plural). The error lists them in `allowed`. | | `invalid_quantity` | 400 | `validation_error` | `quantity` is not an integer between 1 and 50. The value is otherwise ignored: every order creates one eSIM. | | `invalid_query` | 400 | `validation_error` | `query` is not a text of the right length: 2 to 100 characters for the name search, 1 to 64 for the location search (which answers this code with HTTP 422). | | `invalid_radius` | 400 | `validation_error` | `radius` is not a whole number of meters (a boolean, a fraction, `1e3`, text that is not plain digits). A whole number written as text is read; a number outside 100 to 70,000 is not an error: it is moved to the nearest bound. | | `invalid_rate_hash` | 400 | `validation_error` | `book_hash` is not a rate token taken from `get_rates`. | | `invalid_replace_flag` | 400 | `validation_error` | `replace` is not `true` or `false` (`1`, `yes`, `on`, `0`, `no`, `off` are read too). | | `invalid_request` | 400 | `validation_error` | The body of `book_hotel` names two booking ids: `partner.partner_order_id` differs from `booking_id`. Send the booking id once, as `booking_id`. (A `book_hash` or `hotel_id` that differs from the pre-booked offer is not an error: it is ignored, with an `ignored_field` warning.) | | `invalid_residency` | 400 | `validation_error` | `residency` is not a two-letter ISO 3166-1 country code the hotel supplier knows (`zz`, `usa`, `gbr`, an empty text). A sub-territory code such as `GB-SCT`, `USPR` or `PT-MA` is read as its country, not refused. | | `invalid_rooms` | 400 | `validation_error` | `rooms` of `book_hotel` is not a non-empty list of `{guests: [...]}` objects with 1 to 10 guests each. | | `invalid_search` | 400 | `validation_error` | `search` is longer than 100 characters or contains control characters. | | `invalid_segments` | 400 | `validation_error` | `segments` is not a list of 1 to 6 flights. | | `invalid_send_email` | 400 | `validation_error` | `send_email` of `book_hotel` is not `true` or `false`. The text `true` or `false` (also `1`, `0`, `yes`, `no`, `on`, `off`) is read as the boolean, `null` means the default; any other value is refused before anything is booked. | | `invalid_status` | 400 | `validation_error` | The booking is not `confirmed`: only a confirmed booking can be cancelled. `pending`, `failed` and already cancelled bookings cannot. | | `invalid_supplier_selection` | 400 | `validation_error` | A `supplier_id` is unknown, disabled, not a valid token or cannot do what the call needs (flexible search). `availableSuppliers` lists the usable ids. | | `invalid_type` | 400 | `validation_error` | `type` is not a short name such as `local` or `global`. | | `invalid_url` | 400 | `validation_error` | The `url` of a search is longer than 512 characters or is not a path-style search. | | `missing_booking_id` | 400 | `validation_error` | No `booking_id` (a JSON field for `get_acd_documents`, a form field for `upload_acd_documents`). | | `missing_documents` | 400 | `validation_error` | An upload without a file in `documents`. | | `missing_field` | 400 | `validation_error` | A required field is missing. | | `missing_search_params` | 400 | `validation_error` | A search names neither a `url`, nor `segments`, nor the three fields `origin`, `destination` and `departure_date`. | | `missing_traveler_ids` | 400 | `validation_error` | An upload without `traveler_ids`. | | `order_not_found` | 400 | `supplier_error` | The supplier has no order for this booking id: `pre_book` did not open one (the hotel is not bookable) or the order expired. | | `rate_not_found` | 400 | `supplier_error` | The supplier does not know the rate token any more: the rate expired, was never valid, or its price rose by more than `price_increase_percent`. Call `get_rates` again. | | `return_before_departure` | 400 | `validation_error` | The return date, or the date of a later segment, is before the previous flight. | | `stay_too_long` | 400 | `validation_error` | The stay is longer than 30 nights, the longest the supplier prices. | | `supplier_passenger_combination_unsupported` | 400 | `supplier_error` | No selected supplier can serve the passenger mix (some suppliers need one adult or senior for every passenger under two). | | `too_many_children` | 400 | `validation_error` | A room has more than 4 children. | | `too_many_documents` | 400 | `validation_error` | More files than travelers on the booking. The body has `expected_travelers` and `received_documents`. | | `too_many_guests` | 400 | `validation_error` | A room has more than 10 guests. | | `too_many_iccids` | 400 | `validation_error` | `iccids` lists more ICCIDs than an API key may ask for at once (50). | | `too_many_ids` | 400 | `validation_error` | More than 200 hotel ids in `hotel_ids` of `get_hotel_info`. | | `too_many_passengers` | 400 | `validation_error` | A booking has more than 18 passengers. | | `too_many_rooms` | 400 | `validation_error` | More than 9 rooms in one request. | | `traveler_rule_violation` | 400 | `validation_error` | The passenger mix breaks a rule: more than 9 seated passengers, more lap infants than adults and seniors, or more seated infants than adults and seniors. The body keeps its older flat shape (`error: TRAVELER_RULE_VIOLATION`) with `violations[]`. | | `unknown_traveler_id` | 400 | `validation_error` | A traveler id is not on the booking. `results[].error.expected_traveler_ids` lists the valid ones. | | `unsupported_supplier` | 400 | `validation_error` | `get_ticket_number`: the booking is not an Amadeus, Travelport or Duffel booking, so its e-ticket number cannot be asked. Read the booking with `get_booking`. | | `validation_failed` | 400 | `validation_error` | Several files of one upload have different problems; nothing was stored. Read `results[].error`. | | `authentication_required` | 401 | `authentication_error` | No API key was sent. | | `invalid_api_key` | 401 | `authentication_error` | The key is unknown, revoked or expired. | | `invalid_api_key_format` | 401 | `authentication_error` | The key does not have the shape of an FX-Port key: fxp_live_ or fxp_test_ followed by 16 to 64 letters and digits. | | `invalid_token` | 401 | `authentication_error` | The bearer token is neither an API key nor a valid dashboard session (eSIM endpoints accept both). | | `unauthorized` | 401 | `authentication_error` | The request is not authenticated and no more specific reason is available. | | `insufficient_balance` | 402 | `payment_error` | The balance of your agency is lower than the amount to debit. Live only: the sandbox debits nothing. Top up the balance, then repeat the request. | | `acd_exemption_currency` | 403 | `permission_error` | ACD exemptions exist only for agencies with a DZD account. | | `acd_exemption_invalid` | 403 | `permission_error` | `acd_exempt_company_id` is not linked to your agency, or is no longer approved. A search with it answers `403`; `book_flight` answers `409` with the same code when the exemption of the search is not valid any more (search again without it). | | `agency_not_active` | 403 | `permission_error` | The agency of the key is not approved for this environment yet. | | `endpoint_not_available` | 403 | `permission_error` | The endpoint is for FX-Port's own applications and is not available to API keys. | | `forbidden` | 403 | `permission_error` | The key may not do this and no more specific reason is available. | | `ip_not_allowed` | 403 | `permission_error` | The key has an IP allow-list and the request came from another address. | | `read_write_permission_required` | 403 | `permission_error` | The endpoint changes data and the key has read permission only. | | `balance_not_found` | 404 | `not_found` | No balance of your agency has this id (live). The sandbox has no balances and answers any id with an empty list. | | `booking_not_found` | 404 | `not_found` | No booking of your agency in this environment has this reference (for the product asked, when `product` is set). | | `hotel_not_found` | 404 | `not_found` | The supplier has no hotel with this id. | | `iccid_not_found` | 404 | `not_found` | The ICCID is not an eSIM that your agency ordered in this environment. | | `not_found` | 404 | `not_found` | No such route or object. | | `offer_not_found` | 404 | `not_found` | The offer is not in that search: it comes from another supplier entry or another search. | | `offer_not_priced` | 404 | `not_found` | The offer was never priced, or its priced version is older than 12 hours: call `price_flight` first. | | `pdf_not_found` | 404 | `not_found` | The booking exists but its voucher is not made yet. It is made right after the booking is `confirmed`, sometimes up to 10 seconds later: call `get_voucher` again in 2 seconds. | | `search_not_found` | 404 | `not_found` | The `search_id` is unknown, older than 24 hours, a `requestId`, or belongs to another agency (the answer is the same). | | `method_not_allowed` | 405 | `validation_error` | The route exists but not for this HTTP method. | | `acd_not_required` | 409 | `conflict` | The booking does not need ACD documents (`get_acd_documents` answers `200` for it; only the upload is refused). | | `acd_waived` | 409 | `conflict` | FX-Port marked ACD as not required for this booking. | | `booking_in_progress` | 409 | `conflict` | Another request is booking this offer right now. Read the booking before you retry. | | `booking_lock_failed` | 409 | `conflict` | The offer could not be locked for booking. Read the booking before you retry. | | `booking_not_eligible` | 409 | `conflict` | The booking is cancelled, failed, voided, refunded or expired. The body has `booking_status`. | | `cached_offer_required` | 409 | `conflict` | The offer locked by `pre_book` is not found: it is older than one hour (live), or another agency locked it. Lock the rate again with `pre_book` and your own key, then book. `errors[0].details.partner_order_id` is the id you sent. | | `concurrent_update` | 409 | `conflict` | Another request changed the documents of this booking at the same moment. Read the state and send what is still missing. | | `conflict` | 409 | `conflict` | The request conflicts with the state of the object and no more specific code is available; read the message. | | `document_already_uploaded` | 409 | `conflict` | The traveler already has a passport. Nothing was changed: send `replace=true` to replace it. | | `duplicate_booking` | 409 | `conflict` | A booking already exists for this id. Do not call `book_hotel` again: read its status with `GET /bookings/{booking_id}?product=hotels`. `errors[0].details` carries the `booking_id` and, for the agency that owns the booking, its `status`. | | `duplicate_request` | 409 | `conflict` | An order for the same package, or a topup of the same eSIM and package, is already being processed; or, on live, the latest confirmed order of your agency is for the same package (topup: the latest confirmed topup of the same eSIM) and is less than 60 seconds old. | | `hotel_not_bookable_in_sandbox` | 409 | `conflict` | A sandbox key can book the supplier test hotels only: `test_hotel_do_not_book` (any residency) and `test_hotel` (residency `dz`, `ru` or `us`). | | `invalid_booking_status` | 409 | `conflict` | The booking is not in the status the call needs (`cancel_order` needs `awaiting_payment`). | | `offer_already_booked` | 409 | `supplier_error` | The supplier says this offer was already booked. | | `offer_request_already_booked` | 409 | `supplier_error` | The supplier says this search (Duffel: one booking per offer request) was already booked. Search again. | | `order_already_exists` | 409 | `supplier_error` | The supplier says an order for this booking already exists. | | `travelers_not_available` | 409 | `conflict` | The booking has no traveler data, so files cannot be matched. Contact support. | | `trusted_offer_pricing_missing` | 409 | `conflict` | The locked offer has no usable price: lock the rate again with `pre_book`. | | `gone` | 410 | `not_found` | The object has expired (for example an offer): search and price again. | | `offer_expired` | 410 | `not_found` | The supplier says the offer is gone. Search and price again. | | `offer_incomplete` | 410 | `not_found` | The saved offer cannot be used any more. Search again. | | `supplier_not_found` | 410 | `supplier_error` | The supplier of the saved offer is no longer available. Search again. | | `technology_type_unknown` | 410 | `not_found` | The saved offer has a supplier technology FX-Port does not know any more. Search again. | | `file_too_large` | 413 | `validation_error` | A file is over 10 MiB (10,485,760 bytes). The error has `max_file_size_bytes`. | | `request_too_large` | 413 | `validation_error` | The body is larger than the endpoint accepts. | | `unsupported_file_type` | 415 | `validation_error` | The content of a file is not a PDF or a supported image (JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000, JPEG XL). The name and the `Content-Type` are not looked at. | | `unsupported_media_type` | 415 | `validation_error` | The body is not in a format the endpoint accepts (for example an upload that is not multipart/form-data). | | `born_on_does_not_match` | 422 | `supplier_error` | The date of birth does not match the passenger type of the offer (a child who is not 2 to 11 at departure, for example). | | `no_supplier_supports_flexible_search` | 422 | `validation_error` | A flexible-only search on a route that no flexible-capable supplier serves (flexible dates exist on routes that touch Algeria). | | `supplier_rejected_request` | 422 | `supplier_error` | The hotel supplier refused the request of `search_advanced` although it is well formed. Check dates, guests, residency and radius. `errors[0].details.supplier_code` carries the supplier's code when it has a plain one. | | `validation_error` | 422 | `validation_error` | A query or path parameter is missing or has the wrong type or range; one item per problem in `errors`. | | `validation_type` | 422 | `supplier_error` | The supplier refused a detail of the booking (a field has the wrong type or format). `errors[0].field` names it. | | `auth_rate_limited` | 429 | `rate_limit_error` | Too many requests with a missing or invalid key came from your IP address. Requests with a valid key are never affected. | | `concurrency_limit_exceeded` | 429 | `rate_limit_error` | Too many requests of this kind in flight at the same time for your key. Wait for Retry-After (a few seconds) or send fewer in parallel. | | `rate_limit_exceeded` | 429 | `rate_limit_error` | More requests than the limit of this endpoint group per minute. See the Retry-After header. | | `supplier_rate_limited` | 429 | `supplier_error` | The hotel supplier receives too many requests (`get_hotel_info`). Retry in a minute with backoff. | | `balance_debit_failed` | 500 | `internal_error` | The supplier created the eSIM but the balance debit failed. Do not retry: contact support with the order code. | | `booking_record_failed` | 500 | `internal_error` | The supplier created the eSIM but the booking could not be saved. Do not retry: contact support with the order code. | | `booking_update_failed` | 500 | `internal_error` | The reservation was deleted but the FX-Port booking could not be updated. Read the booking and contact support with the request id. | | `internal_error` | 500 | `internal_error` | An unexpected error on our side; quote the request_id. | | `post_booking_failed` | 500 | `internal_error` | The booking was created at the supplier but FX-Port could not finish storing it. Do not book again: look for the booking in your history and quote the request id. | | `supplier_response_invalid` | 500 | `supplier_error` | The supplier answered in a way that could not be processed. Do not retry: support has been alerted with the booking reference. | | `bad_gateway` | 502 | `internal_error` | A supplier or an upstream service answered with an error. | | `storage_upload_failed` | 502 | `internal_error` | No file could be stored. Nothing changed: retry the request. Each `results[].error` has the fixed `reason: storage_error`. | | `supplier_error` | 502 | `supplier_error` | The hotel supplier could not process the request right now. Retry in a few minutes with backoff. | | `supplier_invalid_response` | 502 | `supplier_error` | The hotel supplier answered in a form that could not be used. After `book_hotel` or `cancel_hotel` the call may have been processed: read the booking before you repeat it. On `pre_book` the same code with HTTP 400 means that the supplier priced the rate but returned no usable offer: call `get_rates` again. | | `all_suppliers_failed` | 503 | `supplier_error` | No supplier returned results. `supplierErrors[]` lists each supplier with a stable `cause`. | | `balance_lock_timeout` | 503 | `internal_error` | Another balance operation of your agency in the same currency is still running. Retry in a few seconds. | | `duplicate_check_unavailable` | 503 | `internal_error` | The duplicate check could not run, so nothing was ordered (live). Retry in a few seconds. | | `service_busy` | 503 | `internal_error` | The hotel name search is busy. Retry after the seconds in `Retry-After`. | | `service_overloaded` | 503 | `internal_error` | The service is shedding load for a moment. Retry after the number of seconds in Retry-After. | | `service_unavailable` | 503 | `internal_error` | The service or a supplier is temporarily unavailable. Retry with backoff. | | `storage_unavailable` | 503 | `internal_error` | Document storage is not available. Retry later. | | `supplier_api_error` | 503 | `supplier_error` | The supplier refused or failed on a price call. Retry once, otherwise search again. | | `supplier_busy` | 503 | `supplier_error` | The supplier is saturated: its queue is full. Retry after the number of seconds in Retry-After. | | `supplier_temporarily_unavailable` | 503 | `supplier_error` | The supplier is skipped for a short while after repeated failures. Wait, then retry. | | `supplier_timeout` | 503 | `supplier_error` | The hotel supplier did not answer in time. A cancellation may still have happened: read the booking before you repeat `cancel_hotel`. | | `supplier_unavailable` | 503 | `supplier_error` | The hotel supplier cannot be reached. Nothing was processed: retry in a few minutes with backoff. | | `gateway_timeout` | 504 | `internal_error` | A supplier or an upstream service did not answer in time. | --- # Rate limits and fair use > How many requests a key may send, what a 429 means, which requests are refused early and how to back off. Source: https://fx-port.com/docs/rate-limits FX-Port limits how fast a key can call the API, so that one runaway loop or one abusive client cannot slow everybody else down. The limits are far above what an interactive application needs. This page says what they are, what you get when you reach one, which requests are refused before they cost anything, and how to stay comfortably below them. ## Limits per key Every API key has a counter per minute (the minute of the clock, not a sliding window) for each of three groups of endpoints. Sandbox keys have the same counters as live keys. | Group | Limit per minute | Endpoints | |---|---|---| | **Search and pricing** | 6,000 | Flight searches (`get_flights`, `flexible_search`), `price_flight`, hotel search, rates and hotel content (`search_advanced`, `get_rates`, `get_hotel_info`, `hotels/search`, `hotels/details`) | | **Booking** | 1,200 | Everything that creates, pays or cancels: `book_flight`, `pay_order`, `cancel_order`, `confirm_cancel`, hotel `pre_book`, `book_hotel`, `cancel_hotel`, eSIM orders and topups | | **Everything else** | 6,000 | Reading a booking (`get_booking`, `get_ticket`, ACD documents, `GET /bookings/{id}`, hotel vouchers), eSIM catalogue, usage, instructions and bookings, balances, history, suppliers, location and name lookups | The counter belongs to the **key**, not to the agency or the IP address: two keys of one agency count separately. A request that is refused for another reason (a malformed body, for example) still counts, so a client that loops on a bad request burns its own budget. ### Heavy endpoints have a second, tighter protection A search costs a supplier real work, so searches and hotel name searches are also limited by how many run **at the same time** and by short bursts. These ceilings sit well above what a user-driven application produces, and they protect the suppliers (and your other calls) from a loop that fires thousands of searches at once. | Kind of call | Per minute | Burst | At the same time | |---|---|---|---| | Flight search (`flexible_search` counts twice) | 300 | 60 | 12 | | Hotel search and hotel content (`search_advanced`, `hotels/search`, `hotels/details`) | 180 | 40 | 8 | | Hotel name search (a call counts three times) | 120 | 20 | 3 | | Price, rates and hotel info (`price_flight`, `get_rates`, `get_hotel_info`) | 3,000 | 450 | 48 | | Booking calls | 600 | 100 | 20 | | Reading a booking, voucher or ticket | 1,200 | 200 | 16 | | ACD passport upload | 30 | 10 | 3 | - **Sandbox keys** get a quarter of every number in this table. - All the keys of an agency together get three times the numbers of one key. - A key can keep **4 streams** ([`stream=true`](https://fx-port.com/docs/streaming.md)) open at the same time. - A call that has to wait for a free slot waits up to two seconds inside the API before it is refused with `concurrency_limit_exceeded`. > [!NOTE] > At the release of 2026-11-01 these ceilings run in observation mode: they are counted but not enforced, so nothing is refused because of them yet. We announce in the [changelog](https://fx-port.com/docs/changelog.md) before they are enforced. Whatever the numbers, the `ratelimit-*` headers of every response show the limit that applies to your request: treat them as the authority, and build your client to back off on a `429`. ## When you reach a limit The API answers `429` with an error code that says which limit, and tells you when to come back: | Code | Meaning | |---|---| | `rate_limit_exceeded` | You used the per-minute budget of the group. | | `concurrency_limit_exceeded` | Too many calls of this kind were running at the same time. | | `auth_rate_limited` | Too many requests with a missing or invalid key came from your IP address. Calls with a valid key are never affected. | A `503` with `service_overloaded` or `supplier_busy` means the service or a supplier is shedding load for a moment: the answer is the same, wait and retry. ~~~http HTTP/1.1 429 Too Many Requests Retry-After: 17 ratelimit-limit: 6000 ratelimit-remaining: 0 ratelimit-reset: Sun, 01 Nov 2026 15:20:00 GMT ratelimit-policy: 6000;w=60 x-fx-limit-class: flight_search ~~~ ~~~json { "environment": "live", "success": false, "errors": [ { "code": "rate_limit_exceeded", "type": "rate_limit_error", "title": "Rate limit exceeded", "message": "Too many requests hit the API too quickly. Retry after the number of seconds in the Retry-After header.", "field": null, "details": { "retry_after": 17 }, "documentation_url": "https://fx-port.com/docs/errors#rate_limit_exceeded" } ], "meta": { "request_id": "req_8f3a9c1d2e4b", "status": 429 }, "request_id": "req_8f3a9c1d2e4b" } ~~~ | Header | Meaning | |---|---| | `Retry-After` | Seconds to wait before the next call. | | `ratelimit-limit` | The limit of the group the request belongs to. | | `ratelimit-remaining` | Calls left in the current minute. | | `ratelimit-reset` | When the counter restarts, as an HTTP date. | | `ratelimit-policy` | The policy that applies, as `;w=` (for example `6000;w=60`). | | `x-fx-limit-class` | The kind of call the request was counted as: `flight_search`, `hotel_search`, `hotel_name`, `price`, `booking`, `read`, `suggest` ... | The `ratelimit-*` headers are on every response of a key, not only on a `429`, so you can slow down before you are refused. Wait for `Retry-After`, then retry with exponential backoff and jitter (see [Errors](https://fx-port.com/docs/errors.md#retrying-safely)). ## Refused before they cost anything Some requests never reach a supplier, so they cost neither you nor us anything: - **Malformed input.** A wrong airport code, an impossible or out-of-range date, an absurd passenger count, an unknown enum value or a body that is not a JSON object is answered with `400` and a stable code (see [Errors](https://fx-port.com/docs/errors.md)) before any supplier is called, and it does not count as a supplier failure. - **Oversized bodies.** A search or price request may be up to 64 KB, any other JSON request up to 256 KB, an ACD upload up to 26 MB (10 MiB per file). Larger bodies get `413 request_too_large` before they are read. - **A key that is not a key.** A key that does not have the shape of an FX-Port key (`fxp_live_` or `fxp_test_` followed by 16 to 64 letters and digits, 80 characters at most) is refused at once with `401 invalid_api_key_format`, without a lookup. - **Repeated bad authentication.** Requests with an unknown or malformed key are limited per IP address (30 a minute, a burst of 20). After repeated failures that address is blocked for a minute, then for longer if it continues, up to an hour. Requests with a **valid key** are never affected, even from the same address: an office behind one address is safe. ## The typeaheads The airport and destination lookups need no key, so they are limited per IP address instead (1,200 a minute, a burst of 300). They answer `Cache-Control: public, max-age=60`: let your browser or your CDN cache them, and debounce your input. ## Stay comfortably below the limits | Do | Why | |---|---| | **Cache what does not change**: airports, [suppliers](https://fx-port.com/docs/suppliers.md), hotel content, eSIM packages (a few MB per country list: cache them for an hour) | They are the biggest part of most integrations' traffic. | | **Reuse a search** for a few seconds when several users ask for the same route and dates | Offers do not change from one second to the next, and every search costs a supplier call. The API also merges identical searches that run at the same moment, but your own cache is faster. | | **Search in parallel with a small cap**: five at a time is plenty; do not fan out one search per day of a month | Parallel searches multiply supplier load. For a date range use [flexible search](https://fx-port.com/docs/flights/flexible-search.md). | | **Use streaming** for a search page | The first offers arrive in about two seconds instead of after the slowest supplier ([Streaming](https://fx-port.com/docs/streaming.md)). | | **Debounce typeahead** (airports, hotel locations) at about 300 ms and cancel the previous request | The lookups are limited per IP address too. | | **Poll a booking every 2 seconds at most**, and stop when it is `confirmed` | Hotel bookings confirm in 4 to 14 seconds. | | **Ask for gzip** (`Accept-Encoding: gzip`) | Flight searches are 0.7 to 14 MB of JSON; compression makes them 5 to 10 times smaller. | | **Back off on every `429` and `5xx`** | A client that retries immediately makes the situation worse for itself. | | **Use mini hotel data for lists** | A result list does not need the full hotel document: see [Hotel data](https://fx-port.com/docs/hotels/hotel-data.md). | If you plan a load test or a bulk job, tell us at [dev@fx-port.com](mailto:dev@fx-port.com) first. Sandbox keys have a quarter of the heavy-endpoint ceilings, and the suppliers' test systems are small. ## Related limits | Limit | Value | |---|---| | Hotel stay | 1 to 30 nights, check-in from yesterday up to 400 days ahead | | Flight dates | From yesterday up to 365 days ahead | | Passengers | 9 seated passengers in total, lap infants not more than adults, ages 0 to 17 for children | | Hotel ids in one content request | 200 | | Hotel rooms in one search | 9 | | ACD upload | 10 MiB per file, 25 MiB per request | The limits of each endpoint are in its page and in the [API reference](https://fx-port.com/docs/api.md). --- # Streaming search results > Receive flight search results supplier by supplier as server-sent events, instead of waiting for the slowest supplier. Source: https://fx-port.com/docs/streaming `POST /api/v1/get_flights?stream=true` runs the same search as the JSON mode, but answers with **server-sent events**: the first offers arrive as soon as the first supplier has answered, and the rest follow supplier by supplier, instead of one response after the slowest supplier. The request body, the key permission (Read), the offers and the errors that are found before the search starts are the ones of [Search flights](https://fx-port.com/docs/flights/search.md). **POST** `/api/v1/get_flights?stream=true` ## When to use it A search fans out to every compatible supplier and the suppliers answer at different speeds. Measured on the sandbox through `api.fx-port.com` on 2026-11-01 (ALG to CDG, one adult, two suppliers): | | JSON mode | Stream | |---|---|---| | First byte | After the slowest supplier: 2.7 to 8 s | **0.3 to 0.7 s** (the `start` event) | | First offers | Same moment as the last offers | **1.5 to 2.6 s** (the fastest supplier) | | Last offers | 2.7 to 8 s | 2.9 to 8 s (the slowest supplier) | | Bytes on the wire | About 26 KB with `Accept-Encoding: gzip` (700 KB without) | About 660 KB: **streams are never compressed** | | Code | One `json.loads` | An event loop: see [the complete client](#a-complete-client) | Use the stream for a **search page that a person is looking at**: the first fares appear in about two and a half seconds and the list grows while the slow suppliers finish. Use the JSON mode for **everything else**: batch jobs, price comparison, caching a search for later, and any client that cannot read a stream. On a slow link (a phone on mobile data) the 660 KB of an uncompressed stream can take longer to download than the 26 KB of a gzipped JSON response: measure before you choose. ## Request Same body as [Search flights](https://fx-port.com/docs/flights/search.md#request-fields), same Read permission. Ask for a stream with **either** of these (each works alone): - the query parameter `?stream=true` (`1` and `yes` also work); - the header `Accept: text/event-stream`. | Header | Value | | |---|---|---| | `Authorization` | `Bearer fxp_test_...` | Required | | `Content-Type` | `application/json` | Required: the search is a JSON body | | `Accept` | `text/event-stream` | Recommended | | `User-Agent` | Your own, for example `my-app/1.0` | Cloudflare blocks the default agents of Python's `urllib` and Perl's `libwww-perl` with a `403` and the text `error code: 1010` | **Request** ```bash curl 'https://api.fx-port.com/api/v1/get_flights?stream=true' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Accept: text/event-stream" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` **A problem with the request is answered before the stream starts, as an ordinary JSON error** with its own status code, not `200`: a missing or invalid key (`401`), a key that is not allowed (`403`), a malformed body, an invalid passenger mix or an unknown supplier (`400`), too many requests (`429`). Always check that the status is `200` and the `Content-Type` is `text/event-stream` before you parse events. Once the stream has started the status is `200` and failures arrive as events. ## Response headers Verified through Cloudflare on 2026-11-01: | Header | Value | Meaning | |---|---|---| | `Content-Type` | `text/event-stream` | Server-sent events in UTF-8 (the header has no `charset`: decode the bytes as UTF-8) | | `Cache-Control` | `no-cache, no-transform` | Never cached, and proxies are asked not to compress or rewrite it | | `Transfer-Encoding` | `chunked` | The length is unknown (on HTTP/2 the body is sent as frames and the header is absent) | | `X-Environment` | `sandbox` or `live` | The environment of the key | | `X-Request-Id` | The id of the request | Quote it to support | | `cf-cache-status` | `DYNAMIC` | Cloudflare does not cache or hold the stream | There is **no `Content-Encoding`**: the stream is not compressed, even when you send `Accept-Encoding: gzip`. ## The wire format A stream is text. Each event is a few lines ended by an empty line: ~~~text id: 4 event: supplier_complete data: {"type":"supplier_complete","supplierId":"duffel_1","totalOffers":92,"hasFlexPrices":false,"flexPricesCount":0,"complete":true,"timestamp":"2026-11-01T15:22:59.175148"} ~~~ | Field | Content | |---|---| | `id` | The number of the event, from `0`, increasing by one. For logging: a stream **cannot be resumed** (see [Timeouts and reconnecting](#timeouts-and-reconnecting)) | | `event` | The name of the event: `start`, `supplier_start`, `offers_chunk`, `supplier_complete`, `supplier_result`, `flex_prices`, `error`, `summary`, `done` | | `data` | One line of compact JSON. The same name is in its `type` key | | an empty line | Ends the event: dispatch it now | Rules every client must follow: 1. **Ignore lines that start with a colon.** They are comments, such as the keep-alive line `: keep-alive` ([below](#keep-alive-lines)). 2. **Ignore a field or an event you do not know.** More of them can appear in the future. 3. **A line can be huge.** One `offers_chunk` carries up to 50 offers and its `data` line was up to 210 KB in our measurements. A reader with a small line buffer (Go's `bufio.Scanner` has 64 KB by default) fails with a cryptic error. 4. **Timestamps** (`timestamp`) are ISO 8601 in UTC **without** a `Z`: `2026-11-01T15:22:59.175148`. 5. Only the `start` event carries `environment` (as its **first key**); the header `X-Environment` says it too. ## Events A search with two suppliers produced these 12 events (the offers are not shown): ~~~text 0 start 1 supplier_start duffel_1 92 offers coming in 2 chunks 2 offers_chunk duffel_1 chunk 1 of 2, 50 offers 3 offers_chunk duffel_1 chunk 2 of 2, 42 offers 4 supplier_complete duffel_1 complete: true 5 supplier_start amadeus_aqc_dz_1 68 offers coming in 2 chunks 6 offers_chunk amadeus_aqc_dz_1 chunk 1 of 2, 50 offers 7 offers_chunk amadeus_aqc_dz_1 chunk 2 of 2, 18 offers 8 supplier_complete amadeus_aqc_dz_1 complete: false <- another batch of this supplier follows 9 supplier_result amadeus_aqc_dz_1 2 more offers, complete: true, another searchId 10 summary 11 done ~~~ | Event | When | What it carries | |---|---|---| | `start` | First, at once | The request id and the suppliers that will answer | | `supplier_result` | A supplier, or a later batch of it, answered with **50 offers or fewer** | All the offers of one result set, in `data.results.offers` | | `supplier_start` | A supplier answered with **more than 50 offers** | The result set without its offers: the `searchId`, the currencies, `results.totalOffers`, `chunkSize`, `totalChunks` | | `offers_chunk` | After `supplier_start` | Up to 50 offers, numbered `chunkNumber` of `totalChunks` | | `flex_prices` | A chunked result set that carries a date matrix (`flexible: true`) | The matrix of the supplier. Not seen in the sandbox: see [flexible dates](#flexible-dates) | | `supplier_complete` | After the last chunk | The counters of the result set and `complete` | | `error` | A supplier failed | Which supplier, and the error text | | `summary` | After every supplier has answered or failed | The counters and the list of failures | | `done` | Last | The end of the stream | ### start ~~~json { "environment": "sandbox", "type": "start", "requestId": "d36b6918", "searchType": "ONE WAY", "search_type": "ONE_WAY", "flexible": false, "timestamp": "2026-11-01T15:22:57.762071", "expectedSuppliers": ["amadeus_aqc_dz_1", "duffel_1"] } ~~~ `expectedSuppliers` are the suppliers called for this search: the compatible ones, a supplier skipped for the route or the passenger mix is not listed. `searchType` here is the raw classification of the route (`ONE WAY`, `ROUNDTRIP`, `MULTIPLE CITY`, `OPEN`), **not** the `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW`, `MULTI_CITY` of the JSON mode. Branch on `search_type`: it has the vocabulary of the JSON mode. ### supplier_result and supplier_start A supplier answered. `data` is the same object as an entry of `suppliers[]` in the JSON mode, arranged a little differently: ~~~json { "type": "supplier_start", "supplierId": "duffel_1", "success": true, "data": { "success": true, "requestId": "d36b6918", "searchId": "fs_dfx_59ce98b7-ce5a-4911-abb8-b43d4786e6c9", "searchType": "ONE_WAY", "processingTime": 1.381, "supplierCurrency": "USD", "agencyCurrency": "DZD", "results": { "totalOffers": 92, "supplierCurrency": "USD", "agencyCurrency": "DZD", "offers": [], "chunked": true, "chunkSize": 50, "totalChunks": 2 }, "meta": { "filtersApplied": {}, "warnings": [], "supplierId": "duffel_1", "technologyType": "duffel", "supportHold": false, "supportVoid": false, "voidDeadline": null }, "exchangeRate": 250.0 }, "processingTime": 1.381, "timestamp": "2026-11-01T15:22:59.161692" } ~~~ `supplier_result` has the same shape, plus a top-level `complete`, and the offers in `data.results.offers` (no `chunked`, `chunkSize` or `totalChunks`). Compared with the JSON mode: `searchId` and `requestId` are inside `data`; `supportHold`, `supportVoid`, `voidDeadline`, `filtersApplied` and `warnings` stay inside `data.meta` (the JSON mode moves them to the supplier entry); `meta` has no `supplierName`, `processingTime` or currencies (the last two are next to it, in `data`). The offers themselves are exactly those of the JSON mode: see [the Flight offer object](https://fx-port.com/docs/api/objects/flight-offer.md). ### offers_chunk ~~~json { "type": "offers_chunk", "supplierId": "duffel_1", "chunkNumber": 1, "totalChunks": 2, "offersInChunk": 50, "offers": [{ "id": "fx_offer_9d08a93c", "type": "flight_offer", "...": "an offer, as in the JSON mode" }], "timestamp": "2026-11-01T15:22:59.161771" } ~~~ A chunk carries only the `supplierId`: add its offers to the result set opened by the last `supplier_start` of that supplier. ### supplier_complete ~~~json { "type": "supplier_complete", "supplierId": "amadeus_aqc_dz_1", "totalOffers": 68, "hasFlexPrices": false, "flexPricesCount": 0, "complete": false, "timestamp": "2026-11-01T15:22:59.819079" } ~~~ ### error ~~~json { "type": "error", "supplierId": "duffel_1", "success": false, "error": "The supplier rejected the search request.", "cause": "supplier_error", "processingTime": 0, "timestamp": "2026-11-01T15:23:32.838533" } ~~~ One supplier failed: it timed out, refused the search, was skipped for a short while or was busy. The other suppliers go on. `cause` is a stable code: `supplier_timeout`, `supplier_api_error`, `supplier_auth_error`, `supplier_error`, `supplier_temporarily_unavailable`, `supplier_busy` (FX-Port's capacity towards the supplier is full for a moment: the supplier was not called, search again in a few seconds) or `service_overloaded` (FX-Port was too busy to prepare that supplier's results: search again in a few seconds). `error` is a fixed sentence about the cause: it never carries the supplier's own text (addresses, stack text), and its wording can change, so branch on `cause`. ### summary and done ~~~json { "type": "summary", "requestId": "d36b6918", "success": true, "processingTime": 2.581, "summary": { "totalSuppliers": 2, "successfulSuppliers": 2, "failedSuppliers": 0, "totalOffers": 162 }, "successfulSuppliers": [ { "supplierId": "duffel_1", "offersCount": 92, "processingTime": 1.381 }, { "supplierId": "amadeus_aqc_dz_1", "offersCount": 70, "processingTime": 2.516 } ], "failures": null, "timestamp": "2026-11-01T15:23:00.286463" } ~~~ ~~~json { "type": "done", "requestId": "d36b6918", "timestamp": "2026-11-01T15:23:00.286605" } ~~~ `success` is `true` when at least one supplier returned offers. `failures` is `null`, or a list of `{ "supplierId", "error", "cause" }` (the same `cause` values as the `error` event). `successfulSuppliers` is a **number** inside `summary` and a **list** next to it. In the stream `summary.totalSuppliers` is the number of suppliers that were **called** (in the JSON mode it counts the suppliers that answered). When the stream reaches its time limit (see [Timeouts and reconnecting](#timeouts-and-reconnecting)) the `summary` event has `"truncated": true`, and the suppliers that had not answered are in `failures` with the cause `supplier_timeout`. ### Flexible dates With `"flexible": true` the date matrix (`flexPrices`, see [Flexible-date search](https://fx-port.com/docs/flights/flexible-search.md)) is the `data.flexPrices` of the result set that has it. For Amadeus Algeria that is the **second, slower batch**: the first batch has the offers and no matrix, and the matrix arrives with the `supplier_result` that has `complete: true` (7 seconds into the stream in our test). A chunked result set that carries a matrix sends it in a separate `flex_prices` event (`flexPrices` and a `count`) before `supplier_complete`; we have not seen that event in the sandbox, so treat it as optional. ## Ordering guarantees - `start` is the first event (`id` 0) and `done` the last. `summary` is just before `done`. - The events of a result set are in order: `supplier_start`, then `offers_chunk` 1 to `totalChunks`, then `flex_prices` if there is one, then `supplier_complete`. A small result set is a single `supplier_result`. - Suppliers are independent and arrive in the order they finish. There is no fixed order between suppliers, and it can change from one search to the next. - The events of two result sets are not mixed in practice, but do not rely on it: file every `offers_chunk` under its `supplierId`. - An `error` can come at any point after `start`. The stream still ends with `summary` and `done`: `done` is **always** the last event, also when the search failed half-way. - The stream is over **only** at `done`. A stream that ends without it (a dropped connection, a proxy that closed it) is incomplete. ## Merging the offers A supplier can answer **more than once**. Keep one list of offers per `searchId`: **Step 1: Open a result set** On `supplier_start` or `supplier_result`, read `data.searchId`, remember it as the current `searchId` of that `supplierId`, and start a list with `data.results.offers` (empty for `supplier_start`). **Step 2: Fill it** On every `offers_chunk`, append `offers` to the list of the current `searchId` of its `supplierId`. `chunkNumber` and `totalChunks` let you check that none is missing. **Step 3: Know when a supplier is finished** A supplier is finished at its first `supplier_complete` or `supplier_result` with `complete: true`, or at its `error`. `complete: false` means that **another batch of this supplier follows**. > [!WARNING] > **One supplier, two searchIds** > > Amadeus Algeria answers in two batches, a fast one and a slow one. The first batch has `complete: false`. The second arrives later as a `supplier_result` with `complete: true`, with **only the extra offers** and **a different `searchId`**. In the example above the first batch had 68 offers under `fs_509846e6-...` and the second 2 offers under `fs_35418d61-...`, and `summary` counted 70 for the supplier. > > Price and book an offer with the `searchId` of **the result set it arrived in**. Do not merge two result sets into one list that has a single `searchId`. `duffel_1` answered in a single result set. `supplier_complete.totalOffers` and `results.totalOffers` are the number of offers of **that result set**. The total of the search is `summary.summary.totalOffers`. ## Keep-alive lines While a supplier is slow the server writes a comment line about **every 15 seconds**, so that proxies, load balancers and your own read timeout see traffic: ~~~text : keep-alive ~~~ A comment starts with a colon and carries no event. **Skip every line that starts with a colon**: the SSE standard says so too. Do not count a comment as an event and do not use it as an end condition. ## Errors in the stream | Situation | What you see | What to do | |---|---|---| | The request is wrong (invalid key, malformed body, unknown supplier, a passenger mix no supplier can serve) | An ordinary JSON error with a `4xx` status, **no stream** | Fix the request. See [Errors](https://fx-port.com/docs/errors.md) | | One supplier fails | An `error` event for it, then the stream goes on | Show the offers of the others. Search again later if the missing supplier matters | | Every supplier fails | `error` events, then `summary` with `success: false`, then `done` | Treat it like the `503` of the JSON mode (`all_suppliers_failed`, or `supplier_busy` or `service_overloaded` when every `cause` says so): wait, then retry once or twice with backoff (a few seconds are enough for `supplier_busy` and `service_overloaded`) | | The connection drops, or the stream ends without `done` | The stream is cut | The offers you have are valid but incomplete. Run the search again: see below | | `429` | An ordinary JSON error before the stream: `rate_limit_exceeded`, or `concurrency_limit_exceeded` when too many streams of your key are open ([Limits](#limits)) | Wait for `Retry-After` ([Rate limits](https://fx-port.com/docs/rate-limits.md)) | ## Timeouts and reconnecting - **A stream cannot be resumed.** There is no `Last-Event-ID` support and no `retry:` field. If a connection drops, **start a new search**: it is a new request and counts once in your [rate limit](https://fx-port.com/docs/rate-limits.md). - **Do not use the browser's `EventSource`.** It cannot send a `POST` body or an `Authorization` header, and it reconnects on its own and would repeat the search. Read the stream with `fetch` on your server, or relay it to the browser with an endpoint of yours. - **Never put your key in a browser.** Call the API from your backend and forward the events to the page. - **A stream lasts 300 seconds at most.** At that point the server cancels the suppliers that are still running, sends an `error` event with `cause: "supplier_timeout"` for each, then `summary` with `"truncated": true`, then `done`. A normal search ends within about 10 seconds. - **Set a read timeout of at least 60 seconds** (the time you accept without receiving a byte) and a **total deadline of about two minutes**. A slow supplier can take more than 15 seconds, and the keep-alive lines keep the connection busy meanwhile. - **Reconnect with backoff.** After a failure wait 1 s, then 2 s, then 4 s (with jitter) and give up after three tries. Do not reconnect in a tight loop. - **Offers expire.** A search stays usable for 24 hours, but a supplier can withdraw an offer much earlier. Price an offer soon after you show it ([Price a flight offer](https://fx-port.com/docs/flights/price.md)). ## Limits | Limit | Value | |---|---| | Streams open at the same time | **Keep at most 4 streams open per API key.** A stream holds its connection and its supplier calls for as long as it runs, so the open streams of one key are capped at 4. Every search with `stream=true` counts while it runs; a JSON search never does. Once the cap is enforced, a fifth stream is refused before it starts with `429 concurrency_limit_exceeded` and `Retry-After: 5`. Until then the streams are only counted: do not rely on the extra ones. | | Duration of one stream | 300 seconds: see [Timeouts and reconnecting](#timeouts-and-reconnecting). | | Requests | A stream is one request of the search group ([Rate limits](https://fx-port.com/docs/rate-limits.md)). | | Request body | The body of the search: 64 KB at most (`413 request_too_large`). | When many of your users search at the same moment, their streams all use **your** key. Open a stream only for a search that a person is waiting for, close the connection when that person leaves the page (the server stops the search and frees the slot), and use the JSON mode for batch jobs and for anything that is not shown while it arrives. After a `429`, wait for `Retry-After` and open the stream again. ## Proxies, CDNs and compression - **Cloudflare passes the stream through unbuffered**: the first event arrived after 0.3 to 0.7 s and the others as the suppliers answered. The response has `cf-cache-status: DYNAMIC`. - **Streams are not compressed.** `Content-Encoding` is absent whatever you send in `Accept-Encoding`. The JSON mode is compressed (26 KB instead of 700 KB for the same search). - **Do not buffer in your own proxy.** In nginx set `proxy_buffering off;` for the route that forwards the stream, turn off response compression for `text/event-stream` and raise the proxy read timeout above 60 seconds. - **Use your own `User-Agent`.** Cloudflare answers the default agents of Python's `urllib` and Perl's `libwww-perl` with `403` and the text `error code: 1010`: see [Errors that do not come from the API](https://fx-port.com/docs/errors.md#errors-that-do-not-come-from-the-api). `curl`, Go, Java, Node.js, PHP and Python `requests` are not blocked. - **Decode as UTF-8.** Some HTTP libraries assume ISO-8859-1 for `text/*` types without a charset (Python `requests` does): set the encoding to UTF-8 or read bytes, or the names of airports and cities come out garbled. - **A line can be 200 KB or more.** Do not use a reader with a small fixed line limit. ## A complete client One program per language. Each sends a search, ignores comment lines, keeps the offers of every result set under its `searchId`, prints every event with the time it arrived and ends at `done`. They read the key from `FXPORT_API_KEY` and search for a date 30 days from today. The Java and Kotlin versions count the offers with regular expressions because the JDK has no JSON parser: parse the `data` lines with Jackson or Gson to keep the offers. **Read the search stream, merge the offers and stop at done** **cURL** ```bash # --no-buffer shows every event when it arrives instead of when the program exits. DEPARTURE=$(date -d '+30 days' +%F 2>/dev/null || date -v+30d +%F) curl --no-buffer --silent --show-error \ --request POST "https://api.fx-port.com/api/v1/get_flights?stream=true" \ --header "Authorization: Bearer fxp_test_YOUR_KEY" \ --header "Content-Type: application/json" \ --header "Accept: text/event-stream" \ --header "User-Agent: my-flight-app/1.0" \ --data "{\"origin\":\"ALG\",\"destination\":\"CDG\",\"departure_date\":\"$DEPARTURE\",\"cabin_class\":\"economy\",\"passengers\":{\"adults\":1}}" # Only the event names, each with the time it arrived: # ... | grep --line-buffered '^event:' | while read -r line; do echo "$(date +%T) $line"; done ``` **Node.js** ```javascript // Node.js 18 or later. No library is needed: fetch() gives a stream and a few lines split it into events. const API_KEY = process.env.FXPORT_API_KEY ?? "fxp_test_YOUR_KEY"; const BASE_URL = "https://api.fx-port.com/api/v1"; const departure = new Date(Date.now() + 30 * 86_400_000).toISOString().slice(0, 10); const startedAt = Date.now(); const stamp = () => "[" + ((Date.now() - startedAt) / 1000).toFixed(2) + "s]"; const response = await fetch(BASE_URL + "/get_flights?stream=true", { method: "POST", headers: { Authorization: "Bearer " + API_KEY, "Content-Type": "application/json", Accept: "text/event-stream", "User-Agent": "my-flight-app/1.0", }, body: JSON.stringify({ origin: "ALG", destination: "CDG", departure_date: departure, cabin_class: "economy", passengers: { adults: 1 }, }), signal: AbortSignal.timeout(120_000), }); if (!response.ok) { // A problem with the request is reported before the stream starts, as an ordinary JSON error. console.error(response.status, await response.text()); process.exit(1); } const offersBySearch = new Map(); // searchId -> offers: price an offer with the searchId of the result set it came in const currentSearch = new Map(); // supplierId -> searchId of the result set being received let done = false; function handle(event, data) { switch (event) { case "start": console.log(stamp(), "start, suppliers:", data.expectedSuppliers.join(", ")); break; case "supplier_start": // the offers follow in offers_chunk events case "supplier_result": { // every offer of the result set is in this event const searchId = data.data.searchId; currentSearch.set(data.supplierId, searchId); const offers = offersBySearch.get(searchId) ?? []; offers.push(...data.data.results.offers); offersBySearch.set(searchId, offers); console.log(stamp(), event, data.supplierId, searchId, "totalOffers", data.data.results.totalOffers); break; } case "offers_chunk": offersBySearch.get(currentSearch.get(data.supplierId)).push(...data.offers); console.log(stamp(), " chunk", data.chunkNumber + "/" + data.totalChunks, data.supplierId, "+" + data.offersInChunk); break; case "supplier_complete": console.log(stamp(), data.supplierId, "complete:", data.complete); break; case "error": console.warn(stamp(), data.supplierId, "failed:", data.error); break; case "summary": console.log(stamp(), "summary", JSON.stringify(data.summary)); break; case "done": done = true; break; } } const decoder = new TextDecoder(); let buffer = ""; let eventName = "message"; let dataLines = []; for await (const chunk of response.body) { buffer += decoder.decode(chunk, { stream: true }); let newline; while ((newline = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, newline).replace(/\r$/, ""); buffer = buffer.slice(newline + 1); if (line === "") { // A blank line ends the event. if (dataLines.length) handle(eventName, JSON.parse(dataLines.join("\n"))); eventName = "message"; dataLines = []; } else if (line.startsWith(":")) { // A comment such as ": keep-alive": ignore it. } else { const colon = line.indexOf(":"); const field = colon < 0 ? line : line.slice(0, colon); const value = colon < 0 ? "" : line.slice(colon + 1).replace(/^ /, ""); if (field === "event") eventName = value; else if (field === "data") dataLines.push(value); } } } const total = [...offersBySearch.values()].reduce((sum, offers) => sum + offers.length, 0); console.log(stamp(), total + " offers in " + offersBySearch.size + " result sets"); if (!done) { console.error("The stream ended without a done event: run the search again."); process.exit(1); } ``` **Python** ```python # pip install requests import json import os import sys import time from datetime import date, timedelta import requests API_KEY = os.environ.get("FXPORT_API_KEY", "fxp_test_YOUR_KEY") BASE_URL = "https://api.fx-port.com/api/v1" started = time.monotonic() def stamp(): return "[%5.2fs]" % (time.monotonic() - started) body = { "origin": "ALG", "destination": "CDG", "departure_date": (date.today() + timedelta(days=30)).isoformat(), "cabin_class": "economy", "passengers": {"adults": 1}, } headers = {"Authorization": "Bearer " + API_KEY, "Accept": "text/event-stream", "User-Agent": "my-flight-app/1.0"} offers_by_search = {} # searchId -> offers: price an offer with the searchId of the result set it came in current_search = {} # supplierId -> searchId of the result set being received done = False def handle(event, data): global done if event == "start": print(stamp(), "start, suppliers:", ", ".join(data["expectedSuppliers"])) elif event in ("supplier_start", "supplier_result"): # the offers are here, or follow in offers_chunk events search_id = data["data"]["searchId"] current_search[data["supplierId"]] = search_id offers_by_search.setdefault(search_id, []).extend(data["data"]["results"]["offers"]) print(stamp(), event, data["supplierId"], search_id, "totalOffers", data["data"]["results"]["totalOffers"]) elif event == "offers_chunk": offers_by_search[current_search[data["supplierId"]]].extend(data["offers"]) print(stamp(), " chunk %s/%s" % (data["chunkNumber"], data["totalChunks"]), data["supplierId"], "+%s" % data["offersInChunk"]) elif event == "supplier_complete": print(stamp(), data["supplierId"], "complete:", data["complete"]) elif event == "error": print(stamp(), data["supplierId"], "failed:", data["error"], file=sys.stderr) elif event == "summary": print(stamp(), "summary", json.dumps(data["summary"])) elif event == "done": done = True url = BASE_URL + "/get_flights?stream=true" # timeout = (connect, read): the read timeout counts the silence between two chunks, not the whole stream. with requests.post(url, json=body, headers=headers, stream=True, timeout=(10, 90)) as response: if response.status_code != 200: # A problem with the request is reported before the stream starts, as an ordinary JSON error. sys.exit("%s %s" % (response.status_code, response.text)) response.encoding = "utf-8" # requests assumes ISO-8859-1 for text/* types without a charset event, data_lines = "message", [] for line in response.iter_lines(decode_unicode=True): if line == "": # A blank line ends the event. if data_lines: handle(event, json.loads("\n".join(data_lines))) event, data_lines = "message", [] elif line.startswith(":"): continue # A comment such as ": keep-alive": ignore it. else: field, _, value = line.partition(":") value = value[1:] if value.startswith(" ") else value if field == "event": event = value elif field == "data": data_lines.append(value) total = sum(len(offers) for offers in offers_by_search.values()) print(stamp(), "%d offers in %d result sets" % (total, len(offers_by_search))) if not done: sys.exit("The stream ended without a done event: run the search again.") ``` **PHP** ```php sprintf('[%5.2fs]', microtime(true) - $started); $body = json_encode([ 'origin' => 'ALG', 'destination' => 'CDG', 'departure_date' => date('Y-m-d', strtotime('+30 days')), 'cabin_class' => 'economy', 'passengers' => ['adults' => 1], ]); $offersBySearch = []; // searchId => offers: price an offer with the searchId of the result set it came in $currentSearch = []; // supplierId => searchId of the result set being received $done = false; $handle = function (string $event, array $data) use (&$offersBySearch, &$currentSearch, &$done, $stamp) { switch ($event) { case 'start': echo $stamp(), ' start, suppliers: ', implode(', ', $data['expectedSuppliers']), "\n"; break; case 'supplier_start': // the offers follow in offers_chunk events case 'supplier_result': // every offer of the result set is in this event $searchId = $data['data']['searchId']; $currentSearch[$data['supplierId']] = $searchId; $offersBySearch[$searchId] = array_merge($offersBySearch[$searchId] ?? [], $data['data']['results']['offers']); echo $stamp(), " $event {$data['supplierId']} $searchId totalOffers {$data['data']['results']['totalOffers']}\n"; break; case 'offers_chunk': $searchId = $currentSearch[$data['supplierId']]; $offersBySearch[$searchId] = array_merge($offersBySearch[$searchId], $data['offers']); echo $stamp(), " chunk {$data['chunkNumber']}/{$data['totalChunks']} {$data['supplierId']} +{$data['offersInChunk']}\n"; break; case 'supplier_complete': echo $stamp(), " {$data['supplierId']} complete: ", json_encode($data['complete']), "\n"; break; case 'error': fwrite(STDERR, $stamp() . " {$data['supplierId']} failed: {$data['error']}\n"); break; case 'summary': echo $stamp(), ' summary ', json_encode($data['summary']), "\n"; break; case 'done': $done = true; break; } }; $buffer = ''; $errorBody = ''; $eventName = 'message'; $dataLines = []; $ch = curl_init($baseUrl . '/get_flights?stream=true'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $apiKey, 'Content-Type: application/json', 'Accept: text/event-stream', 'User-Agent: my-flight-app/1.0', ], CURLOPT_TIMEOUT => 120, CURLOPT_WRITEFUNCTION => function ($ch, string $chunk) use (&$buffer, &$errorBody, &$eventName, &$dataLines, $handle) { if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) !== 200) { $errorBody .= $chunk; // a problem with the request is an ordinary JSON error return strlen($chunk); } $buffer .= $chunk; while (($newline = strpos($buffer, "\n")) !== false) { $line = rtrim(substr($buffer, 0, $newline), "\r"); $buffer = substr($buffer, $newline + 1); if ($line === '') { // A blank line ends the event. if ($dataLines) { $handle($eventName, json_decode(implode("\n", $dataLines), true)); } $eventName = 'message'; $dataLines = []; } elseif ($line[0] === ':') { continue; // A comment such as ": keep-alive": ignore it. } else { [$field, $value] = array_pad(explode(':', $line, 2), 2, ''); $value = ltrim($value, ' '); if ($field === 'event') { $eventName = $value; } elseif ($field === 'data') { $dataLines[] = $value; } } } return strlen($chunk); }, ]); curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { fwrite(STDERR, "$status $errorBody\n"); exit(1); } $total = array_sum(array_map('count', $offersBySearch)); echo $stamp(), " $total offers in ", count($offersBySearch), " result sets\n"; if (!$done) { fwrite(STDERR, "The stream ended without a done event: run the search again.\n"); exit(1); } ``` **Go** ```go package main import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) const baseURL = "https://api.fx-port.com/api/v1" func text(v any) string { s, _ := v.(string); return s } func object(v any) map[string]any { m, _ := v.(map[string]any); return m } func main() { apiKey := os.Getenv("FXPORT_API_KEY") if apiKey == "" { apiKey = "fxp_test_YOUR_KEY" } started := time.Now() stamp := func() string { return fmt.Sprintf("[%5.2fs]", time.Since(started).Seconds()) } payload, _ := json.Marshal(map[string]any{ "origin": "ALG", "destination": "CDG", "departure_date": time.Now().AddDate(0, 0, 30).Format("2006-01-02"), "cabin_class": "economy", "passengers": map[string]any{"adults": 1}, }) // A context deadline bounds the whole stream; http.Client.Timeout would cut a long stream too. ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() req, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/get_flights?stream=true", bytes.NewReader(payload)) req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "text/event-stream") req.Header.Set("User-Agent", "my-flight-app/1.0") resp, err := http.DefaultClient.Do(req) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // A problem with the request is reported before the stream starts, as an ordinary JSON error. body, _ := io.ReadAll(resp.Body) fmt.Fprintln(os.Stderr, resp.StatusCode, string(body)) os.Exit(1) } offersBySearch := map[string][]any{} // searchId -> offers: price an offer with the searchId of the result set it came in currentSearch := map[string]string{} // supplierId -> searchId of the result set being received done := false handle := func(event string, data map[string]any) { supplier := text(data["supplierId"]) switch event { case "start": fmt.Println(stamp(), "start, suppliers:", data["expectedSuppliers"]) case "supplier_start", "supplier_result": // the offers are here, or follow in offers_chunk events inner := object(data["data"]) searchID := text(inner["searchId"]) results := object(inner["results"]) currentSearch[supplier] = searchID offers, _ := results["offers"].([]any) offersBySearch[searchID] = append(offersBySearch[searchID], offers...) fmt.Println(stamp(), event, supplier, searchID, "totalOffers", results["totalOffers"]) case "offers_chunk": offers, _ := data["offers"].([]any) searchID := currentSearch[supplier] offersBySearch[searchID] = append(offersBySearch[searchID], offers...) fmt.Printf("%s chunk %v/%v %s +%d\n", stamp(), data["chunkNumber"], data["totalChunks"], supplier, len(offers)) case "supplier_complete": fmt.Println(stamp(), supplier, "complete:", data["complete"]) case "error": fmt.Fprintln(os.Stderr, stamp(), supplier, "failed:", data["error"]) case "summary": summary, _ := json.Marshal(data["summary"]) fmt.Println(stamp(), "summary", string(summary)) case "done": done = true } } // A data line can be 200 KB or more: read lines with bufio.Reader, not bufio.Scanner (64 KB limit by default). reader := bufio.NewReaderSize(resp.Body, 1<<20) event, dataLines := "message", []string{} for { line, readErr := reader.ReadString('\n') line = strings.TrimRight(line, "\r\n") switch { case line == "" && readErr == nil: // A blank line ends the event. if len(dataLines) > 0 { var data map[string]any if err := json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &data); err == nil { handle(event, data) } } event, dataLines = "message", nil case strings.HasPrefix(line, ":"): // A comment such as ": keep-alive": ignore it. case line != "": field, value, _ := strings.Cut(line, ":") value = strings.TrimPrefix(value, " ") if field == "event" { event = value } else if field == "data" { dataLines = append(dataLines, value) } } if readErr != nil { break } } total := 0 for _, offers := range offersBySearch { total += len(offers) } fmt.Println(stamp(), total, "offers in", len(offersBySearch), "result sets") if !done { fmt.Fprintln(os.Stderr, "The stream ended without a done event: run the search again.") os.Exit(1) } } ``` **Java** ```java import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import java.time.LocalDate; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; // Java 11 or later: java FxPortExample.java // The JDK has no JSON parser, so this sample reads the few fields it needs with regular expressions and counts the // offers. To keep the offers themselves, parse each data line with Jackson or Gson. public class FxPortExample { static final String BASE_URL = "https://api.fx-port.com/api/v1"; static long started = System.nanoTime(); static String stamp() { return String.format("[%5.2fs]", (System.nanoTime() - started) / 1e9); } static String field(String json, String name) { Matcher m = Pattern.compile("\"" + name + "\":(\"([^\"]*)\"|[0-9.]+|true|false)").matcher(json); return m.find() ? (m.group(2) != null ? m.group(2) : m.group(1)) : ""; } static int countOffers(String json) { Matcher m = Pattern.compile("\"type\":\"flight_offer\"").matcher(json); int count = 0; while (m.find()) count++; return count; } static final Map offersBySearch = new HashMap<>(); // searchId -> offers received static final Map currentSearch = new HashMap<>(); // supplierId -> searchId being received static boolean done = false; static void handle(String event, String data) { String supplier = field(data, "supplierId"); switch (event) { case "start": System.out.println(stamp() + " start: " + data.substring(0, Math.min(data.length(), 160))); break; case "supplier_start": // the offers follow in offers_chunk events case "supplier_result": { // every offer of the result set is in this event String searchId = field(data, "searchId"); currentSearch.put(supplier, searchId); offersBySearch.merge(searchId, countOffers(data), Integer::sum); System.out.println(stamp() + " " + event + " " + supplier + " " + searchId + " totalOffers " + field(data, "totalOffers")); break; } case "offers_chunk": offersBySearch.merge(currentSearch.get(supplier), countOffers(data), Integer::sum); System.out.println(stamp() + " chunk " + field(data, "chunkNumber") + "/" + field(data, "totalChunks") + " " + supplier + " +" + field(data, "offersInChunk")); break; case "supplier_complete": System.out.println(stamp() + " " + supplier + " complete: " + field(data, "complete")); break; case "error": System.err.println(stamp() + " " + supplier + " failed: " + field(data, "error")); break; case "summary": System.out.println(stamp() + " summary " + data.replaceAll(".*\"summary\":(\\{[^}]*\\}).*", "$1")); break; case "done": done = true; break; } } public static void main(String[] args) throws Exception { String apiKey = System.getenv("FXPORT_API_KEY") != null ? System.getenv("FXPORT_API_KEY") : "fxp_test_YOUR_KEY"; String body = "{\"origin\":\"ALG\",\"destination\":\"CDG\",\"departure_date\":\"" + LocalDate.now().plusDays(30) + "\",\"cabin_class\":\"economy\",\"passengers\":{\"adults\":1}}"; HttpRequest request = HttpRequest.newBuilder(URI.create(BASE_URL + "/get_flights?stream=true")) .timeout(Duration.ofMinutes(2)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") .header("User-Agent", "my-flight-app/1.0") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); // ofLines() hands over each line as soon as it has arrived. started = System.nanoTime(); HttpResponse> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofLines()); if (response.statusCode() != 200) { // A problem with the request is reported before the stream starts, as an ordinary JSON error. System.err.println(response.statusCode() + " " + response.body().collect(Collectors.joining("\n"))); System.exit(1); } String[] event = {"message"}; List dataLines = new ArrayList<>(); response.body().forEach(line -> { if (line.isEmpty()) { // A blank line ends the event. if (!dataLines.isEmpty()) handle(event[0], String.join("\n", dataLines)); event[0] = "message"; dataLines.clear(); } else if (line.startsWith(":")) { // A comment such as ": keep-alive": ignore it. } else { int colon = line.indexOf(':'); String name = colon < 0 ? line : line.substring(0, colon); String value = colon < 0 ? "" : line.substring(colon + 1).replaceFirst("^ ", ""); if (name.equals("event")) event[0] = value; else if (name.equals("data")) dataLines.add(value); } }); int total = offersBySearch.values().stream().mapToInt(Integer::intValue).sum(); System.out.println(stamp() + " " + total + " offers in " + offersBySearch.size() + " result sets"); if (!done) { System.err.println("The stream ended without a done event: run the search again."); System.exit(1); } } } ``` **C#** ```csharp // .NET 8 or later: dotnet run using System.Diagnostics; using System.Text; using System.Text.Json; var apiKey = Environment.GetEnvironmentVariable("FXPORT_API_KEY") ?? "fxp_test_YOUR_KEY"; const string BaseUrl = "https://api.fx-port.com/api/v1"; var clock = Stopwatch.StartNew(); string Stamp() => $"[{clock.Elapsed.TotalSeconds,5:F2}s]"; var body = JsonSerializer.Serialize(new { origin = "ALG", destination = "CDG", departure_date = DateTime.UtcNow.AddDays(30).ToString("yyyy-MM-dd"), cabin_class = "economy", passengers = new { adults = 1 }, }); using var http = new HttpClient { Timeout = Timeout.InfiniteTimeSpan }; using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); // bounds the whole stream using var request = new HttpRequestMessage(HttpMethod.Post, BaseUrl + "/get_flights?stream=true") { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + apiKey); request.Headers.TryAddWithoutValidation("Accept", "text/event-stream"); request.Headers.TryAddWithoutValidation("User-Agent", "my-flight-app/1.0"); // ResponseHeadersRead returns as soon as the headers arrive, so the body can be read while it is still being sent. using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token); if (!response.IsSuccessStatusCode) { // A problem with the request is reported before the stream starts, as an ordinary JSON error. Console.Error.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync(cts.Token)}"); return 1; } var offersBySearch = new Dictionary>(); // searchId -> offers: price an offer with the searchId of the result set it came in var currentSearch = new Dictionary(); // supplierId -> searchId of the result set being received var done = false; void Handle(string eventName, JsonElement data) { var supplier = data.TryGetProperty("supplierId", out var s) ? s.GetString() ?? "" : ""; switch (eventName) { case "start": Console.WriteLine($"{Stamp()} start, suppliers: {data.GetProperty("expectedSuppliers")}"); break; case "supplier_start": // the offers follow in offers_chunk events case "supplier_result": // every offer of the result set is in this event var inner = data.GetProperty("data"); var searchId = inner.GetProperty("searchId").GetString()!; var results = inner.GetProperty("results"); currentSearch[supplier] = searchId; if (!offersBySearch.TryGetValue(searchId, out var list)) offersBySearch[searchId] = list = new(); list.AddRange(results.GetProperty("offers").EnumerateArray().Select(o => o.Clone())); Console.WriteLine($"{Stamp()} {eventName} {supplier} {searchId} totalOffers {results.GetProperty("totalOffers")}"); break; case "offers_chunk": offersBySearch[currentSearch[supplier]].AddRange(data.GetProperty("offers").EnumerateArray().Select(o => o.Clone())); Console.WriteLine($"{Stamp()} chunk {data.GetProperty("chunkNumber")}/{data.GetProperty("totalChunks")} {supplier} +{data.GetProperty("offersInChunk")}"); break; case "supplier_complete": Console.WriteLine($"{Stamp()} {supplier} complete: {data.GetProperty("complete")}"); break; case "error": Console.Error.WriteLine($"{Stamp()} {supplier} failed: {data.GetProperty("error")}"); break; case "summary": Console.WriteLine($"{Stamp()} summary {data.GetProperty("summary")}"); break; case "done": done = true; break; } } await using var stream = await response.Content.ReadAsStreamAsync(cts.Token); using var reader = new StreamReader(stream, Encoding.UTF8); var currentEvent = "message"; var dataLines = new List(); while (await reader.ReadLineAsync(cts.Token) is string line) { if (line.Length == 0) { // A blank line ends the event. if (dataLines.Count > 0) { using var document = JsonDocument.Parse(string.Join("\n", dataLines)); Handle(currentEvent, document.RootElement); } currentEvent = "message"; dataLines.Clear(); } else if (line.StartsWith(':')) { // A comment such as ": keep-alive": ignore it. } else { var colon = line.IndexOf(':'); var name = colon < 0 ? line : line[..colon]; var value = colon < 0 ? "" : line[(colon + 1)..].TrimStart(' '); if (name == "event") currentEvent = value; else if (name == "data") dataLines.Add(value); } } Console.WriteLine($"{Stamp()} {offersBySearch.Values.Sum(offers => offers.Count)} offers in {offersBySearch.Count} result sets"); if (!done) { Console.Error.WriteLine("The stream ended without a done event: run the search again."); return 1; } return 0; ``` **Swift** ```swift // macOS 12 / iOS 15 or later: swift main.swift import Foundation let apiKey = ProcessInfo.processInfo.environment["FXPORT_API_KEY"] ?? "fxp_test_YOUR_KEY" let baseURL = "https://api.fx-port.com/api/v1" let startedAt = Date() func stamp() -> String { String(format: "[%5.2fs]", Date().timeIntervalSince(startedAt)) } let dayFormat = ISO8601DateFormatter() dayFormat.formatOptions = [.withFullDate] var request = URLRequest(url: URL(string: baseURL + "/get_flights?stream=true")!) request.httpMethod = "POST" request.timeoutInterval = 120 // the silence allowed between two chunks request.setValue("Bearer " + apiKey, forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("text/event-stream", forHTTPHeaderField: "Accept") request.setValue("my-flight-app/1.0", forHTTPHeaderField: "User-Agent") request.httpBody = try JSONSerialization.data(withJSONObject: [ "origin": "ALG", "destination": "CDG", "departure_date": dayFormat.string(from: Date().addingTimeInterval(30 * 86_400)), "cabin_class": "economy", "passengers": ["adults": 1], ] as [String: Any]) let (bytes, response) = try await URLSession.shared.bytes(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { // A problem with the request is reported before the stream starts, as an ordinary JSON error. var text = "" for try await line in bytes.lines { text += line } FileHandle.standardError.write(Data(("HTTP error: " + text + "\n").utf8)) exit(1) } var offersBySearch: [String: [Any]] = [:] // searchId -> offers: price an offer with the searchId of the result set it came in var currentSearch: [String: String] = [:] // supplierId -> searchId of the result set being received var done = false func handle(_ event: String, _ data: [String: Any]) { let supplier = data["supplierId"] as? String ?? "" switch event { case "start": print(stamp(), "start, suppliers:", (data["expectedSuppliers"] as? [String] ?? []).joined(separator: ", ")) case "supplier_start", "supplier_result": // the offers are here, or follow in offers_chunk events let inner = data["data"] as? [String: Any] ?? [:] let results = inner["results"] as? [String: Any] ?? [:] let searchId = inner["searchId"] as? String ?? "" currentSearch[supplier] = searchId offersBySearch[searchId, default: []] += results["offers"] as? [Any] ?? [] print(stamp(), event, supplier, searchId, "totalOffers", results["totalOffers"] ?? "?") case "offers_chunk": let offers = data["offers"] as? [Any] ?? [] offersBySearch[currentSearch[supplier] ?? "", default: []] += offers print(stamp(), " chunk", data["chunkNumber"] ?? "?", "of", data["totalChunks"] ?? "?", supplier, "+\(offers.count)") case "supplier_complete": print(stamp(), supplier, "complete:", data["complete"] as? Bool ?? false) case "error": print(stamp(), supplier, "failed:", data["error"] ?? "?") case "summary": let summary = try? JSONSerialization.data(withJSONObject: data["summary"] ?? [:], options: [.sortedKeys]) print(stamp(), "summary", summary.flatMap { String(data: $0, encoding: .utf8) } ?? "") case "done": done = true default: break } } // bytes.lines drops empty lines, so an event is handled when its data line arrives (each event has exactly one). var eventName = "message" for try await line in bytes.lines { if line.hasPrefix(":") { continue } // A comment such as ": keep-alive": ignore it. guard let colon = line.firstIndex(of: ":") else { continue } let name = String(line[..() // searchId -> offers received val currentSearch = mutableMapOf() // supplierId -> searchId being received var done = false fun handle(event: String, data: String) { val supplier = field(data, "supplierId") when (event) { "start" -> println(stamp() + " start: " + data.take(160)) "supplier_start", "supplier_result" -> { // the offers are here, or follow in offers_chunk events val searchId = field(data, "searchId") currentSearch[supplier] = searchId offersBySearch[searchId] = (offersBySearch[searchId] ?: 0) + countOffers(data) println(stamp() + " " + event + " " + supplier + " " + searchId + " totalOffers " + field(data, "totalOffers")) } "offers_chunk" -> { val searchId = currentSearch[supplier] ?: "" offersBySearch[searchId] = (offersBySearch[searchId] ?: 0) + countOffers(data) println(stamp() + " chunk " + field(data, "chunkNumber") + "/" + field(data, "totalChunks") + " " + supplier + " +" + field(data, "offersInChunk")) } "supplier_complete" -> println(stamp() + " " + supplier + " complete: " + field(data, "complete")) "error" -> System.err.println(stamp() + " " + supplier + " failed: " + field(data, "error")) "summary" -> println(stamp() + " summary " + Regex("\"summary\":(\\{[^}]*\\})").find(data)?.groupValues?.get(1)) "done" -> done = true } } fun main() { val apiKey = System.getenv("FXPORT_API_KEY") ?: "fxp_test_YOUR_KEY" val body = """{"origin":"ALG","destination":"CDG","departure_date":"${LocalDate.now().plusDays(30)}","cabin_class":"economy","passengers":{"adults":1}}""" val request = HttpRequest.newBuilder(URI.create("$BASE_URL/get_flights?stream=true")) .timeout(Duration.ofMinutes(2)) .header("Authorization", "Bearer $apiKey") .header("Content-Type", "application/json") .header("Accept", "text/event-stream") .header("User-Agent", "my-flight-app/1.0") .POST(HttpRequest.BodyPublishers.ofString(body)) .build() // ofLines() hands over each line as soon as it has arrived. startedAt = System.nanoTime() val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofLines()) if (response.statusCode() != 200) { // A problem with the request is reported before the stream starts, as an ordinary JSON error. System.err.println(response.statusCode().toString() + " " + response.body().iterator().asSequence().joinToString("\n")) exitProcess(1) } var event = "message" val dataLines = mutableListOf() response.body().forEach { line -> when { line.isEmpty() -> { // A blank line ends the event. if (dataLines.isNotEmpty()) handle(event, dataLines.joinToString("\n")) event = "message" dataLines.clear() } line.startsWith(":") -> {} // A comment such as ": keep-alive": ignore it. else -> { val colon = line.indexOf(':') val name = if (colon < 0) line else line.substring(0, colon) val value = if (colon < 0) "" else line.substring(colon + 1).removePrefix(" ") if (name == "event") event = value else if (name == "data") dataLines.add(value) } } } println(stamp() + " " + offersBySearch.values.sum() + " offers in " + offersBySearch.size + " result sets") if (!done) { System.err.println("The stream ended without a done event: run the search again.") exitProcess(1) } } ``` A run of the Node.js program against the sandbox (two suppliers, 184 offers; the first offers arrived after 1.8 seconds and the stream took 7 seconds): ~~~text [0.40s] start, suppliers: amadeus_aqc_dz_1, duffel_1 [1.45s] supplier_start duffel_1 fs_dfx_3f7927a9-e248-40ec-96ce-1453f3831f22 totalOffers 108 [1.81s] chunk 1/3 duffel_1 +50 [1.94s] chunk 2/3 duffel_1 +50 [1.94s] chunk 3/3 duffel_1 +8 [1.94s] duffel_1 complete: true [6.57s] supplier_start amadeus_aqc_dz_1 fs_4c684e2a-5de8-447e-9d5e-2a02ab8d8575 totalOffers 76 [6.93s] chunk 1/2 amadeus_aqc_dz_1 +50 [7.05s] chunk 2/2 amadeus_aqc_dz_1 +26 [7.05s] amadeus_aqc_dz_1 complete: true [7.05s] summary {"totalSuppliers":2,"successfulSuppliers":2,"failedSuppliers":0,"totalOffers":184} [7.05s] 184 offers in 2 result sets ~~~ ## Test it in the sandbox | You want to see | Send | |---|---| | Chunks | Any search that returns more than 50 offers from a supplier: `ALG` to `CDG` in 30 days returns 90 to 110 from `duffel_1` | | A result set with `complete: false`, then a second `searchId` | A route that touches Algeria, so that `amadeus_aqc_dz_1` answers (`ALG` to `CDG`); it happens on most searches, not on all | | One supplier only | `"supplier_id": "duffel_1"` | | An `error` event | A well-formed code that does not exist, for example `"origin": "XXX"` with `"supplier_id": "duffel_1"`: the supplier refuses the request, the stream carries one `error` event (`cause: supplier_error`), then `summary` (`success: false`) and `done`. A refusal of a request does not count as an outage; a supplier that keeps timing out or answering 5xx is skipped for a short while, for every agency of the environment (`cause: supplier_temporarily_unavailable`) | | A cut stream | Close your connection while the search runs: nothing is kept on the server | ## Related - [Search flights](https://fx-port.com/docs/flights/search.md): the request, the filters and the offers. - [API reference: Search flights](https://fx-port.com/docs/api/get-flights.md): every field, with the events listed. - [Price a flight offer](https://fx-port.com/docs/flights/price.md): the next step, with the `searchId` and the offer `id`. - [Rate limits](https://fx-port.com/docs/rate-limits.md) and [Errors](https://fx-port.com/docs/errors.md). --- # Flights API overview > Search, price, book, hold, pay and manage flight orders across every enabled GDS, airline and NDC supplier. Source: https://fx-port.com/docs/flights Search, price, book and manage flight orders across every enabled supplier through one API. A search fans out to the suppliers that can serve the route and returns the fares of each, with prices in your agency's currency. For a summary of what the API covers, who it is for and how to get access, see the [flight booking API for travel agencies](https://fx-port.com/b2b-flights-api). > [!NOTE] > **New suppliers are added without a change on your side.** FX-Port keeps connecting GDS, NDC and low-cost suppliers, direct airline deals and more office ids. An integration that selects suppliers automatically benefits from every new supplier and fare. Richer content (more fare families, ancillary types) ships under a new API version, so your current integration keeps working. ## Flight concepts Read these before you integrate: they apply to every flight endpoint. - [Booking flows](https://fx-port.com/docs/flights/booking-flows.md): Instant issue, hold then pay, and how cancelling works. - [Passengers and ages](https://fx-port.com/docs/flights/passengers.md): Passenger types, ages at departure, seated and lap infants, capacity limits. - [Flight pricing](https://fx-port.com/docs/flights/pricing.md): What each price field means and the `b2bPrice` formula. - [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md): Cancel, refund, void and exchange: what is self-serve and what is manual. - [Streaming search results](https://fx-port.com/docs/streaming.md): The first offers in about two seconds, supplier by supplier. ## Endpoints | Method | Path | Permission | Description | |---|---|---|---| | `POST` | `/api/v1/get_flights` | Read | Search for flights. Add `?stream=true` for [server-sent events](https://fx-port.com/docs/streaming.md) | | `POST` | `/api/v1/flights/flexible_search` | Read | The ±3-day price matrix | | `POST` | `/api/v1/flights/price_flight` | Read | Price an offer: required before booking | | `POST` | `/api/v1/flights/book_flight` | **Read+Write** | Create a booking, instant issue or hold | | `POST` | `/api/v1/flights/get_order_quote` | Read | The current price and state of a held order | | `POST` | `/api/v1/flights/pay_order` | **Read+Write** | Pay a held order and issue the tickets | | `POST` | `/api/v1/flights/cancel_order` | **Read+Write** | Cancel a **held (unpaid)** reservation | | `POST` | `/api/v1/flights/cancel_quote` | Read | Ask what cancelling an issued **Duffel** order would refund | | `POST` | `/api/v1/flights/confirm_cancel` | **Read+Write** | Confirm that cancellation: irreversible | | `POST` | `/api/v1/flights/get_booking` | Read | The booking, with the PDF links | | `POST` | `/api/v1/flights/get_ticket` | Read | The ticket PDF links | | `POST` | `/api/v1/flights/get_ticket_number` | Read | The e-ticket number | | `POST` | `/api/v1/flights/get_acd_documents` | Read | The passport documents of an ACD booking: [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md) | | `POST` | `/api/v1/flights/upload_acd_documents` | **Read+Write** | Add or replace those passports (multipart) | | `GET` | `/api/v1/flights/airports` | Public | Airport autocomplete | | `GET` | `/api/v1/flights/top-airports` | Public | The most searched airports | Every endpoint is described in the [API reference](https://fx-port.com/docs/api.md). ## What every flight endpoint has in common - **Auth.** `Authorization: Bearer fxp_test_...` (sandbox) or `fxp_live_...` (live). A read key is enough for the Read endpoints; the Read+Write endpoints answer `403 read_write_permission_required` to a read key. See [Authentication](https://fx-port.com/docs/authentication.md). - **Sandbox suppliers.** `amadeus_aqc_dz_1` (Amadeus Algeria: only routes that touch Algeria; supports hold and void) and `duffel_1` (Duffel: worldwide; instant ticketing only). See [Sandbox](https://fx-port.com/docs/sandbox.md). - **Names.** Requests use `snake_case`; older spellings still work and are answered with a `warnings` array. Responses keep their keys and add snake_case twins. See [Conventions and naming](https://fx-port.com/docs/conventions.md). - **Your agency.** It is always the one of your API key. Every flights endpoint accepts an `agency_id` (older spelling `agencyId`) so that older clients keep working, and ignores it: do not send it. - **Identifiers.** A booking is found by its PNR, its supplier order id or its FX-Port id (a UUID), depending on the endpoint: each page says which. - **Rate limits.** Search and general endpoints 6,000 requests per minute per key; booking endpoints (`book_flight`, `pay_order`, `cancel_order`, `get_order_quote`, `get_booking`) 1,200. See [Rate limits](https://fx-port.com/docs/rate-limits.md). - **Size.** A search response is 0.7 to 14 MB: send `Accept-Encoding: gzip`. ## Important notes > [!WARNING] > **Hold warning.** Holding a flight can prevent you from creating another booking for the same passenger on the same airline and route with some suppliers. If you no longer need a hold, cancel it (`cancel_order`) to release the PNR. A displayed deadline is not a guarantee: an airline can release the PNR early, and only a successful `pay_order` confirms the issue. See [Cancelling](https://fx-port.com/docs/flights/booking-flows.md#cancelling). > [!WARNING] > **Fee accuracy.** The exchange fees, refund fees and penalties in `conditions` are indicative and can differ from what is charged. Tell travelers that a ticket is "refundable for a fee" or "changeable for a fee"; do not quote exact amounts. > [!WARNING] > **Check `success`, not only the status.** After a booking, `pay_order`, `get_order_quote`, `cancel_quote` and `confirm_cancel` answer **`200`** with `"success": false` when the supplier refuses the request. See [Errors](https://fx-port.com/docs/errors.md#check-success-not-only-the-status). ## Error responses Every error carries a stable `code`: branch on `errors[0].code`, not on the text. The error body is described in [Errors](https://fx-port.com/docs/errors.md). | Status | Typical codes | Cause | |---|---|---| | `400` | `invalid_airport_code`, `invalid_date`, `invalid_cabin_class`, `missing_field`, `hold_not_supported`, `invalid_booking_type` | The request is wrong; read `errors[0].field` | | `401` | `authentication_required`, `invalid_api_key` | Missing, malformed or revoked key | | `402` | `insufficient_balance` | Live only: the balance cannot pay the booking | | `403` | `read_write_permission_required`, `agency_not_active` | A read key on a write endpoint, or an agency that is not approved. A booking of another agency is never a `403`: it is a `404`, like an unknown id | | `404` | `search_not_found`, `offer_not_found`, `offer_not_priced`, `booking_not_found` | The id is unknown for your agency and environment | | `409` | `duplicate_booking`, `booking_in_progress`, `invalid_booking_status` | The request is valid but not possible in the current state | | `410` | `offer_expired` | The supplier withdrew the offer: search again | | `413` | `request_too_large` | The body is too large | | `422` | supplier validation codes | The supplier rejected a passenger detail (a booking only) | | `429` | `rate_limit_exceeded`, `concurrency_limit_exceeded` | Too many requests, or too many streams open at the same time: wait for `Retry-After` | | `503` | `all_suppliers_failed`, `supplier_busy`, `service_overloaded`, `supplier_temporarily_unavailable`, `balance_lock_timeout` | No supplier answered, a supplier lane is full, FX-Port is too busy to prepare a large answer, or another operation holds your balance for a moment: retry with backoff (`Retry-After` says how long when it is sent) | The codes of each endpoint are in its page. ## Questions? Write to the FX-Port developer team at [dev@fx-port.com](mailto:dev@fx-port.com) for integration help, feature requests or to report an API error. For a void, an exchange, a re-issue or a refund of an issued Amadeus ticket, use **Request support** on the booking page in the FX-Port dashboard or write to the same address. --- # Booking flows > Step-by-step flight booking flows: instant issue, hold then pay, and how cancelling a held PNR works. Source: https://fx-port.com/docs/flights/booking-flows Every flight booking follows one of two flows. Both start with a search and a mandatory pricing step, and a booking can be cancelled in different ways depending on its state and its supplier. ## Instant issue (most common) The tickets are issued when you book. Instant issue, end to end. Actors: Your server, FX-Port API, Supplier. 1. Your server to FX-Port API: POST /get_flights 2. FX-Port API to Supplier: Search every enabled supplier 3. Supplier to FX-Port API: Fares 4. FX-Port API to Your server: Offers, one search_id per supplier 5. Your server to FX-Port API: POST /flights/price_flight 6. FX-Port API to Your server: Confirmed fare, offer cached 7. Your server to FX-Port API: POST /flights/book_flight (issue) 8. FX-Port API to Supplier: Book and issue the tickets 9. FX-Port API to Your server: Booking, PNR and ticket numbers 10. Your server to FX-Port API: POST /flights/get_booking 11. FX-Port API to Your server: Booking with the PDF link **Step 1** **Search for flights** `POST /api/v1/get_flights` returns offers from one or every supplier. Each supplier entry has a `searchId`: keep it for the next steps. See [Search flights](https://fx-port.com/docs/flights/search.md). **Step 2** **Price the offer** *(required)* `POST /api/v1/flights/price_flight` confirms the current fare and saves the priced offer. This step is **mandatory**: booking an offer that was not priced is refused with `404 offer_not_priced`. See [Price a flight offer](https://fx-port.com/docs/flights/price.md). **Step 3** **Book and issue the tickets** `POST /api/v1/flights/book_flight` with `"booking_type": "issue"`. The tickets are issued at once. The response has `booking.reference` (the supplier's order id), `booking.pnr` and the ticket documents. See [Book a flight](https://fx-port.com/docs/flights/book.md). **Step 4** **Read the booking and the PDF** `POST /api/v1/flights/get_booking` returns the whole booking. The PDF link (`booking.bucket_link`) appears a few seconds after the booking. See [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md) and [Get the ticket PDF](https://fx-port.com/docs/flights/get-ticket.md). ~~~text GET_FLIGHTS -> PRICE_FLIGHT -> BOOK_FLIGHT (issue) -> GET_BOOKING ~~~ Two rules that surprise people: - **A booking is idempotent per offer.** Booking an offer twice answers `409 duplicate_booking` with the PNR of the first booking, and a second request while the first is running answers `409 booking_in_progress`. A booking that failed releases the offer, so you can try again. - **Duffel books one offer per search.** After a Duffel booking, booking another offer of the **same** search fails with `409 offer_request_already_booked`: search again first. --- ## Hold, then pay (suppliers that support holds) Reserve the seats, pay later. Use it only when the **priced** offer says `supportHold: true` (today Amadeus Algeria). Never infer hold support from a supplier name: it depends on the supplier, the airline, the fare and the offer. Hold, then pay. Actors: Your server, FX-Port API, Supplier. 1. Your server to FX-Port API: POST /get_flights, then /flights/price_flight 2. Your server to FX-Port API: POST /flights/book_flight (hold) 3. FX-Port API to Supplier: Create the reservation, no ticket 4. FX-Port API to Your server: Held booking, awaiting_payment 5. Your server to FX-Port API: POST /flights/get_order_quote (optional) 6. FX-Port API to Your server: Current price and deadline 7. Your server to FX-Port API: POST /flights/pay_order 8. FX-Port API to Supplier: Issue the tickets, debit the balance 9. FX-Port API to Your server: Ticketed booking **Step 1** **Search and price**: as for an instant issue. **Step 2** **Create the hold** `POST /api/v1/flights/book_flight` with `"booking_type": "hold"`. The reservation is created without tickets and without a charge. The booking answer says `booking.status: "pending"`; every later answer (quote, `get_booking`) says `awaiting_payment`. A `hold` on an offer that cannot be held is refused with `400 hold_not_supported`. **Step 3** **Preview the price** *(optional)* `POST /api/v1/flights/get_order_quote` returns the current price and whether the hold can still be paid. See [Quote a held booking](https://fx-port.com/docs/flights/get-order-quote.md). **Step 4** **Pay and issue** `POST /api/v1/flights/pay_order` debits the agency balance and issues the tickets. Send the `order_id`. Do **not** send `currency`: it is refused. See [Pay a held booking](https://fx-port.com/docs/flights/pay-order.md). > [!CAUTION] > **A hold is not guaranteed until the payment succeeds.** `pay_order` fails when the airline has already released the reservation, when the deadline has passed, or when the booking is cancelled. Airlines can expire a hold earlier than the time that was displayed. The answer of `pay_order` is the only truth: handle a failure without charging the traveler, and search again for a new booking. ~~~text GET_FLIGHTS -> PRICE_FLIGHT -> BOOK_FLIGHT (hold) -> [GET_ORDER_QUOTE] -> PAY_ORDER -> GET_BOOKING ~~~ --- ## Cancelling What you can cancel by yourself depends on the state and on the supplier: | Booking | How | Money | |---|---|---| | A **held** (unpaid) reservation, Amadeus Algeria | [`cancel_order`](https://fx-port.com/docs/flights/cancel-order.md): deletes the reservation and releases the PNR | Nothing was paid: nothing is refunded | | An **issued** Duffel order | [`cancel_quote`](https://fx-port.com/docs/flights/cancel-quote.md) asks what the airline would refund, then [`confirm_cancel`](https://fx-port.com/docs/flights/confirm-cancel.md) cancels for good | The refund is credited to your FX-Port balance. Only for orders the airline lets you cancel (`order_not_cancellable` otherwise) | | An **issued** Amadeus Algeria ticket: void, exchange, re-issue, refund | Manual: see [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md) | By the FX-Port team | ### Why cancel a hold you abandon If you create a hold and then abandon it, the airline can **refuse a new booking for the same passenger** on the same route while the old reservation is pending. Cancel the old hold with `cancel_order` to release the PNR before you book that passenger again. ~~~text BOOK_FLIGHT (hold) -> (you change your mind) -> CANCEL_ORDER -> PNR released -> safe to book again ~~~ ## After the booking - `get_booking` is the source of truth for the status, the prices and the PDF links. - `get_ticket` returns the PDF links, and `get_ticket_number` the ticket number when the airline has issued it: see [Get the ticket number](https://fx-port.com/docs/flights/get-ticket-number.md). - A booking that needs passports (`requiresACD`) is completed with [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). - There are **no webhooks**: poll `get_booking`, not more often than every few seconds. --- # Passengers and ages > Passenger types, age rules evaluated at departure, seated and lap infants, required age arrays and capacity limits. Source: https://fx-port.com/docs/flights/passengers > [!NOTE] > These rules are **specific to flights**. Hotels and eSIMs have their own guest and traveler rules, in their own sections. > [!CAUTION] > **Ages are evaluated at the departure date of the first flight segment, not at the booking date.** A passenger who turns 12 on or before departure must be a young adult or an adult; a lap infant who turns 2 on or before departure must be a child and have a seat. The wrong type is rejected at check-in and can mean a rebooking fee. ## Passenger types | Count in a search | `type` when booking | Type in the response | Age at departure | Seat | Ages list (search) | |---|---|---|---|---|---| | `adults` | `adult` | `ADULT` | 18 and over | own | none | | `seniors` | `senior` | `SENIOR` | 60 and over | own | none | | `young_adults` | `young_adult` | `YOUNG` | 12 to 17 | own | `young_adult_ages` | | `children` | `child` | `CHILD` | 2 to 11 | own | `child_ages` | | `seated_infants` | `seated_infant` | `SEATED_INFANT` | under 2 | **own seat** | `seated_infant_ages` | | `infants` | `infant` | `HELD_INFANT` | under 2 | **lap** of an adult | `infant_ages` | - A **lap infant** (`infants`) sits on an adult's lap: no seat and, in most markets, no separate fare. Send it when the child stays under 2 for the whole trip. - A **seated infant** (`seated_infants`) has a seat and a ticket. Use it when the family wants a seat for a child under 2. - A **child aged 2 to 11** (`children`) is a regular seated passenger. An adult can travel with several children, and with a child of 2 and a lap infant at the same time. ## Search: the `passengers` object Counts, and an ages list for every type that has one. Every field is optional: **the default is one adult**, and a search with no passenger at all (`passengers` missing, `[]` or an empty text) is a search for one adult. Counts are JSON integers. `"2"`, `"2.0"` and `2.0` are read as 2 with a `coerced_value` warning, and an empty text or `null` means not sent. A negative count, a fraction, words, a boolean or a number above 99 is `400 invalid_passenger_count`. What limits a search in practice are the [capacity constraints](#capacity-constraints) below. ~~~json { "adults": 2, "seniors": 0, "young_adults": 0, "children": 2, "child_ages": [10, 6], "infants": 1, "infant_ages": [1], "seated_infants": 1, "seated_infant_ages": [1] } ~~~ | Ages list | Values | If you leave it out | |---|---|---| | `child_ages` | 2 to 11 | Every child is searched at age 11 | | `young_adult_ages` | 12 to 17 | Every young adult at age 17 | | `seated_infant_ages` | 0 to 1 | Every seated infant at age 1 | | `infant_ages` | 0 to 1 | Every lap infant at age 0 (no warning: 0 is the right default) | An ages list is a JSON array of integers. A text list (`"7,9"`), one bare number, and numeric text or an integral float for an age (`"7"`, `7.0`) are read with a `coerced_value` warning; a fractional age (`0.5`) counts as the years completed (0). An empty text or `null` means not sent. An age above 17, a negative one, or one that is not a number is `400 invalid_passenger_age`. **Ages are not required, and they win over the counts.** The API does not reject a count that disagrees with its ages: it changes the search and says so in a `coerced_value` warning. A missing age of a child, a young adult or a seated infant is reported with a warning that names the age assumed; a missing lap-infant age is not. Measured on the sandbox: | You send | The search prices | |---|---| | `"children": 2, "child_ages": [8]` | One adult and **one** child: the count is cut to the number of ages | | `"children": 1, "child_ages": [1]` | One adult only: **no child**, because an age under 2 is not a child | | `"children": 1, "child_ages": [15]` | A `YOUNG` traveler, not a child | | `"infants": 1, "infant_ages": [3]` | A `CHILD`, not an infant | | `"children": 2` and no ages | Two children aged 11 | Always check `travelerPricing` of the first offer against the travelers you meant, and send the real ages. > [!NOTE] > **Passenger limits can depend on the supplier.** Some suppliers require one distinct adult or senior for every passenger under two, seated and lap infants together. In an automatic or multi-supplier search FX-Port skips a supplier that cannot serve the mix and returns the others. `400 supplier_passenger_combination_unsupported` is returned only when **no** selected supplier can serve it. The rule does not apply to children aged 2 to 11. See [the example](https://fx-port.com/docs/flights/search/passenger-and-cabin.md#one-adult-with-two-infants). ## Capacity constraints | Rule | Limit | Violation code | |---|---|---| | Seated passengers (adults + seniors + young adults + children + seated infants) | At most **9** | `MAX_SEATS_EXCEEDED` | | Lap infants (`infants`) | Not more than adults + seniors | `LAP_INFANT_LIMIT` | | Seated infants under 2 (`seated_infants`) | Not more than adults + seniors | `SEATED_INFANT_LIMIT` | | Count of one type | A whole number from 0 to 99 | `400 invalid_passenger_count` | | An age | A whole number from 0 to 17 | `400 invalid_passenger_age` | | A traveler to look after the others | A normal online booking has at least one adult or senior. Unaccompanied minors depend on the airline | none | A broken rule is answered with `400`. The body keeps its long-standing flat shape (`error: "TRAVELER_RULE_VIOLATION"`, in `errors[0].code` as `traveler_rule_violation`), and `violations[]` lists every broken rule: ~~~json { "environment": "sandbox", "success": false, "requestId": "2b773b87", "error": "TRAVELER_RULE_VIOLATION", "message": "Total seated passengers cannot exceed 9", "violations": [ { "code": "MAX_SEATS_EXCEEDED", "message": "Total seated passengers cannot exceed 9", "max_seats": 9, "seated_passengers": 10 } ] } ~~~ The violation codes are `MAX_SEATS_EXCEEDED` (`max_seats`, `seated_passengers`), `LAP_INFANT_LIMIT` (`lap_infants`, `adult_senior_count`) and `SEATED_INFANT_LIMIT` (`seated_infants`, `adult_senior_count`). With no supplier left, the body is `SUPPLIER_PASSENGER_COMBINATION_UNSUPPORTED` with a `limitation` object (`UNDER_TWO_HOST_RATIO`, `underTwoPassengers`, `adultSeniorHosts`). ## Common mistakes ~~~jsonc // Children without ages: not an error, but the ages are guessed (11) // and the price may be wrong. { "adults": 1, "children": 2 } // Better: the real ages. { "adults": 1, "children": 2, "child_ages": [8, 5] } // A child who turns 2 before departure sent as a lap infant: // the airline rejects it at check-in, it needs its own seat. { "adults": 1, "infants": 1, "infant_ages": [1] } // Better: a traveler who is 2 at departure is a child. { "adults": 1, "children": 1, "child_ages": [2] } // More lap infants than adults: 400 traveler_rule_violation (LAP_INFANT_LIMIT). { "adults": 1, "infants": 2, "infant_ages": [0, 1] } // 10 seated passengers: 400 traveler_rule_violation (MAX_SEATS_EXCEEDED). { "adults": 5, "children": 5, "child_ages": [4, 5, 6, 7, 8] } // Split the trip into two bookings of at most 9 seats. ~~~ ## Booking: the passenger object `book_flight` takes a `passengers` array with **one object per traveler you priced** (lap infants included). The list must match the passengers of the search and the offer's `travelerPricing`: a mismatch is refused by the supplier. | Field | Type | Required | Description | |---|---|---|---| | `type` | string | Yes | `adult`, `senior`, `young_adult`, `child`, `seated_infant` or `infant` (a lap infant). Case-insensitive. Also accepted: `young`, `young_adults`, `infant_with_seat`, `held_infant`, `infant_without_seat`. Any other value is rejected with `400 invalid_passenger_type`. | | `gender` | string | Yes | `MALE` or `FEMALE` (`M` and `F` too), in any letter case. `X`, `U` or `UNSPECIFIED` for a passport marked X: FX-Port passes `UNSPECIFIED` on and the supplier decides whether it can issue the ticket. | | `first_name` | string | Yes | Given name as in the passport. Spaces, line breaks and tabs around it (and zero-width marks left by copy-paste) are removed before the checks. It needs a letter, at most 64 characters and no control character: otherwise `400 invalid_name`. | | `last_name` | string | Yes | Family name as in the passport, with the same rules. | | `date_of_birth` | string | Yes | `YYYY-MM-DD`. A real date that is not in the future: tomorrow is still accepted (time-zone slack), later is `400 invalid_date`. The age is checked against the departure date by the supplier. | | `identity_documents` | array | No | The passport (up to 5 documents). Send it for every traveler: airlines can require it, and international trips need it. | | `id` | string | No | An id of yours for the passenger. | > [!WARNING] > **Names.** FX-Port keeps every letter of any alphabet, spaces, hyphens, commas, periods and apostrophes, and replaces digits and symbols by a space; it does **not** reject accents or non-Latin scripts. But **airlines and GDS systems need Latin letters**: write the name the way the machine-readable zone of the passport does (`Jose`, `Muller`, `Mohammed`, `Ivanov`), because a ticket with a different name is refused at the airport. Amadeus writes the names in upper case on the booking (`AUDIT TESTER`). ### Identity document object | Field | Type | Required | Description | |---|---|---|---| | `type` | string | No | `passport` (the default). | | `number` | string | No | The document number: 1 to 40 letters, digits, spaces and `- . / _`. Spaces around it are removed. A JSON number is read as text with a `coerced_value` warning. Symbols or markup are `400 invalid_field_type`. | | `issuing_country` | string | No | The country that issued it: ISO 3166-1 alpha-2 (`DZ`, `EG`, `FR`). Any code of 2 to 4 letters, optionally followed by `-` and 1 to 3 letters or digits, is also accepted and passed on in upper case (`GB-SCT`, `USPR`, `GP-MG`: the values of FX-Port's own website); FX-Port does not convert it, so the supplier decides whether it knows the code. Anything else is `400 invalid_country_code`. | | `expiry_date` | string | No | `YYYY-MM-DD`. | | `nationality` | string | No | Accepted and **ignored**: suppliers receive the issuing country as the nationality. | Legacy names for every field are listed in [Book a flight](https://fx-port.com/docs/flights/book.md#legacy-names). --- # Flight pricing > What each flight price field means, the b2bPrice formula, the ACD block and how the exchange rate is presented. Source: https://fx-port.com/docs/flights/pricing Every flight price block exposes the same set of agency-facing money fields. They are in the offers of a search, in a priced offer, in a booking, in a quote and in a payment. ## Field meanings | Field | Meaning | |---|---| | `basePrice` | The airline's base fare, **before taxes**, in the agency currency. | | `taxesAndFees` | Taxes plus all platform-side fees, folded together. **Excludes** your agency commission. | | `agencyCommission` | The commission of your agency on this sale. | | `finalPrice` | What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. | | `b2bPrice` | **The amount debited from your agency balance**: `finalPrice - agencyCommission`. | | `totalAmount` | An alias of `b2bPrice` on the order quote only. | | `supplierCurrency`, `agencyCurrency` | The currency the supplier prices in and the currency of every amount above. | | `additionalServices` | Paid extras (see below). | All amounts are JSON numbers in the **currency of your agency**. The sandbox agency settles in DZD. ## The formula ~~~text finalPrice = basePrice + taxesAndFees + agencyCommission b2bPrice = finalPrice - agencyCommission (= debited from your balance) ~~~ A real Duffel offer from the sandbox (supplier prices in USD, agency in DZD): ~~~json { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16385.0, "taxesAndFees": 5226.2, "agencyCommission": 21.62, "finalPrice": 21632.82, "b2bPrice": 21611.2, "additionalServices": [] } ~~~ Check: `16385.00 + 5226.20 + 21.62 = 21632.82` and `21632.82 - 21.62 = 21611.20`. > [!NOTE] > **Rounding.** The same amount can differ by one hundredth between endpoints that round it separately: a hold had `finalPrice` `29856.0` and `b2bPrice` `29826.17` in the booking and in the payment, and `29855.99` and `29826.16` in `get_booking`. Compare amounts with a tolerance of 0.01, or use the amount of one endpoint consistently. ## The same amounts, different names | Where | Names | |---|---| | Search, price, book, pay | `pricing.basePrice`, `taxesAndFees`, `agencyCommission`, `finalPrice`, `b2bPrice` (twins: `base_price` ... `b2b_price`) | | One traveler | `travelerPricing[].price.base`, `total` (= the traveler's final price) and `b2bPrice` | | Order quote | `orderQuote.pricing` (same names, plus `totalAmount`) | | Payment | `paymentDetails.paymentAmount` (= `b2bPrice`) | | `get_booking` | `booking.total_price`, `agency_commission`, and `pricingBreakdown`: `base_price`, `taxes_and_fees`, `agency_commission`, `final_price`, `b2b_price` | | Account booking detail | `totalPrice`, `netPrice` (= `totalPrice - agencyCommission`) and `agencyCommission` | ## Exchange rate When `supplierCurrency` differs from `agencyCurrency` (for example Duffel prices in `USD` and your agency settles in `DZD`) the rate is at the **top level** of the response, never inside `pricing`: - search: in each supplier entry, `suppliers[n].exchangeRate` (a number); - price, book and pay: at the top level of the response, `exchangeRate` (a number), for example `250.0`; - quote, cancel quote and confirm cancel for **Duffel** orders: an **object** `{ "rate": 250.0, "marginPercent": 0, "fromCurrency": "USD", "toCurrency": "DZD" }`. When both currencies are the same (Amadeus Algeria prices in DZD for a DZD agency) there is no `exchangeRate`. Do not apply the rate yourself: every amount in a response is already in the agency currency. ## Per-traveler pricing Search, price and booking responses also have a `travelerPricing` array with a per-traveler `price` object (`base`, `total`, `b2bPrice`, and the two currencies), so you can show a breakdown. The `total` of the travelers add up to `pricing.finalPrice`, and their `b2bPrice` to `pricing.b2bPrice`. Children and infants are usually cheaper than adults: read each traveler's own price. ## Extra services `additionalServices` lists paid extras. In a search it is a list (empty for most offers). In a priced offer it is an object with a `baggage` list: each entry has an `id`, a `type` (`checked`), a `quantity`, the weight limit, `priceAmount` in the supplier's currency and `convertedPriceAmount` in yours, and the passengers and segments it applies to. FX-Port lists them so you can show the price of an extra bag; adding one to a booking is not part of the public API. ## The `acd` block (Amadeus Algeria) Offers of Amadeus Algeria carry `pricing.acd`. It says whether the fare is an **ACD fare**: a fare of some Algeria routes whose price is adjusted by a rule, and whose booking needs **one passport copy for every traveler**, which you send after the booking. Offers of other suppliers (Duffel) have no `acd` key. ~~~json { "applied": false } ~~~ | `pricing.acd.applied` | Meaning | |---|---| | `false` | An ordinary fare. Nothing to do. | | `true` | An ACD fare. The offer, the priced offer and the booking say `requiresACD: true`, and the booking needs passports: see [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). | An ACD fare carries more keys in `acd` (`scenario`, `A` or `B`, and amounts that show how the price was worked out). They are informational, can change without notice and are not meant for travelers: do not build on them. An agency that has an ACD-exempt company can send `acd_exempt_company_id` in a search; the fares then have `{ "applied": false, "exempt": true }`. In the sandbox, `CDG` to `ALG` on `amadeus_aqc_dz_1` returns ACD fares only. ## Related - [Search flights](https://fx-port.com/docs/flights/search.md), [Price a flight offer](https://fx-port.com/docs/flights/price.md) and [Book a flight](https://fx-port.com/docs/flights/book.md). - [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). --- # Post-booking operations > Void, exchange, re-issue and refund of issued flight tickets, and how to request them from the FX-Port team today. Source: https://fx-port.com/docs/flights/post-booking > [!WARNING] > **This page is about flight tickets only.** For **hotels**, **transfers** and **eSIMs**, cancellation, refunds where the rate allows, and booking updates are available through the API and the dashboard. Only **issued flight tickets** still need a person for most operations, because airlines and GDS systems gate those authorisations. ## What you can do yourself | Operation | Availability today | |---|---| | **Cancel a held (unpaid) reservation** | Self-serve with [`cancel_order`](https://fx-port.com/docs/flights/cancel-order.md): releases the PNR, no refund (Amadeus Algeria and Travelport holds) | | **Refund an issued Duffel order** | Self-serve: [`cancel_quote`](https://fx-port.com/docs/flights/cancel-quote.md) shows what the airline refunds, [`confirm_cancel`](https://fx-port.com/docs/flights/confirm-cancel.md) cancels and credits your FX-Port balance. Only when the airline lets the order be cancelled through the API | | **Void an issued ticket** (same day) | Manual: write to the FX-Port team | | **Exchange or re-issue** | Manual: write to the FX-Port team | | **Refund an issued Amadeus Algeria ticket** | Manual: write to the FX-Port team | > [!CAUTION] > Refund eligibility is **never guaranteed**: it depends on the airline, the fare rules and the supplier. `cancel_quote` tells you the amount **before** you confirm, and `confirm_cancel` cannot be undone. Do not build a customer-facing flow that promises an automatic refund for every ticket. ## Which identifier to use | Operation | Send | |---|---| | `cancel_order`, `get_order_quote`, `pay_order` | `order_id` (the supplier's order id), or `booking_id` or `pnr` | | `cancel_quote` | `order_id`: the supplier's order id (`ord_...` for Duffel) | | `confirm_cancel` | `cancellation_id` from `cancel_quote`, and `order_id` so that the booking is updated | | `get_booking`, `get_ticket`, `get_ticket_number`, ACD endpoints | `booking_id`: a PNR, an order id or an FX-Port UUID | PNRs are not unique across suppliers: prefer the order id. ## Support and turnaround Manual does not mean slow. Post-booking requests are handled by a dedicated team that you can reach directly, not by an anonymous ticket portal: - **Several channels.** **Request support** on every booking in the dashboard, email to [dev@fx-port.com](mailto:dev@fx-port.com), plus a direct messaging channel and a phone contact agreed with your account manager at onboarding. - **Around the clock.** Coverage is 24/7, weekends and holidays included, because disruptions do not keep office hours. - **A named contact.** Integrated partners get a dedicated account and technical contact who already knows the integration. - **Fast by design.** Urgent cases, such as same-day voids and irregular-operations rebookings, are prioritised and actioned as soon as they arrive, inside the airline's deadlines. If a request is not moving fast enough, escalate on the same thread. ## How to request a manual operation Use any support channel: - Open the booking confirmation page in the FX-Port dashboard and select **Request support**. - Write to [dev@fx-port.com](mailto:dev@fx-port.com). - Use your dedicated channel or account contact for anything urgent. Include: 1. The **order id** (`booking.reference` in the book and pay responses, `orderId` in every response) or the FX-Port booking id (UUID). 2. The **PNR**, for cross-reference. 3. The operation you need: **void**, **exchange or re-issue**, or **refund**. 4. For an exchange: the new dates or itinerary. 5. Any airline reference or context. The team confirms the eligibility, quotes the fees and processes the operation. ## Other products | Product | Post-booking today | |---|---| | **Hotels** | Cancellation and refund according to the cancellation policy of the rate, booking retrieval and voucher re-issue | | **Transfers** | Cancellation and booking updates | | **eSIMs** | Top-ups, usage and status | | **Flights (held, unpaid)** | Self-serve cancellation with [`cancel_order`](https://fx-port.com/docs/flights/cancel-order.md) | | **Flights (issued, Duffel)** | Self-serve refund with [`cancel_quote`](https://fx-port.com/docs/flights/cancel-quote.md) and [`confirm_cancel`](https://fx-port.com/docs/flights/confirm-cancel.md) | | **Flights (issued, Amadeus Algeria)** | Assisted: void, exchange and refund through the channels above | ## What is coming Closing the remaining gap is a priority. FX-Port is building self-serve post-booking operations for flights and enables them **per supplier** as airline and GDS authorisations are granted. Releases are documented here and announced by email to API users. Because these are significant additions, richer post-booking capabilities may ship under a **new API version**, so your integration is never broken. If you have a priority use case, send it from the booking confirmation page or by email: partner demand decides which suppliers we unlock first. --- # Airport autocomplete > Airport autocomplete for origin and destination inputs. No authentication required. Supports 30+ language locales. Endpoint: `GET /api/v1/flights/airports` Source: https://fx-port.com/docs/flights/airports Airport autocomplete for origin and destination inputs. Accepts an IATA code, a city name or an airport name and returns matching airports and cities from a global database. **No authentication is required.** **Permission:** Public (no API key). The two endpoints of this page are not counted by the key rate limits, but they are limited per IP address: debounce your typeahead. > [!NOTE] > Built for a debounced typeahead: wait about 300 ms after the last keystroke and cancel the request that is in flight. The answer is a flat **array**, not an object, and it has no `environment` key. It carries `Cache-Control: public, max-age=60, s-maxage=300`: a browser can reuse an answer for a minute and a CDN for five. ## Search airports `GET /api/v1/flights/airports` ### Query parameters | Parameter | Type | Required | Description | |---|---|---|---| | `query` | string | Yes | An IATA code (`ALG`), a city (`Paris`) or an airport name: 1 to 64 characters once cleaned (control characters are removed and runs of spaces become one). One letter is valid (a prefix of a code). | | `locale` | string | No | The language of the names. Default `en`. Written `ll` or `ll-RR`; case and `_` or `-` are normalised (`FR` is `fr`, `es_ar` is `es-AR`) and anything else is read as `en`. See [Supported locales](#supported-locales). | ### Request examples ~~~bash # IATA code lookup curl "https://api.fx-port.com/api/v1/flights/airports?query=CDG&locale=en" # City name, French names curl "https://api.fx-port.com/api/v1/flights/airports?query=Paris&locale=fr" # Arabic names curl "https://api.fx-port.com/api/v1/flights/airports?query=ALG&locale=ar" ~~~ ### Response example The response is a flat **array** of records. A real record has about 40 keys (it is the answer of an airport search service, forwarded as it is); only the ones below are meant for your use. This is the answer for `query=ALG`: ~~~json [ { "ap": "ALG", "airportname": "Houari Boumediene", "cityname": "Algiers, Algeria", "cc": "DZ", "country": "Algeria", "timezone": "Africa/Algiers", "utc": "+01:00", "lat": 36.6985, "lng": 3.20672, "displayname": "Houari Boumediene, Algiers, Algeria, (ALG)", "shortdisplayname": "Algiers, Algeria (ALG)", "loctype": "ap", "cityonly": "Algiers", "isMetroOnly": false }, { "ap": "AHO", "airportname": "Alghero-Fertilia", "cityname": "Alghero, Sardinia, Italy", "cc": "IT", "country": "Italy", "timezone": "Europe/Rome", "utc": "+02:00", "displayname": "Alghero-Fertilia, Alghero, Sardinia, Italy, (AHO)", "shortdisplayname": "Alghero, Sardinia, Italy (AHO)" } ] ~~~ ### Key fields | Field | Description | |---|---| | `ap` | The IATA code: use it as `origin` or `destination` in a search. | | `airportname` | The airport name in the requested locale. | | `cityname` | City and country in the requested locale. | | `cityonly` | The city alone. | | `cc` | ISO 3166-1 alpha-2 country code. | | `country` | The country name. | | `timezone` | The IANA time zone, for example `Europe/Paris`. | | `utc` | The UTC offset at the time of the query. | | `lat`, `lng` | Coordinates. | | `displayname` | A full label for a result list. | | `shortdisplayname` | A short label for the selected value. | | `isMetroOnly` | `true` for a **city code** that stands for all the airports of a city (`PAR`, "All airports"): it is valid as an `origin` or `destination`. | A query for `PAR` returns the city entry (`ap: "PAR"`, `isMetroOnly: true`) first, then `CDG`, `ORY`, `BVA`, `LBG` and other airports near Paris. The other keys (identifiers, bounding boxes, images, search-form strings, display types ...) are raw data of the lookup service, mixed in lower case, camelCase and snake_case. They are not part of the API and can change: use only the fields above. ### Errors | Status | Code | When | |---|---|---| | `422` | `invalid_query` | `query` is blank, or longer than 64 characters once cleaned. `errors[0].field` is `query`. | | `422` | `validation_error` | `query` is missing (`errors[0].field` is `query`) or empty. | | `429` | `rate_limit_exceeded` | Too many lookups from one IP address. Wait for the seconds in `Retry-After`. | | `502` / `504` | | The lookup service failed (`502`) or timed out (`504`). Retry once after a moment. | When the lookup service is down, the endpoint can answer `200` with up to 10 airports from a local list instead of an error. ## Top airports `GET /api/v1/flights/top-airports` returns the most searched airports, for the first suggestions of an empty search box. No key is needed. | Parameter | Type | Required | Description | |---|---|---|---| | `airport_type` | string | No | `from` (default) for departure airports or `to` for arrival airports. | | `locale` | string | No | The language of the names, read as for `search airports`. Default `en`. | | `limit` | integer | No | How many airports, 1 to 10. Default 5. | | `market_region` | string | No | A country or region code, to rank airports for one market. | ~~~bash curl "https://api.fx-port.com/api/v1/flights/top-airports?airport_type=to&locale=fr&limit=2" ~~~ The answer is `{ "environment": "live", "airports": [...], "context": { "locale", "airport_type", "agency_id", "market_region" } }`. `context` echoes what the ranking used: `locale` and `market_region` (upper case; `null` when you sent none), and `agency_id`, an internal ranking scope of FX-Port's own apps that is `null` in your answers. Without a key `environment` is always `live`. Each airport has the fields of the table above plus `payload_locale`, `popularity_score`, `popularity_scopes` (`locale` and `global` scores), `recent_score` and `last_seen_day`. An invalid `airport_type` or a `limit` outside 1 to 10 is answered with `422`. ## Supported locales Pass `locale=XX` in the query string. | `locale` | Language | |---|---| | `en` | English (default) | | `fr` | French | | `ar` | Arabic | | `de` | German | | `es` | Spanish | | `es-AR` | Spanish (Argentina) | | `it` | Italian | | `pt` | Portuguese | | `nl` | Dutch | | `nl-BE` | Dutch (Belgium) | | `pl` | Polish | | `cs` | Czech | | `ru` | Russian | | `uk` | Ukrainian | | `el` | Greek | | `ro` | Romanian | | `da` | Danish | | `no` | Norwegian | | `sv` | Swedish | | `fi` | Finnish | | `tr` | Turkish | | `he` | Hebrew | | `ko` | Korean | | `ja` | Japanese | | `zh-CN` | Chinese (Simplified) | | `zh-TW` | Chinese (Traditional) | | `th` | Thai | | `id` | Indonesian | | `ms` | Malay | | `vi` | Vietnamese | | `en-PH` | English (Philippines) | The locale changes the names of airports and cities. The IATA code (`ap`) and the country code (`cc`) are always the same. A well-formed value that is not in the list (`xx`) is **not rejected**: it is passed on to the lookup service, and in our tests the names came back in English. ## Related - [Search flights](https://fx-port.com/docs/flights/search.md): the next step, with the `ap` code. --- # Search flights > Search for flights across one, several or every enabled supplier. Start here, then expand the examples by route, passenger, filter, supplier or itinerary type. Endpoint: `POST /api/v1/get_flights` Source: https://fx-port.com/docs/flights/search Search available flight offers across every compatible supplier. Results are grouped under `suppliers[]`: each supplier has its own `searchId` and its own list of offers. **Permission:** Read (a read key is enough; so is a read+write key). **Rate group:** search, 6,000 requests per minute per key. > [!WARNING] > **Save the supplier-level `searchId`.** Pass `suppliers[n].searchId` to `price_flight` and `book_flight`, never the top-level `requestId`. A request that fans out to two suppliers returns two `searchId` values, and an offer can only be priced with the `searchId` of the supplier entry it is in. ## What to expect - **Size.** A search returns 0.7 MB (one-way, two suppliers) to 14 MB of JSON (a busy round trip with 2,000 offers). **Send `Accept-Encoding: gzip`**: the same 695 KB response is 26 KB on the wire. There is **no pagination**: `results.totalOffers` always equals the length of `results.offers`. - **Time.** Two to eight seconds, because the response waits for the slowest supplier. To show the first fares after about two seconds, read the results as a [stream](https://fx-port.com/docs/streaming.md). - **Partial results are normal.** If at least one supplier answers, the status is `200` and the others are listed in `failures[]`. Only when no supplier answers is the status `503`. - **Values are read generously, garbage is refused.** A value a real client can send (`"2"` for a count, `premium economy` for a cabin, a space between airline codes) is read, with a `coerced_value` warning when its meaning had to be guessed ([Conventions](https://fx-port.com/docs/conventions.md)). Only a value that makes no sense is a `400` with a stable code, before any supplier is called. - **Identical searches are merged.** Searches with the same parameters that arrive at the same moment from the same agency, environment and currency are merged into **one** supplier fan-out. Nothing is kept afterwards, and every caller still gets its own `requestId` and headers and a `searchId` that works with `price_flight`. - **Sandbox suppliers.** `amadeus_aqc_dz_1` (Amadeus Algeria, only for routes that touch Algeria; supports hold, void and flexible dates) and `duffel_1` (Duffel, worldwide; instant ticketing only). See [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md). ## Request fields ### Route | Field | Type | Required | Description | |---|---|---|---| | `origin` | string | Yes¹ | Departure IATA code: three ASCII letters, an airport (`ALG`, `CDG`) or a city (`PAR`, `LON`). Lower case is accepted and read as upper case. | | `destination` | string | Yes¹ | Arrival IATA code. Must differ from `origin`. | | `departure_date` | string | Yes¹ | `YYYY-MM-DD`, a real calendar date from yesterday (time-zone slack) up to 365 days ahead (`date_too_far` beyond). | | `return_date` | string | No | Makes the search a round trip. On or after `departure_date`, and within the same 365 days. | | `segments` | array | No² | 1 to 6 flights `{ origin, destination, departure_date }` instead of the four fields above. Dates must not go backwards. See [Open-jaw and multi-city](https://fx-port.com/docs/flights/search/complex-itineraries.md). | | `url` | string | No | A path-style search used by FX-Port's own site, for example `flights/ALG-CDG/2026-12-15/economy/1adults`. It wins over every route field. Prefer the fields above: they are validated field by field. | _¹ Required unless `segments` or `url` is sent. ² A list of segments replaces `origin`, `destination`, `departure_date` and `return_date`._ The type of the search comes from the route: one segment is `ONE_WAY`; two segments where the second is the exact reverse of the first (or `return_date`) is `ROUND_TRIP`; two segments that are not reverses is `OPEN_JAW`; three or more is `MULTI_CITY`. Several airports can be written `"ALG,ORN"` or as a list `["ALG","ORN"]` (up to five, in `origin` and `destination` only: a segment takes one code): the first one is searched and the others are kept as alternatives that a supplier may ignore (Duffel does). Search one airport per request when you need a predictable result. ### Passengers and cabin | Field | Type | Required | Description | |---|---|---|---| | `passengers` | object | No | Counts and age lists. **Optional: the default is one adult.** See [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md). | | `cabin_class` | string | No | `economy` (default), `premium_economy`, `business`, `first` or `any` (no cabin restriction). Case, spaces, dashes and underscores do not matter: `Premium Economy`, `premium-economy` and `PREMIUM_ECONOMY` are the same cabin. `premium`, `economy_class`, `business_class`, `first_class` and `premium_economy_class` are read as their cabin with a `coerced_value` warning, and an empty text means the default cabin. Any other value (`coach`, `Y`) is rejected with `400 invalid_cabin_class`. | The cabin you send is lower case; the cabin of each segment in the response is upper case (`ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS`, `FIRST`). ### Filters | Field | Type | Description | |---|---|---| | `nonstop` | boolean | `true` returns non-stop offers only. | | `refundable` | boolean | `true` asks the supplier for refundable fares. A hint, not a guarantee: see the table below. | | `checked_bags` | boolean | `true` asks for fares with a checked bag included. | | `max_price` | number | A price ceiling. **Not enforced in the sandbox**; see below. `0`, an empty text and `null` mean no ceiling: the field is ignored (`0` and an empty text add a `coerced_value` warning). Numeric text (`"1500"`) is read as a number with a warning. A negative or non-numeric value (`"cheap"`, `"150000 DZD"`) is `400 invalid_field_type`. | | `included_airlines` | string[] or text | Up to 20 airline codes: `["AF","AH"]`, or a text with the codes separated by commas, semicolons, bars or spaces (`"AF,AH"`, `"AF AH"`). Only these carriers. | | `excluded_airlines` | string[] or text | Up to 20 airline codes to remove, written like `included_airlines`. | | `preferred_airlines` | string[] or text | Behaves exactly like `included_airlines`: a strict allow-list, **not** a ranking. | | `flexible` | boolean | Also returns the date matrix (`flexPrices`) from suppliers that support it, next to the offers. See [Flexible-date search](https://fx-port.com/docs/flights/flexible-search.md). | | `flexible_only` | boolean | The matrix without offers. `POST /flights/flexible_search` sends it for you. | | `from_cc`, `to_cc` | string | ISO country codes of the origin and the destination: **exactly two letters** (`DZ`; a three-letter value is `400 invalid_country_code`). Used to choose suppliers; see [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md). | | `countries` | string[] | Up to 10 country codes of the route, when you want to override the countries FX-Port infers from the airports. | | `filters` | object | Only the three airline lists above are read from it. Any other key (`{"max_stops": 0}`) is ignored. | **What each supplier enforces.** `filtersApplied` in the response repeats what you **asked for**, for every supplier, not what the supplier honoured: | Filter | Amadeus Algeria (`amadeus_aqc_dz_1`) | Duffel (`duffel_1`) | |---|---|---| | `nonstop` | Enforced. | Enforced. | | `refundable` | Sent to the supplier; the result still mixes refundable and non-refundable fares (70 offers became 53, still mixed). | Ignored. | | `checked_bags` | Enforced (included checked bags only). | **Ignored**: offers with no checked bag are returned. | | `max_price` | Sent in the **supplier's** currency (DZD), and **not enforced in the sandbox**: with `max_price: 40000` offers up to 242,844 DZD came back. | Never sent; offers up to 280,279 DZD came back. | | `included_airlines`, `preferred_airlines` | Native carrier restriction: the validating carrier is in the list. | A filter on the returned offers: an offer stays when **any** marketing, operating or validating carrier matches, so `validatingAirline` can be a carrier you did not list. | | `excluded_airlines` | Native. | A filter on the returned offers. | Always read the offer itself (`pricing`, `travelerPricing[].fareDetailsBySegment[].includedCheckedBags`, `conditions`) and filter again on your side when a filter is a hard requirement. You can send `included_airlines` and `excluded_airlines` together: the excluded codes are taken out of the included list. ### Supplier selection | Field | Type | Description | |---|---|---| | *(none)* | | **Recommended.** FX-Port selects the compatible suppliers. | | `supplier_id` | string | Restrict the search to one supplier id (a comma separated list is accepted). Strict: an unknown or disabled id is `400 invalid_supplier_selection`. | | `supplier_ids` | string[] | Restrict the search to this set. Strict, up to 10. Merged with `supplier_id`. | | `preferred_suppliers` | string[] | A hint: ids that are not usable are ignored and the automatic selection applies. | | `max_suppliers` | integer | Keep only the first N selected suppliers, in priority order. `"3"` and `3.0` are read as 3 with a `coerced_value` warning; an empty text means no limit. | ### Other fields | Field | Type | Description | |---|---|---| | `loyalty_program_accounts` | array | Up to 10 frequent-flyer accounts `{ airline_iata_code, account_number, first_name, last_name }`. They are stored with the search and matched by passenger name at booking. | | `acd_exempt_company_id` | string | For agencies that settle in DZD and have an ACD-exempt company: its id, to price the fares without ACD. A company that is not linked to your agency is `403 acd_exemption_invalid`, a non-DZD agency `403 acd_exemption_currency`. See [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). | | `agent_id` | string | Your own id of the agent who searches; stored with the search and the booking. | ### Legacy names These spellings still work. The response says so in `warnings` (`deprecated_field`) and in the headers `Deprecation` and `X-FX-Deprecated-Fields`. New code should send the name in the second column. If a request holds a legacy name **and** its replacement with different values (`direct` and `nonstop`), the replacement wins and an `ignored_field` warning names the dropped one. | Legacy name | Use instead | |---|---| | `departureDate`, `returnDate`, `cabinClass` | `departure_date`, `return_date`, `cabin_class` | | `travelers` | `passengers` | | `direct` | `nonstop` | | `checkedBags`, `maxPrice`, `flexibleOnly` | `checked_bags`, `max_price`, `flexible_only` | | `fromCc`, `toCc` | `from_cc`, `to_cc` | | `supplierId`, `supplierIds`, `preferredSuppliers`, `maxSuppliers` | `supplier_id`, `supplier_ids`, `preferred_suppliers`, `max_suppliers` | | `includedAirlines`, `include_airlines`, `includeAirlines`, `included_airline`, `include_airline`, `airline_include`, `airlineInclude` | `included_airlines` | | `excludedAirlines`, `exclude_airlines`, `excludeAirlines`, `excluded_airline`, `exclude_airline`, `airline_exclude`, `airlineExclude` | `excluded_airlines` | | `preferredAirlines`, `airlines` | `preferred_airlines` | | `passengers.young`, `passengers.youngAdults` | `passengers.young_adults` | | `passengers.toddlers`, `passengers.seatedInfants` | `passengers.seated_infants` | | `passengers.childAges`, `youngAdultAges`, `seatedInfantAges`, `infantAges` | `passengers.child_ages`, `young_adult_ages`, `seated_infant_ages`, `infant_ages` | | `segments[].date`, `segments[].departureDate`, `segments[].departure` | `segments[].departure_date` | | `agentId`, `acdExemptCompanyId`, `loyaltyProgramAccounts` | `agent_id`, `acd_exempt_company_id`, `loyalty_program_accounts` | A key the endpoint does not read at all (a typo, the flat `adults`, `max_stops` inside `filters`) never fails the request: it is ignored and reported as an `ignored_field` warning. ## Examples by category - [Basic routes](https://fx-port.com/docs/flights/search/basic-routes.md): One-way, round trip, non-stop and every-supplier searches. - [Passengers & cabins](https://fx-port.com/docs/flights/search/passenger-and-cabin.md): Family mix, lap and seated infants, senior and young adult, business, premium economy, all cabins. - [Filters](https://fx-port.com/docs/flights/search/filters.md): Refundable, checked bags, price cap, airline include and exclude, flexible dates. - [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md): Automatic selection, exact supplier ids and the routing rules. - [Open-jaw and multi-city](https://fx-port.com/docs/flights/search/complex-itineraries.md): Two or more segments. ## Minimal request **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` ## Response essentials The example is a trimmed **real sandbox response** to the request above: the Duffel entry with one non-stop offer (the sandbox airline `ZZ` is Duffel's test carrier). The other supplier's entry has the same shape. Arrays are cut and the `amenities` list is shortened; the field tables below describe everything that is not shown. ~~~json { "environment": "sandbox", "success": true, "requestId": "54139f32", "searchType": "ONE_WAY", "flexible": false, "processingTime": 2.243, "suppliers": [ { "success": true, "searchId": "fs_dfx_a3aeaa36-74ad-424b-b88d-e6bdc9a4ba94", "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "meta": { "supplierId": "duffel_1", "technologyType": "duffel", "supplierName": "Duffel", "processingTime": 1.34, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "results": { "totalOffers": 92, "supplierCurrency": "USD", "agencyCurrency": "DZD", "offers": [ { "id": "fx_offer_9ab6d940", "type": "flight_offer", "supplier": { "id": "duffel_1", "name": "Duffel", "originalId": "off_034q6JKdiQDZ8HIDMFus64" }, "bookableSeats": null, "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "instantTicketingRequired": false, "lastTicketingDate": "2026-11-01", "lastTicketingDateTime": "2026-11-01T15:52:40.830072Z", "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16385.0, "taxesAndFees": 5226.2, "agencyCommission": 21.62, "finalPrice": 21632.82, "b2bPrice": 21611.2, "additionalServices": [] }, "itinerary": [ { "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": [ { "id": "seg_0000BC0a5iUChSV6Qrz0Co", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "2", "datetime": "2026-12-15T10:50:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "CDG", "airportName": "Paris Charles de Gaulle Airport", "cityName": "Paris", "countryCode": "FR", "terminal": "1", "datetime": "2026-12-15T13:14:00", "timeZone": "Europe/Paris" }, "flight": { "carrierCode": "ZZ", "carrierName": "Duffel Airways", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/ZZ.svg", "flightNumber": "7426", "aircraftCode": null, "aircraftName": null, "operatingCarrier": "ZZ", "operatingCarrierName": "Duffel Airways" }, "duration": "PT2H24M", "cabin": "ECONOMY", "bookingClass": null, "fareBasis": "Y20LGTN2", "brandedFare": "BASIC", "brandedFareLabel": "Basic" } ], "conditions": { "changeBeforeDeparture": { "allowed": false }, "refundBeforeDeparture": { "allowed": false } } } ], "travelerPricing": [ { "travelerId": "pas_0000BC0a5iH5UE0bmBqWkl", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "base": 16385.0, "total": 21632.82, "b2bPrice": 21611.2 }, "fareDetailsBySegment": [ { "segmentId": "seg_0000BC0a5iUChSV6Qrz0Co", "cabin": "ECONOMY", "fareBasis": "Y20LGTN2", "brandedFare": "BASIC", "brandedFareLabel": "Basic", "bookingClass": null, "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [ { "description": "WIFI", "isChargeable": true, "amenityType": "WIFI", "amenityProvider": { "name": "BrandedFare" } } ] } ] } ], "conditions": { "refundBeforeDeparture": { "allowed": false }, "changeBeforeDeparture": { "allowed": false } }, "fareRules": { "exchange": { "allowed": false }, "refund": { "allowed": false }, "revalidation": { "allowed": false }, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "validatingAirline": [{ "code": "ZZ", "name": "Duffel Airways" }] } ] }, "exchangeRate": 250.0 } ], "summary": { "totalSuppliers": 2, "successfulSuppliers": 2, "failedSuppliers": 0, "totalOffers": 158 } } ~~~ Names: next to the keys you feed back into the next calls the response adds **snake_case twins** with the same value. At the top level: `request_id`, `processing_time`, `search_type`. In each supplier entry: `search_id`, `support_hold`, `support_void`, `void_deadline`, `exchange_rate`, `filters_applied`, and `meta.supplier_id`, `meta.supplier_name`, `meta.processing_time`. In each offer: `offer_id` (next to `id`), `support_hold`, `support_void`, `void_deadline`, `pricing.base_price`, `pricing.taxes_and_fees`, `pricing.agency_commission`, `pricing.final_price`, `pricing.b2b_price`, and `passenger_id` and `passenger_type` in each `travelerPricing` entry. Add `?naming=snake` to get every key in snake_case. See [Conventions and naming](https://fx-port.com/docs/conventions.md). ### Top-level response fields | Field | Type | Meaning | |---|---|---| | `environment` | string | `sandbox` or `live`, from your key. | | `success` | boolean | `true` on every `200`, even if some suppliers failed: read `suppliers[n].success` and `failures`. | | `requestId` | string | The id of this search, for support. | | `searchType` | string | `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW` or `MULTI_CITY`, derived from your route. | | `flexible` | boolean | Whether the date matrix was requested. | | `flexibleOnly` | boolean | Only present (and `true`) for [flexible search](https://fx-port.com/docs/flights/flexible-search.md). | | `processingTime` | number | Seconds spent by the whole search. | | `suppliers` | array | One entry per supplier that answered; see below. | | `summary` | object | `totalSuppliers` and `successfulSuppliers` (both count the suppliers that **answered**), `failedSuppliers` (the ones listed in `failures`) and `totalOffers`. | | `failures` | array | Only when a supplier failed: `{ supplierId, error, cause }` (twin: `supplier_id`). `cause` is a stable code: `supplier_timeout`, `supplier_api_error`, `supplier_auth_error`, `supplier_error`, `supplier_temporarily_unavailable` (the supplier is skipped for a short while), `supplier_busy` (FX-Port's capacity towards that supplier is full for a moment: the supplier was not called, retry in a few seconds) or `service_overloaded` (FX-Port was too busy to prepare that supplier's results: retry in a few seconds). `error` is a fixed sentence about the cause, never the supplier's own text. | | `warnings` | array | Only when the request used a legacy name, an ignored field or a value that was adjusted. See [Conventions and naming](https://fx-port.com/docs/conventions.md). | ### `suppliers[n]` fields | Field | Type | Meaning | |---|---|---| | `success` | boolean | Whether this supplier returned usable results. | | `searchId` | string | **Save this.** `fs_` (Amadeus Algeria) or `fs_dfx_` (Duffel). Required as `search_id` by [price_flight](https://fx-port.com/docs/flights/price.md) and [book_flight](https://fx-port.com/docs/flights/book.md). It is valid for 24 hours, but a supplier withdraws offers earlier: price soon after you show an offer. | | `supportHold`, `supportVoid` | boolean | What the supplier can do in general; re-read them on the priced offer before you choose between hold and issue. | | `voidDeadline` | string or null | ISO 8601 UTC: the end of the same-day void window (midnight in the supplier's time zone, `Africa/Algiers` for Amadeus Algeria). `null` when the supplier cannot void. | | `filtersApplied` | object | The filters you asked for, echoed (see above). | | `warnings` | array | Notes of the supplier; empty in every sample. | | `meta` | object | `supplierId`, `supplierName`, `technologyType` (`amadeus_aqc`, `duffel`), `processingTime`, `supplierCurrency`, `agencyCurrency`. | | `results` | object | `totalOffers`, `supplierCurrency`, `agencyCurrency`, `offers`. | | `exchangeRate` | number | Only when the supplier's currency is not your agency's (Duffel, USD to DZD: 250.0). | | `flexPrices` | object | Only with `flexible: true` from a supplier that supports it. | ### `offers[n]` fields | Field | Type | Meaning | |---|---|---| | `id` | string | **Save this.** `fx_offer_` and 8 hex characters. Required as `offer_id` by price and book. | | `type` | string | Always `flight_offer`. | | `supplier` | object | `id`, `name` and `originalId` (the supplier's own id of the offer: for support only). | | `bookableSeats` | integer or null | Seats left at this fare, when the supplier says (Amadeus: up to 9). | | `supportHold`, `supportVoid`, `voidDeadline` | | As on the supplier, for this offer. | | `instantTicketingRequired` | boolean | `true`: the fare cannot be held; book it with `booking_type: "issue"`. | | `lastTicketingDate`, `lastTicketingDateTime` | string | Duffel only: when the offer stops being sellable. Typically 15 to 60 minutes after the search. | | `pricing` | object | Every field is explained in [Flight pricing](https://fx-port.com/docs/flights/pricing.md). Amadeus Algeria adds `acd`. | | `itinerary` | array | One entry per direction; see below. | | `travelerPricing` | array | One entry per priced traveler; see below. | | `conditions` | object | `refundBeforeDeparture.allowed` and `changeBeforeDeparture.allowed`: indicative, not a promise. | | `fareRules` | object | `exchange`, `refund`, `revalidation` (each `{ allowed }`) and the two currencies. | | `validatingAirline` | array | `{ code, name }` of the airline whose rules and ticket stock govern the fare. | | `additionalServices` | array | Amadeus only: paid extras the supplier lists. | | `requiresACD` | boolean | Amadeus Algeria only, present only when `true`: after booking, passports must be uploaded. See [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). | | `filtersApplied`, `warnings` | | As on the supplier. | ### `itinerary[n]` and `segments[n]` fields | Field | Type | Meaning | |---|---|---| | `direction` | string | `outbound`, `return`, and `segment_3`, `segment_4` ... for the third and later legs of a multi-city search. | | `duration` | string | ISO 8601 duration of the direction, for example `PT2H24M`. | | `stops` | integer | Number of stops (`0` is non-stop). | | `segments[n].id` | string | Id of the segment; `fareDetailsBySegment[].segmentId` points to it. | | `segments[n].departure`, `.arrival` | object | `airportCode`, `airportName`, `cityName`, `countryCode`, `terminal` (can be null), `datetime` (local time, no zone), `timeZone` (IANA). | | `segments[n].flight` | object | `carrierCode`, `carrierName`, `carrierLogoUrl`, `flightNumber` (without the carrier code: `7426`), `aircraftCode`, `aircraftName` (can be null), `operatingCarrier`, `operatingCarrierName`. Different carriers mean a codeshare. | | `segments[n].duration` | string | ISO 8601 duration of the flight. | | `segments[n].cabin` | string | `ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS` or `FIRST` for this segment; it can differ from the cabin you asked for. | | `segments[n].bookingClass` | string or null | Booking class letter (Amadeus: `Q`, `O`; Duffel: null). | | `segments[n].fareBasis`, `.brandedFare`, `.brandedFareLabel` | string | Fare basis code, fare family code and its display label. | | `conditions` | object | Refund and change rules of this direction. | ### `travelerPricing[n]` fields | Field | Type | Meaning | |---|---|---| | `travelerId` | string | `"1"`, `"2"` ... (Amadeus) or `pas_...` (Duffel). | | `travelerType` | string | `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). | | `fareOption` | string | The fare option, for example `STANDARD`. | | `price` | object | The traveler's share: `supplierCurrency`, `agencyCurrency`, `base`, `total`, `b2bPrice`. | | `fareDetailsBySegment[n]` | array | `segmentId`, `cabin`, `fareBasis`, `brandedFare`, `brandedFareLabel`, `bookingClass`, `includedCheckedBags.quantity`, `includedCabinBags.quantity` and `amenities[]` (`description`, `isChargeable`, `amenityType`, `amenityProvider`). | The entries are grouped **by type** (adults, then seniors, children ...), not in the order you would list the travelers: a request has no passenger order. Each traveler's `price.total` adds up to the offer's `pricing.finalPrice`. ## Save these values | Value | Where | Used by | |---|---|---| | `searchId` | `suppliers[n].searchId` | `price_flight.search_id`, then `book_flight.search_id` | | offer `id` | `suppliers[n].results.offers[m].id` | `price_flight.offer_id`, then `book_flight.offer_id` | | `supportHold` | supplier and priced offer | Choose hold only when it is still `true` after pricing | | `requiresACD` | offer | Plan the [ACD passport upload](https://fx-port.com/docs/flights/acd-documents.md) | ## Streaming Add `?stream=true` (or send `Accept: text/event-stream`) to receive the same search as server-sent events: a first event after about half a second, the first offers after about two seconds, and the rest as each supplier answers. See [Streaming search results](https://fx-port.com/docs/streaming.md) for the events, the merge rules, the client code in nine languages and the Cloudflare notes. Keep at most 4 streams open at the same time for one API key. ## Errors The error body is described in [Errors](https://fx-port.com/docs/errors.md). Branch on `errors[0].code`. | Status | Code | When | What to do | |---|---|---|---| | `400` | `invalid_json`, `invalid_body` | The body is not JSON, or not a JSON object, or is nested more than 24 levels deep, or holds a text longer than 256 characters. | Send one JSON object with `Content-Type: application/json`. | | `400` | `missing_search_params` | Neither a `url`, nor `segments`, nor the three fields `origin`, `destination` and `departure_date`. The body has `example_params` and `example_url`. | Send a route. | | `400` | `missing_field` | A segment lacks `origin`, `destination` or `departure_date`. `errors[0].field` is the path, for example `segments[1].departure_date`. | Send the field it names. | | `400` | `invalid_airport_code` | An IATA code that is not three ASCII letters (up to five codes in `"ALG,ORN"`), or an origin equal to the destination. | Use three letters; `PAR` and `LON` city codes are valid. | | `400` | `invalid_date`, `date_in_past`, `date_too_far`, `return_before_departure` | A date that is not a real `YYYY-MM-DD`, is earlier than yesterday, is more than 365 days ahead, or a return (or a later segment) before the departure. | Fix the date. | | `400` | `invalid_cabin_class` | `cabin_class` is not one of the five values, once case, spaces, dashes and underscores are ignored. `errors[0].details.allowed` lists them. | Send one of the five values. | | `400` | `invalid_passenger_count`, `invalid_passenger_age` | A count that is not a whole number from 0 to 99, or an age that is not a whole number from 0 to 17. Numeric text and integral floats (`"2"`, `2.0`) are **read** with a `coerced_value` warning; words, fractions, booleans, negatives and bigger numbers are refused. | Fix the number. | | `400` | `traveler_rule_violation` | The mix breaks a rule: more than 9 seated passengers, more lap infants than adults and seniors, or more seated infants under 2 than adults and seniors. This answer keeps its older flat body: `error: "TRAVELER_RULE_VIOLATION"` and `violations[]` with one entry per broken rule. | See [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md#capacity-constraints). | | `400` | `invalid_supplier_selection` | An unknown or disabled supplier, or a supplier that cannot do flexible search. `availableSuppliers` lists the usable ids. | Remove the selector or use an available id. | | `400` | `supplier_passenger_combination_unsupported` | No selected supplier can serve the passenger mix (for example one adult with a seated and a lap infant, with only Duffel available). | Change the mix, or select another supplier. | | `400` | `invalid_airline_filter`, `invalid_field_type`, `invalid_segments`, `invalid_country_code`, `invalid_currency`, `invalid_url` | A filter or a field has the wrong type or shape; `errors[0].field` names it. | Fix the field. | | `403` | `acd_exemption_invalid`, `acd_exemption_currency` | `acd_exempt_company_id` is not usable by this agency. | Remove it, or use a company linked to your agency. | | `413` | `request_too_large` | The body is larger than 64 KB, or holds more than 5,000 values. | Send a smaller body. | | `422` | `no_supplier_supports_flexible_search` | `flexible_only` (or `POST /flights/flexible_search`) on a route that no flexible-capable supplier serves. | Use `get_flights` for fixed dates. | | `503` | `all_suppliers_failed` | No supplier returned results. `supplierErrors[]` lists `{ supplierId, cause, message }` with `cause` one of `supplier_timeout`, `supplier_api_error`, `supplier_auth_error`, `supplier_error`, `supplier_temporarily_unavailable`, `supplier_busy`, `service_overloaded` and a fixed sentence: the supplier's own text is never returned. | A well-formed airport code that does not exist, a date the supplier has no schedule for, or a supplier outage all end here. Wait, then retry with backoff, or search another route. | | `503` | `supplier_busy` | **Every** supplier refused the search because FX-Port's capacity towards it is full for a moment. The suppliers were not called, nothing was booked and nothing was charged. The answer carries `Retry-After` (seconds). | Wait for `Retry-After`, then send the same request again: it is safe. | | `503` | `service_overloaded` | FX-Port was busy preparing other large search answers and no worker became free within a few seconds, so this search could not be completed. Nothing was booked and nothing was charged. The answer carries `Retry-After` (seconds). | Retry with backoff: wait for `Retry-After`, then send the same request again. | Invalid input is refused **before any supplier is called**, so it never costs a supplier request and never counts against a supplier. A supplier that keeps failing is skipped for a short while and listed in `failures[]` with the cause `supplier_temporarily_unavailable`; the others still answer. A supplier whose capacity is full for a moment is listed the same way with the cause `supplier_busy` while the others answer; only when every supplier is busy is the whole search refused with `503 supplier_busy` and `Retry-After`. `service_overloaded` works like `supplier_busy` when FX-Port itself was too busy to prepare a supplier's results (one supplier: `failures[]`; all of them: `503`). Neither makes FX-Port skip the supplier for later searches. > [!WARNING] > **Multi-city searches** (three or more segments) are not supported by Amadeus Algeria: it fails and is listed in `failures`, while Duffel answers. Add `"supplier_id": "duffel_1"` to a multi-city search that touches Algeria to avoid the failing call. ## Test it in the sandbox | You want | Send | |---|---| | Both suppliers | `ALG` to `CDG`, one adult, a date 30 days ahead | | A fare you can hold | A route that touches Algeria; read `supportHold` on the offer and the supplier `amadeus_aqc_dz_1` | | An instant-only fare | `"supplier_id": "duffel_1"` | | An offer that needs ACD passports | `CDG` to `ALG` with `"supplier_id": "amadeus_aqc_dz_1"`: every offer has `pricing.acd.applied: true` | | Round trip, open-jaw, multi-city | The examples of [Basic routes](https://fx-port.com/docs/flights/search/basic-routes.md) and [Open-jaw and multi-city](https://fx-port.com/docs/flights/search/complex-itineraries.md) | | An error | `"cabin_class": "luxury"` returns `400 invalid_cabin_class` before any supplier is called | | A value that is read, with a warning | `"passengers": { "adults": "2" }` is searched as 2 adults and the answer carries a `coerced_value` warning; `"adults": "two"` is `400 invalid_passenger_count` | ## Related - [Price a flight offer](https://fx-port.com/docs/flights/price.md): the required next step. - [Streaming search results](https://fx-port.com/docs/streaming.md) and [Flexible-date search](https://fx-port.com/docs/flights/flexible-search.md). - [API reference: Search flights](https://fx-port.com/docs/api/get-flights.md) and [the Flight offer object](https://fx-port.com/docs/api/objects/flight-offer.md). --- # Basic route examples > One-way, round-trip, direct-only and all-supplier flight searches, with real request and response examples. Source: https://fx-port.com/docs/flights/search/basic-routes Four common searches, each with its request and, where it teaches something, the real response. The full field reference of the response is in [Search flights](https://fx-port.com/docs/flights/search.md#response-essentials). All examples were run on the sandbox on 2026-11-01 and trimmed for length, not invented. Dates are examples: use a date from yesterday up to 365 days ahead. ## One-way economy **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "ORY", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` A one-way search returns `searchType: "ONE_WAY"`, and each offer has exactly one `itinerary` entry with `direction: "outbound"`. Airports in the same city are separate codes: `ALG` to `ORY` and `ALG` to `CDG` are different searches. A **city code** searches all the airports of the city: `ALG` to `PAR` returned offers to both `CDG` and `ORY` from both sandbox suppliers. ## Round-trip economy Add `return_date`. The two dates must be in order (`return_before_departure` otherwise). **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "return_date": "2026-12-22", "cabin_class": "economy", "passengers": { "adults": 2 } }' ``` ### Response example The search returns `searchType: "ROUND_TRIP"`. Each offer has **two** `itinerary` entries (`outbound` and `return`), and `travelerPricing` has one entry per priced traveler: here two adults. This is one non-stop Duffel offer; the segments are shortened to the airports, times and flight (`departure`, `arrival` and `flight` are objects with more keys: see [the full offer](https://fx-port.com/docs/flights/search.md#response-essentials)): ~~~json { "id": "fx_offer_cbeb029c", "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 52652.5, "taxesAndFees": 16252.7, "agencyCommission": 68.92, "finalPrice": 68974.12, "b2bPrice": 68905.2, "additionalServices": [] }, "itinerary": [ { "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": [ { "departure": { "airportCode": "ALG", "datetime": "2026-12-15T10:50:00" }, "arrival": { "airportCode": "CDG", "datetime": "2026-12-15T13:14:00" }, "flight": { "carrierCode": "ZZ", "flightNumber": "7426" }, "cabin": "ECONOMY" } ] }, { "direction": "return", "duration": "PT2H24M", "stops": 0, "segments": [ { "departure": { "airportCode": "CDG", "datetime": "2026-12-22T16:15:00" }, "arrival": { "airportCode": "ALG", "datetime": "2026-12-22T18:39:00" }, "flight": { "carrierCode": "ZZ", "flightNumber": "7426" }, "cabin": "ECONOMY" } ] } ], "travelerPricing": [ { "travelerId": "pas_0000BC0c76NFZS74iWWDfD", "travelerType": "ADULT", "price": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "base": 26326.25, "total": 34487.06, "b2bPrice": 34452.6 } }, { "travelerId": "pas_0000BC0c76NbY8OejcgVCi", "travelerType": "ADULT", "price": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "base": 26326.25, "total": 34487.06, "b2bPrice": 34452.6 } } ] } ~~~ Each traveler's `price.total` adds up to the offer's `pricing.finalPrice` (34,487.06 × 2 = 68,974.12). The same route and dates for one adult returned 736 offers from two suppliers (265 from Amadeus Algeria, 471 from Duffel); with two adults on Duffel alone, 441. A round trip is **one booking and one fare**: the offer prices both directions together. To combine two one-way fares, search twice and book twice. ## Non-stop only `nonstop: true` becomes a zero-stop filter at the supplier. Both sandbox suppliers honour it. (`direct` is the legacy name of the same field.) **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "ORY", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 }, "nonstop": true }' ``` Every returned offer has `itinerary[n].stops: 0` in each direction, and `results.totalOffers` counts only non-stop offers: smaller than the same search without the filter. There is no way to ask for `stops: 1` or `stops: 2` with the fields above; filter `itinerary[n].stops` on your side. ## Every compatible supplier Omit every supplier selector. FX-Port selects the suppliers that can serve the route and the passenger mix, calls them in parallel and returns each answer under its own `searchId`. New suppliers join automatically when FX-Port enables them. **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "return_date": "2026-12-22", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` `suppliers[]` has one entry per supplier that answered: read `suppliers[n].success` and `suppliers[n].meta.supplierId` to see who returned offers for this route, dates and passengers, and `failures[]` for the ones that did not. On the sandbox this route calls `amadeus_aqc_dz_1` (only because the route touches Algeria) and `duffel_1`; `LHR` to `CDG` calls Duffel alone. See [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md). ## Related - [Search flights](https://fx-port.com/docs/flights/search.md): the fields and the response. - [Passengers and cabins](https://fx-port.com/docs/flights/search/passenger-and-cabin.md), [Filters](https://fx-port.com/docs/flights/search/filters.md) and [Open-jaw and multi-city](https://fx-port.com/docs/flights/search/complex-itineraries.md). - [Streaming search results](https://fx-port.com/docs/streaming.md): the same search, supplier by supplier. --- # Passenger and cabin examples > Family groups, lap and seated infants, adult-senior-young mixes and cabin-class searches, with real request and response examples. Source: https://fx-port.com/docs/flights/search/passenger-and-cabin Search examples for every kind of traveler and cabin. The rules are in [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md); the response fields are in [Search flights](https://fx-port.com/docs/flights/search.md#response-essentials). Every response on this page was captured from the sandbox on 2026-11-01 and trimmed. The counts in a search must match the passenger objects you send later to `book_flight`. ## Family: adults, child, lap infant **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 2, "children": 1, "child_ages": [ 8 ], "infants": 1, "infant_ages": [ 1 ] }, "supplier_id": "duffel_1" }' ``` ### Response example `travelerPricing` has one entry per traveler, **grouped by type** (adults, then children, then infants), not in the order you would list the family. The type of a lap infant is `HELD_INFANT`: ~~~json { "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "finalPrice": 64971.32 }, "travelerPricing": [ { "travelerType": "ADULT", "price": { "base": 12994.26, "total": 16242.83 } }, { "travelerType": "ADULT", "price": { "base": 12994.26, "total": 16242.83 } }, { "travelerType": "CHILD", "price": { "base": 12994.26, "total": 16242.83 } }, { "travelerType": "HELD_INFANT", "price": { "base": 12994.26, "total": 16242.83 } } ] } ~~~ > [!NOTE] > The Duffel sandbox prices every traveler the same; real fares price children and infants lower, and so does Amadeus Algeria (below, in the six-traveler search, an adult was 29,856.00 DZD, a child 23,495.24 and a lap infant 4,205.82). Read each traveler's own `price.total` and `price.b2bPrice`; never assume a ratio to the adult fare. ## Complete mix with a seated infant Two adults, two children, one lap infant and one seated infant. The payload is the same for every supplier; a supplier that cannot serve the mix is skipped. **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 2, "seniors": 0, "young_adults": 0, "children": 2, "child_ages": [ 10, 6 ], "infants": 1, "infant_ages": [ 1 ], "seated_infants": 1, "seated_infant_ages": [ 1 ] }, "supplier_ids": [ "amadeus_aqc_dz_1", "duffel_1" ] }' ``` Both suppliers answered with **six** `travelerPricing` entries: `ADULT` ×2, `CHILD` ×2, `SEATED_INFANT` and `HELD_INFANT`. Amadeus Algeria returned 59 offers and priced the entries 29,856.00 (adult), 23,495.24 (child), 23,495.24 (seated infant) and 4,205.82 (lap infant) in DZD. ## One adult with two infants Some suppliers need one adult or senior for **each** passenger under two years old, seated and lap infants together. An adult with one seated and one lap infant is valid at the FX-Port level: **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-29", "cabin_class": "economy", "passengers": { "adults": 1, "seated_infants": 1, "seated_infant_ages": [ 0 ], "infants": 1, "infant_ages": [ 0 ] } }' ``` Do not force a supplier. FX-Port skips the suppliers that cannot serve the mix and returns the others: here only Amadeus Algeria answered (three `travelerPricing` entries: `ADULT`, `SEATED_INFANT`, `HELD_INFANT`) and Duffel was left out without a warning. When **no** supplier is left (the same search on `LHR` to `CDG`, where only Duffel can fly), the answer is `400 supplier_passenger_combination_unsupported`: ~~~json { "environment": "sandbox", "success": false, "requestId": "3ead8580", "error": "SUPPLIER_PASSENGER_COMBINATION_UNSUPPORTED", "message": "No available flight provider supports this passenger combination. Some providers require one adult or senior for each passenger under age 2.", "limitation": { "scope": "supplier_specific", "code": "UNDER_TWO_HOST_RATIO", "underTwoPassengers": 2, "adultSeniorHosts": 1, "description": "Some providers require one adult or senior for each passenger under age 2. Children aged 2-11 are not subject to this limit." } } ~~~ A traveler who is already two years old belongs in `children`, not in `seated_infants`: children aged 2 to 11 do not use an infant-host slot. ## Adult, senior and young adult **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1, "seniors": 1, "young_adults": 1, "young_adult_ages": [ 16 ] }, "supplier_id": "duffel_1" }' ``` The `travelerPricing` entries are `ADULT`, `SENIOR` and `YOUNG`. A supplier can price a senior or a young adult as an adult internally; FX-Port keeps the type you asked for in the response. The type names in the response are `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` and `HELD_INFANT`; the `type` you send when booking is lower case (`adult`, `senior`, `young_adult`, `child`, `seated_infant`, `infant`). ## The ages decide the traveler The ages are what a supplier prices, so they win over the counts. The API does **not** reject a mismatch: it changes the search the way the table says and, in the new release, tells you in a `coerced_value` warning. Check `travelerPricing` on the first offer. | You send | You get (observed) | |---|---| | `"children": 2, "child_ages": [8]` | One child. The count is cut to the number of ages. | | `"children": 1, "child_ages": [1]` | **No child**: an age under 2 is not a child. One adult is priced. | | `"children": 1, "child_ages": [15]` | A `YOUNG`, not a `CHILD`. | | `"infants": 1, "infant_ages": [3]` | A `CHILD`, not an infant. | | `"children": 2` with no ages | Two children aged 11, with a `coerced_value` warning. | | `"adults": 2, "infants": 1` with no ages | One lap infant aged 0, **no warning**: age 0 is the default that is almost always right. | | `"adults": "2"` or `2.0` (text or a float instead of an integer) | Read as 2, with a `coerced_value` warning that asks for a JSON integer. Words, fractions, negatives and numbers above 99 stay `400 invalid_passenger_count`. | | `"child_ages": "7,9"` or `"child_ages": 7` | Read as `[7, 9]` and `[7]`, with a warning. A fractional age (`0.5`) counts as the years completed (0). | Send the **real age at departure** of each traveler, and compare `travelerPricing` with the travelers you meant before you show a price. ## Cabin classes `cabin_class` is `economy` (default), `premium_economy`, `business`, `first` or `any`. Case, spaces, dashes and underscores do not matter (`Premium Economy` and `premium-economy` are `premium_economy`); `premium` and `business_class`-style names are read with a `coerced_value` warning. A value that is not one of these (`coach`, `Y`) is rejected with `400 invalid_cabin_class`. See [Search flights](https://fx-port.com/docs/flights/search.md#passengers-and-cabin). **Business** **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "DXB", "destination": "BOM", "departure_date": "2026-12-15", "cabin_class": "business", "passengers": { "adults": 1 }, "supplier_id": "duffel_1" }' ``` The sandbox returned 39 offers; the segments have `cabin: "BUSINESS"`. **Premium economy** **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "DXB", "destination": "BOM", "departure_date": "2026-12-15", "cabin_class": "premium_economy", "passengers": { "adults": 1 }, "supplier_id": "duffel_1" }' ``` 16 offers; `cabin: "PREMIUM_ECONOMY"`. **First** **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "DXB", "destination": "BOM", "departure_date": "2026-12-15", "cabin_class": "first", "passengers": { "adults": 1 }, "supplier_id": "duffel_1" }' ``` One offer on this route; `cabin: "FIRST"`. **Any cabin** **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "DXB", "destination": "BOM", "departure_date": "2026-12-15", "cabin_class": "any", "passengers": { "adults": 1 }, "supplier_id": "duffel_1" }' ``` `any` sends no cabin restriction: 102 offers here, in different cabins. Read `cabin` on each segment. ## Related - [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md): types, ages and the capacity rules. - [Search flights](https://fx-port.com/docs/flights/search.md) and [Basic routes](https://fx-port.com/docs/flights/search/basic-routes.md). --- # Filter examples > Direct flights, refundable fares, checked bags, airline include and exclude, and flexible dates. Source: https://fx-port.com/docs/flights/search/filters All filters are optional and can be combined. Read the first section before you rely on one: **a filter is a request to the supplier, and the suppliers do not all honour it.** The numbers on this page are a measured search (`ALG` to `CDG`, one adult, 2026-12-15, sandbox, 2026-11-01); the offers of a given day differ, the behaviour does not. ## Filter reference | Field | Type | What it does | |---|---|---| | `nonstop` | boolean | Non-stop offers only. Legacy name: `direct`. | | `refundable` | boolean | Asks for refundable fares. | | `checked_bags` | boolean | Asks for fares that include a checked bag. | | `max_price` | number | A price ceiling. `0`, an empty text or `null` means no ceiling. | | `included_airlines` | string[] or text | Only these carriers (IATA codes, up to 20; as a text, separated by commas, semicolons, bars or spaces). | | `excluded_airlines` | string[] or text | Remove these carriers. | | `preferred_airlines` | string[] or text | Exactly like `included_airlines`. | | `flexible` | boolean | Add the date matrix from capable suppliers. | ## What is enforced `suppliers[n].filtersApplied` and `offers[n].filtersApplied` echo **the filters you asked for**, for every supplier. They do not prove that the supplier applied them. | Filter | Amadeus Algeria | Duffel | Measured | |---|---|---|---| | `nonstop` | Enforced | Enforced | 70 to 12 offers and 92 to 31; every offer has `stops: 0` | | `checked_bags` | Enforced | **Ignored** | Duffel: 92 offers, 24 of them with **no** checked bag | | `refundable` | Hint: the result is **mixed** | **Ignored** | Amadeus: 54 offers, 44 refundable and 10 not. Duffel: 92 offers, 14 refundable | | `max_price` | Sent in the supplier's currency, **not enforced in the sandbox** | Never sent | `max_price: 40000`: offers up to 242,844 DZD (Amadeus) and 280,279 DZD (Duffel) were still returned | | `included_airlines` | Native: the validating airline is in the list | Post-filter: an offer stays when **any** carrier of any segment matches | Duffel with `["AH","AF"]`: 48 offers, 9 of them validated by `TK` or `QR` because `AH` operates a leg | | `excluded_airlines` | Native | Post-filter | `["AH"]`: no offer is validated by `AH`; 85 and 68 offers | So: treat `nonstop`, `included_airlines` and `excluded_airlines` as reliable for the **validating airline**, and everything else as a **hint**. When a filter is a hard requirement, check the offers yourself: - baggage: `travelerPricing[n].fareDetailsBySegment[m].includedCheckedBags.quantity`; - refundability: `conditions.refundBeforeDeparture.allowed` (indicative); - price: `pricing.finalPrice` and `pricing.b2bPrice`, in the agency currency; - carriers: `itinerary[n].segments[m].flight.carrierCode` and `operatingCarrier`. You can send `included_airlines` and `excluded_airlines` together: FX-Port takes the excluded codes out of the included list and never forwards both lists to a supplier. > [!NOTE] > `preferred_airlines` is **not** a ranking. It is an alias of `included_airlines` and restricts the results to the listed carriers (`["AF"]` returned only `AF` offers: 6 and 24). Do not send it with different carriers than `included_airlines`. ## Non-stop only **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 }, "nonstop": true }' ``` Both suppliers honour it, and `filtersApplied` says `"maxStops": 0`. There is no filter for exactly one or two stops: read `itinerary[n].stops`. ## Refundable only **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 }, "refundable": true }' ``` > [!WARNING] > `refundable: true` is forwarded to the supplier, not applied by FX-Port. Amadeus Algeria narrowed 70 offers to 54 and **kept 10 non-refundable ones**; Duffel ignored the filter. Read `conditions.refundBeforeDeparture.allowed` on each offer, and tell the traveler the fare is "refundable for a fee": the amounts in `conditions` are indicative. ## Checked baggage **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "return_date": "2026-12-22", "cabin_class": "economy", "passengers": { "adults": 1 }, "checked_bags": true }' ``` Amadeus Algeria applies the filter at the supplier. **Duffel ignores it**: with `checked_bags: true` its response had 92 offers and 24 of them had `travelerPricing[n].fareDetailsBySegment[m].includedCheckedBags.quantity` equal to `0`, and `filtersApplied.checkedBagsOnly` still said `true`. Check the quantity on the offers you show. ## Price ceiling `max_price` is accepted (a positive number; `0`, an empty text and `null` mean no ceiling and the field is ignored, a negative or non-numeric value is `400 invalid_field_type`) and echoed in `filtersApplied.maxPrice`, but **it does not cap the results in the sandbox**. It is passed to Amadeus Algeria in the **supplier's** currency (DZD), not your agency's, and it is never sent to Duffel. Filter on `pricing.finalPrice` (the traveler's price) or `pricing.b2bPrice` (your debit) on your side. ## Include airlines (allow-list) **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 }, "included_airlines": [ "AF", "AH" ] }' ``` Amadeus Algeria restricts the search to those carriers: every offer has `validatingAirline[0].code` `AF` or `AH`. Duffel has no carrier filter: FX-Port keeps the offers where **any** marketing, operating or validating carrier of any segment matches, so an offer validated by `TK` or `QR` can come back because `AH` flies one leg of it. The lists also accept a comma separated text (`"AF,AH"`) and up to 20 codes. ## Exclude airlines (block-list) **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 }, "excluded_airlines": [ "IB" ] }' ``` None of the listed carriers is the validating airline of a returned offer. (Amadeus Algeria also never returns `BA`, whatever you send.) ## Flexible dates across all suppliers **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "return_date": "2026-12-22", "cabin_class": "economy", "passengers": { "adults": 1 }, "flexible": true }' ``` Every compatible supplier still returns its regular offers under `results`. Only suppliers with the flexible-dates capability (Amadeus Algeria) add a `flexPrices` key to their entry; Duffel echoes `filtersApplied.flexible: true` and returns **no matrix**. Check that `flexPrices` is there before you read it. See [Flexible-date search](https://fx-port.com/docs/flights/flexible-search.md), and use `POST /flights/flexible_search` when you want the matrix **only**. ## Country hints `from_cc` and `to_cc` (ISO country codes) and `countries` take part in the choice of suppliers; see [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md). Send them only if you know why. ## Related - [Search flights](https://fx-port.com/docs/flights/search.md#filters): the request fields and the offer. - [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md). --- # Supplier selection > Let FX-Port select suppliers automatically, or pass exact supplier ids for a special case. Source: https://fx-port.com/docs/flights/search/suppliers Most integrations send no supplier field at all. FX-Port keeps the list of enabled suppliers, selects those that can serve the route and the passenger mix, calls them in parallel, and returns each answer under its own `searchId` in `suppliers[]`. This page says how that selection works, what you can force, and what the sandbox offers. ## Suppliers in the sandbox `GET /api/v1/suppliers` (Read) lists the enabled suppliers with their capabilities; see [Suppliers](https://fx-port.com/docs/suppliers.md). The sandbox has two flight suppliers: | `supplier_id` | Name | Routes | Hold | Void | Flexible dates | Notes | |---|---|---|---|---|---|---| | `amadeus_aqc_dz_1` | Amadeus Algeria | Only routes that **touch Algeria** (`routes: ["DZ"]`) | Yes | Yes | Yes | Source of the ACD fares. Never returns carrier `BA`. | | `duffel_1` | Duffel | Worldwide (`routes: ["*"]`) | No | No | No | Instant ticketing only. Offers expire 15 to 60 minutes after the search. | Other suppliers exist in the code and are disabled; they do not appear in the list and cannot be selected. Never hard-code the catalogue: read `GET /suppliers`. > [!WARNING] > The suppliers' own **test systems** can be unstable: an Amadeus request can fail in the sandbox for a reason that does not exist on live. The failed supplier is listed in `failures[]` and the others still answer. See [Sandbox](https://fx-port.com/docs/sandbox.md). ## Automatic selection (recommended) **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` Do not send `supplier_id` or `supplier_ids`. FX-Port handles the priority, the compatibility and the activation of new suppliers without a change on your side. If one supplier fails while another returns offers, you get the offers and the failure in `failures[]`. The rules, verified on the sandbox on 2026-11-01: | Route | Suppliers called | |---|---| | `ALG` to `CDG` (touches Algeria) | `amadeus_aqc_dz_1` and `duffel_1` | | `LHR` to `CDG` | `duffel_1` only: Amadeus Algeria is eligible only when an airport of the route is in Algeria, or `from_cc` or `to_cc` is `DZ`, or `countries` contains `DZ` | | `ALG` to `ORN` (domestic) | both, unless you send `from_cc: "DZ"` and `to_cc: "DZ"`: then Duffel is skipped | | A passenger mix one supplier cannot serve | That supplier is skipped silently; `400 supplier_passenger_combination_unsupported` only when none is left. See [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md) | > [!NOTE] > **Country hints change the result.** For a search **from Algeria to a foreign country** FX-Port limits Duffel to the airlines `TO` and `BA` when the request carries `from_cc` and `to_cc`. `ALG` to `CDG` with `"from_cc": "DZ", "to_cc": "FR"` returned **one** Duffel offer (`filtersApplied.includedAirlines` was `["TO","BA"]`) against 92 without the hints. FX-Port's own website sends the hints; a server integration normally does not. Do not send `from_cc`, `to_cc` or `countries` unless you want this behaviour. ## Exact supplier ids (advanced) Use them for tests or for a commercial rule of yours. A selection is **strict**: it never substitutes another supplier. | Field | Type | Behaviour | |---|---|---| | `supplier_id` | string | One id, or several separated by commas (`"duffel_1,amadeus_aqc_dz_1"`). | | `supplier_ids` | string[] | A set of ids (up to 10). Merged with `supplier_id` without duplicates. | | `preferred_suppliers` | string[] | A **hint**: ids that cannot be used are ignored and the automatic selection applies. `["duffel_1"]` returned Duffel only. | | `max_suppliers` | integer | Keep only the first N selected suppliers, in priority order (Amadeus Algeria before Duffel). `1` returned Amadeus Algeria only. `"1"` and `1.0` are read as 1 with a `coerced_value` warning; an empty text means no limit. | **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 }, "supplier_ids": [ "amadeus_aqc_dz_1", "duffel_1" ] }' ``` Legacy names: `supplierId`, `supplierIds`, `preferredSuppliers`, `maxSuppliers` still work (see [Search flights](https://fx-port.com/docs/flights/search.md#legacy-names)). An unknown or disabled id, or an id that cannot do flexible search on `flexible_search`, is answered with `400 invalid_supplier_selection`: ~~~json { "environment": "sandbox", "detail": { "error": "invalid_supplier_selection", "message": "One or more requested suppliers cannot be used for this flight search.", "invalidSuppliers": ["nope_1"], "disabledSuppliers": [], "unsupportedFlexibleSuppliers": [], "availableSuppliers": ["amadeus_aqc_dz_1", "duffel_1"] } } ~~~ A selection does not widen the supplier's reach: `"supplier_id": "amadeus_aqc_dz_1"` on `LHR` to `CDG` is a valid selection and answers `200` with a supplier entry that has `results.totalOffers: 0` and an empty `offers` list. ## Price and book never take a supplier Only a search chooses suppliers. `price_flight` and `book_flight` find the supplier from the offer they are given (the `searchId` and the offer `id`), so the `supplier_id` you passed to the search is not needed again. The supplier of a booking is `supplier.id` in the response. ## Related - [Suppliers](https://fx-port.com/docs/suppliers.md): the live list and the capabilities. - [Search flights](https://fx-port.com/docs/flights/search.md), [Filters](https://fx-port.com/docs/flights/search/filters.md) and [Sandbox](https://fx-port.com/docs/sandbox.md). --- # Open-jaw and multi-city searches > Search open-jaw trips and multi-city itineraries of three to six flights with the segments array. Source: https://fx-port.com/docs/flights/search/complex-itineraries Send `segments` instead of `origin`, `destination`, `departure_date` and `return_date` when the trip is not a plain one-way or return: an **open-jaw** (you fly back from another airport) or a **multi-city** trip (three or more flights). The search type comes from the segments you send. | Segments | Rule | `searchType` | |---|---|---| | 1 | A single flight | `ONE_WAY` | | 2 | The second is the exact reverse of the first | `ROUND_TRIP` | | 2 | The second is **not** the reverse of the first | `OPEN_JAW` | | 3 to 6 | Any sequence of flights | `MULTI_CITY` | Each segment needs: | Field | Description | |---|---| | `origin` | Departure IATA code (three letters) | | `destination` | Arrival IATA code; must differ from `origin` | | `departure_date` | Local departure date, `YYYY-MM-DD`. The dates must not go backwards: each is on or after the one before. Legacy names: `date`, `departureDate`, `departure` | The same limits as a plain search apply to each date (from yesterday up to 365 days ahead). > [!WARNING] > **Multi-city is supported by Duffel only.** Amadeus Algeria does not search three or more segments: it fails and appears in `failures[]` (`"cause": "supplier_error"`), while Duffel answers. Send `"supplier_id": "duffel_1"` to skip the failing call. Open-jaw and round-trip searches work on both suppliers. ## Open-jaw The outbound arrives at one airport; the return leaves from another. **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "segments": [ { "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15" }, { "origin": "ORY", "destination": "ALG", "departure_date": "2026-12-22" } ], "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` FX-Port recognises an open-jaw because the second segment is not the reverse of the first. Both sandbox suppliers answered (129 offers from Amadeus Algeria and 196 from Duffel in our test). ### Response example `searchType` is `"OPEN_JAW"` and each offer has one `itinerary` entry per direction. The second entry departs from `ORY`, not from `CDG`: that is the open-jaw. The example is the Duffel offer, trimmed to the airports: ~~~json { "success": true, "searchType": "OPEN_JAW", "suppliers": [ { "success": true, "results": { "totalOffers": 196, "offers": [ { "itinerary": [ { "direction": "outbound", "segments": [{ "departure": { "airportCode": "ALG" }, "arrival": { "airportCode": "CDG" } }] }, { "direction": "return", "segments": [{ "departure": { "airportCode": "ORY" }, "arrival": { "airportCode": "ALG" } }] } ] } ] } } ] } ~~~ ## Multi-city Three or more flights, in order: **Request** ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "segments": [ { "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15" }, { "origin": "CDG", "destination": "MAD", "departure_date": "2026-12-18" }, { "origin": "MAD", "destination": "ALG", "departure_date": "2026-12-22" } ], "cabin_class": "economy", "passengers": { "adults": 1 }, "supplier_id": "duffel_1" }' ``` `searchType` is `"MULTI_CITY"`. Duffel returned 568 offers in a 4.6 MB response (80 KB with `Accept-Encoding: gzip`). The legs of an offer are named **`outbound`**, **`return`** and then **`segment_3`**, `segment_4` ...: the second leg of a multi-city offer is called `return` even though it is not a return, so use the **order** of `itinerary`, not the name, to follow the trip: ~~~json { "itinerary": [ { "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": [{ "departure": { "airportCode": "ALG" }, "arrival": { "airportCode": "CDG" } }] }, { "direction": "return", "duration": "PT1H59M", "stops": 0, "segments": [{ "departure": { "airportCode": "CDG" }, "arrival": { "airportCode": "MAD" } }] }, { "direction": "segment_3", "duration": "PT1H32M", "stops": 0, "segments": [{ "departure": { "airportCode": "MAD" }, "arrival": { "airportCode": "ALG" } }] } ] } ~~~ An offer is one fare for the whole trip: price it and book it as a whole. ## Notes - A search with `segments` ignores `origin`, `destination`, `departure_date` and `return_date`. - Each selected supplier receives every segment. - To fly two separate one-way trips at two different fares, make two searches. ## Related - [Search flights](https://fx-port.com/docs/flights/search.md) and [Basic routes](https://fx-port.com/docs/flights/search/basic-routes.md). - [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md). --- # Flexible-date search > A ±3-day price matrix from suppliers that support flexible-date searches, for both one-way and round-trip requests. Endpoint: `POST /api/v1/flights/flexible_search` Source: https://fx-port.com/docs/flights/flexible-search Returns a date matrix with the lowest price for each day, ±3 days around the requested departure (and return) date, **instead of** individual offers. **Permission:** Read. **Rate group:** search. > [!WARNING] > **Flexible-date search is capability-based.** Only suppliers that support date matrices are called: today that is Amadeus Algeria (`amadeus_aqc_dz_1`). Duffel is skipped entirely. Always check that `flexPrices` is present before you read it. The endpoint takes the same body as [`get_flights`](https://fx-port.com/docs/flights/search.md) and runs the search in **matrix-only mode**: there are no offers in the response, only the matrix. To get the regular offers **and** the matrix in one call, send `"flexible": true` to `get_flights` instead. ## Request body Every field of [Search flights](https://fx-port.com/docs/flights/search.md#request-fields) applies, with these rules: - `flexible` and `flexible_only` are set for you. - Only suppliers with the flexible-dates capability are called. `"supplier_id": "duffel_1"` is answered with `400 invalid_supplier_selection` and `unsupportedFlexibleSuppliers: ["duffel_1"]`. - A route that no flexible-capable supplier serves (for example `LHR` to `CDG`, outside Algeria) answers `422 no_supplier_supports_flexible_search` (`errors[0].field` is `flexible_only`). Use [`get_flights`](https://fx-port.com/docs/flights/search.md) for fixed dates on that route. - The matrix follows the route: a one-way search gives 7 dates, a round trip or an open-jaw gives the combinations of two windows. - Identical flexible searches that arrive at the same moment from the same agency, environment and currency are merged into **one** supplier fan-out. Nothing is kept afterwards, and every caller still gets its own `requestId` and headers. ## One-way: 7 dates **Request** ```bash curl https://api.fx-port.com/api/v1/flights/flexible_search \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` For a one-way request `matrix` has **7 entries**, one per day from `departure_date - 3` to `departure_date + 3`, each keyed by `departure` only. Real response (the matrix is shortened to three of the seven entries): ~~~json { "environment": "sandbox", "success": true, "requestId": "9c844468", "searchType": "ONE_WAY", "flexible": true, "processingTime": 4.092, "flexibleOnly": true, "suppliers": [ { "success": true, "searchId": "fs_4f72482f-0d24-4aba-882d-d4eb49f6d034", "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "filtersApplied": { "flexible": true, "flexibleOnly": true }, "warnings": [], "meta": { "supplierId": "amadeus_aqc_dz_1", "technologyType": "amadeus_aqc", "supplierName": "Amadeus Algeria", "processingTime": 4.029, "supplierCurrency": "DZD", "agencyCurrency": "DZD" }, "flexPrices": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "matrix": [ { "departure": "2026-12-12", "status": "available", "finalPrice": 26925.47, "basePrice": 18360.0, "taxesAndFees": 8538.57, "agencyCommission": 26.9, "flight": { "carrierCode": "IB", "flightNumber": "IB1378", "operatingCarrier": "IB", "validatingAirline": "IB", "duration": "PT19H20M", "stops": 1, "departureTime": "14:05", "arrivalTime": "09:25", "departureAirport": "ALG", "arrivalAirport": "CDG", "cabinClass": "ECONOMY", "brandedFare": null, "brandedFareLabel": null, "includedCheckedBags": { "quantity": 1 } }, "b2bPrice": 26898.57, "agencyCurrency": "DZD" }, { "departure": "2026-12-13", "status": "available", "finalPrice": 29638.41, "...": "..." }, { "departure": "2026-12-14", "status": "available", "finalPrice": 29638.41, "...": "..." } ] } } ], "summary": { "totalSuppliers": 1, "successfulSuppliers": 1, "failedSuppliers": 0, "totalOffers": 0 } } ~~~ `summary.totalOffers` is `0` and the supplier entry has no `results`: the answer is the matrix. ## Round trip: up to 49 combinations **Request** ```bash curl https://api.fx-port.com/api/v1/flights/flexible_search \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "return_date": "2026-12-22", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` For a round trip (or an open-jaw) `matrix` covers every combination of the two ±3-day windows: **49 entries** (7 × 7), each keyed by both `departure` and `return`: ~~~json { "flexPrices": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "matrix": [ { "departure": "2026-12-12", "return": "2026-12-19", "status": "available", "finalPrice": 36443.69, "basePrice": 8470.0, "taxesAndFees": 27937.28, "agencyCommission": 36.41, "flight": { "carrierCode": "AH", "flightNumber": "AH1230", "stops": 0, "departureTime": "18:45", "arrivalTime": "21:15", "...": "..." }, "b2bPrice": 36407.28, "agencyCurrency": "DZD" }, { "departure": "2026-12-12", "return": "2026-12-20", "status": "available", "finalPrice": 36443.69, "...": "..." } ] } } ~~~ ## `flexPrices` fields | Field | Type | Meaning | |---|---|---| | `supplierCurrency`, `agencyCurrency` | string | The currencies of the prices in `matrix`. | | `matrix` | array | One entry per date (one-way) or date pair (round trip, open-jaw). | ## `matrix[n]` fields | Field | Type | Meaning | |---|---|---| | `departure` | string | Candidate departure date, `YYYY-MM-DD`. | | `return` | string | Candidate return date. **Only** for a round trip or an open-jaw. | | `status` | string | `available` when the supplier has a fare for this date or pair, `no_flights_available` otherwise. | | `finalPrice` | number or null | What the traveler pays, in `agencyCurrency`. | | `basePrice`, `taxesAndFees`, `agencyCommission`, `b2bPrice` | number or null | The pricing fields of [Flight pricing](https://fx-port.com/docs/flights/pricing.md). | | `agencyCurrency` | string | Currency of the prices of the entry. | | `flight` | object | The cheapest flight behind the price: `carrierCode`, `flightNumber` (**with** the carrier code: `IB1378`, unlike offers), `operatingCarrier`, `validatingAirline` (a **string**, unlike the object list of offers), `duration`, `stops`, `departureTime` and `arrivalTime` (`HH:MM`), `departureAirport`, `arrivalAirport`, `cabinClass`, `brandedFare`, `brandedFareLabel`, `includedCheckedBags.quantity`. | The matrix is a price calendar: **one cheapest fare per date**. Entries with `status: "no_flights_available"` are expected (the price fields are `null`); filter on `available` before you read prices. ## Book a date from the matrix An entry has **no offer id**. To book a date, run a normal [search](https://fx-port.com/docs/flights/search.md) for that exact `departure` (and `return`) date, then [price](https://fx-port.com/docs/flights/price.md) and [book](https://fx-port.com/docs/flights/book.md) as usual. The fare of the matrix is an indication: the search can return a different price. ## Streaming With `"flexible": true` on `get_flights?stream=true` the matrix arrives with the **second, slower batch** of Amadeus Algeria: see [Streaming search results](https://fx-port.com/docs/streaming.md#flexible-dates). ## Errors The errors of [Search flights](https://fx-port.com/docs/flights/search.md#errors) apply. Two are specific to this endpoint: | Status | Code | When | What to do | |---|---|---|---| | `400` | `invalid_supplier_selection` | `supplier_id` names a supplier without the flexible-dates capability (`unsupportedFlexibleSuppliers` lists it). | Remove `supplier_id`, or use `amadeus_aqc_dz_1`. | | `422` | `no_supplier_supports_flexible_search` | No supplier can serve a flexible search for this route (outside Algeria). | Use `get_flights` for fixed dates. | The `503` answers of a search apply too: `all_suppliers_failed`, and `supplier_busy` or `service_overloaded` with `Retry-After` when FX-Port lacked the capacity for a moment. Retry with backoff. ## Related - [Search flights](https://fx-port.com/docs/flights/search.md) and [Filter examples](https://fx-port.com/docs/flights/search/filters.md#flexible-dates-across-all-suppliers). - [API reference: Flexible-date search](https://fx-port.com/docs/api/flexible-search.md). --- # Price a flight offer > Price a cached flight offer to confirm the current fare. Required before booking: book_flight reads the priced offer from this cache. Endpoint: `POST /api/v1/flights/price_flight` Source: https://fx-port.com/docs/flights/price Price an offer from a search to get its current, confirmed fare. This step is **required** before `book_flight`: the booking reads the priced offer, and an offer that was never priced is refused with `404 offer_not_priced`. **Permission:** Read. **Rate group:** default (6,000 requests per minute per key). > [!WARNING] > Use the **supplier-level** `searchId` of the entry the offer is in (`suppliers[n].searchId`), not the top-level `requestId`. A `requestId` is answered with `404 search_not_found`. ## What it does - **Duffel**: fetches the offer again from the supplier, so you get the live price (0.2 to 10 seconds). - **Amadeus Algeria**: re-prices the saved offer through the supplier's pricing service (1 to 2 seconds). - The priced offer, with your agency's commission, is saved for **12 hours** under the **same** offer id. Pricing the same offer again is allowed and returns the current price. - `supportHold`, `supportVoid` and `voidDeadline` are evaluated again: **read them here** before you choose between `hold` and `issue`. An offer that could be held at search time may not be holdable now. ## Request body | Field | Type | Required | Description | |---|---|---|---| | `search_id` | string | Yes | `suppliers[n].searchId` of the search response. | | `offer_id` | string | Yes | `id` of the offer, `fx_offer_` and 8 hex characters. | Both fields are ids of up to 128 letters, digits and `- _ . : = ~`. Nothing else is read. The offer must belong to that search. ### Legacy names `searchId` and `offerId` still work and are answered with a `deprecated_field` warning. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/price_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "search_id": "fs_dfx_a3aeaa36-74ad-424b-b88d-e6bdc9a4ba94", "offer_id": "fx_offer_89d6592c" }' ``` ## Response example A Duffel offer: the supplier prices in USD and the agency settles in DZD, so `exchangeRate` is at the **top level** (the `pricing` block never holds a rate). The itinerary and the traveler pricing are the same objects as in [Search flights](https://fx-port.com/docs/flights/search.md#response-essentials) and are shortened here. ~~~json { "environment": "sandbox", "success": true, "requestId": "76f8fc58", "offerId": "fx_offer_89d6592c", "offerType": "priced", "processingTime": 0.177, "supplierCurrency": "USD", "agencyCurrency": "DZD", "exchangeRate": 250.0, "results": { "totalOffers": 1, "supplierCurrency": "USD", "agencyCurrency": "DZD", "offers": [ { "id": "fx_offer_89d6592c", "type": "flight_offer", "supplier": { "id": "duffel_1", "name": "Duffel", "originalId": "off_034q6JKduVe2mSW1fH8lge" }, "source": "duffel", "bookableSeats": null, "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "instantTicketingRequired": false, "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16975.0, "taxesAndFees": 5366.8, "agencyCommission": 22.35, "finalPrice": 22364.15, "b2bPrice": 22341.8, "additionalServices": [] }, "itinerary": [{ "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": ["..."], "conditions": { "...": "..." } }], "travelerPricing": [{ "travelerId": "pas_0000BC0a5iH5UE0bmBqWkl", "travelerType": "ADULT", "price": { "...": "..." }, "fareDetailsBySegment": ["..."] }], "conditions": { "refundBeforeDeparture": { "allowed": true }, "changeBeforeDeparture": { "allowed": true } }, "fareRules": { "refund": { "allowed": true }, "exchange": { "allowed": true }, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "validatingAirline": [{ "code": "BA", "name": "British Airways" }], "additionalServices": { "baggage": [ { "id": "ase_0000BC0aGG1cCZZ8LCieTb", "type": "checked", "quantity": 1, "maxQuantity": 1, "maxWeightKg": 23, "maxWeightUnit": "kg", "priceAmount": 20.0, "convertedPriceAmount": 5000.0, "priceCurrency": "USD", "applicablePassengers": ["pas_0000BC0a5iH5UE0bmBqWkl"], "applicableSegments": ["seg_0000BC0a5iVGdVLqUATqqr"], "bookableByItinerary": false, "metadata": { "name": "CHECKED", "description": "Additional checked" } } ] } } ] } } ~~~ The offer is the search offer **plus** `source` (`duffel` or `GDS`) and the priced `additionalServices` (extra baggage the traveler can buy: a list of priced services under `baggage`), and **minus** `lastTicketingDate` and `lastTicketingDateTime`. ## Response fields | Field | Type | Meaning | |---|---|---| | `success` | boolean | `true` when the offer was priced. | | `requestId` | string | The id of this call, for support. | | `offerId` | string | Echoes your `offer_id`. | | `offerType` | string | `priced`. | | `supplierCurrency`, `agencyCurrency` | string | The two currencies; see [Flight pricing](https://fx-port.com/docs/flights/pricing.md). | | `exchangeRate` | number | Only when the two currencies differ: the rate used. | | `processingTime` | number | Seconds spent calling the supplier. | | `results.offers[0]` | object | The priced offer. Its `id` is the `offer_id` for `book_flight`. | | `results.offers[0].supportHold`, `.supportVoid`, `.voidDeadline` | | Re-evaluated at pricing time: decide hold or issue from **these**. | | `results.offers[0].pricing` | object | The confirmed price. Compare `finalPrice` with the search: it can differ. | | `results.offers[0].travelerPricing` | array | The confirmed price of each traveler, as in [search](https://fx-port.com/docs/flights/search.md#travelerpricingn-fields). | | `results.offers[0].conditions`, `.fareRules` | object | Refund and change eligibility of this exact fare (indicative). | Next to the keys above the response carries the snake_case twins `request_id`, `offer_id`, `exchange_rate`, `processing_time` and, in the offer, `offer_id`, `support_hold`, `support_void`, `void_deadline`, `pricing.base_price`, `pricing.taxes_and_fees`, `pricing.agency_commission`, `pricing.final_price`, `pricing.b2b_price`, `supplier.supplier_id`, `supplier.supplier_name`, `supplier.supplier_offer_id`. **What the response does not say**: when the offer expires, and the `searchId` (you must keep it). Duffel offers expire after 15 to 60 minutes: in our sandbox an offer was priced without a problem 26 minutes after the search and was gone at 38 minutes. Book soon after you price. ## Errors | Status | Code | When | What to do | |---|---|---|---| | `400` | `bad_request` | `search_id` or `offer_id` is missing: `Missing required parameters: search_id and offer_id are required`. | Send both. | | `400` | `invalid_identifier` | An id is not a valid token (letters, digits and `- _ . : = ~`, up to 128 characters). | Send the id exactly as you received it. | | `400` | `invalid_json`, `invalid_body` | The body is not a JSON object. | Send a JSON object. | | `404` | `search_not_found` | The `search_id` is unknown, older than 24 hours, a `requestId`, or belongs to another agency (the answer is the same). | Search again. | | `404` | `offer_not_found` | The offer is not in that search. | Check that the offer comes from the supplier entry of this `searchId`. | | `410` | `offer_expired` | The supplier says the offer is gone (`supplier_offer_id` is in the body). | Search again. | | `410` | `offer_incomplete`, `supplier_not_found`, `technology_type_unknown` | The saved offer is unusable. | Search again. | | `503` | `supplier_temporarily_unavailable` | The supplier is being skipped after repeated failures (`SUPPLIER_CIRCUIT_OPEN`). | Wait a little, then retry. | | `503` | `supplier_api_error` | The supplier refused or failed. | Retry once; otherwise search again. | | `503` | `supplier_busy` | FX-Port's capacity towards the supplier is full for a moment. The supplier was not called and nothing was priced. The answer carries `Retry-After` (seconds). | Wait for `Retry-After`, then send the same request again: it is safe. | See [Errors](https://fx-port.com/docs/errors.md) for the body of an error and [Search flights](https://fx-port.com/docs/flights/search.md) for the 24-hour life of a `searchId`. ## Next [Book the flight](https://fx-port.com/docs/flights/book.md) with the same `search_id` and the `id` of the priced offer. For what each price field means, see [Flight pricing](https://fx-port.com/docs/flights/pricing.md). --- # Book a flight > Create a booking, either an instant issue or a hold reservation. Requires Read+Write permission. Endpoint: `POST /api/v1/flights/book_flight` Source: https://fx-port.com/docs/flights/book Create a booking. `booking_type` is `"issue"` (the default) for instant ticketing, or `"hold"` for an unpaid reservation when the offer supports it. **Permission:** Read+Write. **Rate group:** booking (1,200 requests per minute per key). In the sandbox nothing is charged: the response says `balanceDebited: false`. > [!NOTE] > **Price first.** The offer must have been priced with [`price_flight`](https://fx-port.com/docs/flights/price.md) within the last 12 hours, with the same `search_id` and `offer_id`. An offer that was never priced is refused with `404 offer_not_priced`. > [!WARNING] > **Hold depends on the priced offer.** Send `"booking_type": "hold"` only when `supportHold` is `true` **after** `price_flight`. Otherwise the answer is `400 hold_not_supported`. A hold is not ticketed and the airline can release it before the displayed deadline. > [!TIP] > Send `"send_email": false` while you test: otherwise a confirmation e-mail goes to the address you typed. ## Request body | Field | Type | Required | Description | |---|---|---|---| | `search_id` | string | Yes | The `searchId` of the supplier entry the offer came from (the same search that was priced). | | `offer_id` | string | Yes | The `id` of the priced offer. | | `booking_type` | string | No | `issue` (default) or `hold`, in any letter case. Any other value is rejected with `400 invalid_booking_type`. | | `passengers` | array | Yes | One object per traveler, lap infants included: see below. At most 18. | | `contact_details` | object | Yes | `{ "email", "phone_number" }`: **both** are required. Use an international phone number (`+213...`): Duffel validates the format. FX-Port accepts digits with spaces, dots, dashes, slashes and brackets, one leading `+` (also `(+213)`), 5 to 24 digits and 32 characters at most. A dial code alone (`+213`) is `400 invalid_phone_number`. | | `send_email` | boolean | No | `false` suppresses the confirmation e-mail. Default `true`. | | `commission_override` | object | No | Replaces your agency commission for this booking: `{ "type": "percentage" or "fixed", "value": number >= 0 }` (a numeric text such as `"10"` is read as a number with a `coerced_value` warning). `percentage` is a share of `b2bPrice`; `fixed` is `value` times the number of travelers. `pricing.agencyCommission` and `pricing.finalPrice` change, `b2bPrice` does not, in the response **and** in the stored booking. Example: 10 % on a `b2bPrice` of 28679.0 gave a commission of 2867.9 and a `finalPrice` of 31546.9. | | `agent_id` | string | No | Your own id of the agent who books; stored on the booking. | ### Passenger object | Field | Type | Required | Description | |---|---|---|---| | `type` | string | Yes | `adult`, `senior`, `young_adult`, `child`, `seated_infant` or `infant` (a lap infant), in any letter case. More spellings are accepted: see [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md#booking-the-passenger-object). An unknown value is `400 invalid_passenger_type`. | | `gender` | string | Yes | `MALE` or `FEMALE` (`M` and `F` too); `X`, `U` or `UNSPECIFIED` for a passport marked X. | | `first_name` | string | Yes | Given name as in the passport. Spaces and line breaks around it are removed before the checks. | | `last_name` | string | Yes | Family name as in the passport. | | `date_of_birth` | string | Yes | `YYYY-MM-DD`; tomorrow is still accepted (time-zone slack), later is `400 invalid_date`. | | `identity_documents` | array | No | The passport. Send it for every traveler: airlines can require it. | | `id` | string | No | An id of yours for the passenger. | `passengers` must match the passengers of the search and the offer's `travelerPricing` (count and type). FX-Port does not compare them for you: a mismatch is refused by the supplier. Names: any letter of any alphabet is accepted by FX-Port, but airlines need **Latin letters**; see the warning in [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md). ### Identity document object | Field | Type | Required | Description | |---|---|---|---| | `type` | string | No | `passport` (default). | | `number` | string | No | The document number: 1 to 40 letters, digits, spaces and `- . / _`. | | `issuing_country` | string | No | ISO 3166-1 alpha-2 (`DZ`). A website value such as `GB-SCT` or `USPR` (2 to 4 letters, optionally `-` and 1 to 3 letters or digits) is passed on in upper case: see [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md#identity-document-object). | | `expiry_date` | string | No | `YYYY-MM-DD`. | | `nationality` | string | No | Accepted and ignored: suppliers use the issuing country. | ### Legacy names Every field below has a previous spelling that still works and is answered with a `deprecated_field` warning. Send the name in the second column. If a body holds a previous spelling **and** its replacement with different values, the replacement wins and an `ignored_field` warning names the dropped one. | Legacy name | Use instead | |---|---| | `searchId`, `offerId` | `search_id`, `offer_id` | | `bookingType`, `sendEmail`, `commissionOverride`, `agentId` | `booking_type`, `send_email`, `commission_override`, `agent_id` | | `contact`, `contacts`, `contactDetails` | `contact_details` | | `contact_details.phone`, `.phoneNumber`, `.mobile` | `contact_details.phone_number` | | `contact_details.emailAddress` | `contact_details.email` | | `passengers[].firstName`, `.givenName`, `.given_name` | `passengers[].first_name` | | `passengers[].lastName`, `.familyName`, `.family_name`, `.surname` | `passengers[].last_name` | | `passengers[].dateOfBirth`, `.bornOn`, `.born_on`, `.dob` | `passengers[].date_of_birth` | | `passengers[].identityDocuments`, `.documents` | `passengers[].identity_documents` | | `passengers[].passengerType` | `passengers[].type` | | `identity_documents[].issuingCountry`, `.issuing_country_code`, `.issuingCountryCode` | `identity_documents[].issuing_country` | | `identity_documents[].expiryDate`, `.expires_on` | `identity_documents[].expiry_date` | | `identity_documents[].documentNumber`, `.unique_identifier` | `identity_documents[].number` | `phone` and `phoneNumber` at the **top level** of the body are not a spelling of `contact_details`: the contact is an object. ## Request examples **Instant issue** **Request** ```bash curl https://api.fx-port.com/api/v1/flights/book_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "search_id": "fs_dfx_88eb64c9-6c36-47e6-bdaa-9ad3c1377ac4", "offer_id": "fx_offer_b7a95439", "booking_type": "issue", "send_email": false, "passengers": [ { "type": "adult", "gender": "MALE", "first_name": "James", "last_name": "Smith", "date_of_birth": "1988-06-15", "identity_documents": [ { "type": "passport", "number": "A12345678", "issuing_country": "DZ", "expiry_date": "2031-04-18", "nationality": "DZ" } ] } ], "contact_details": { "email": "traveler@example.com", "phone_number": "+15555550123" } }' ``` **Hold** **Request** ```bash curl https://api.fx-port.com/api/v1/flights/book_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "search_id": "fs_85ac5f12-7456-4edf-a2fb-1d51239d278b", "offer_id": "fx_offer_9de60e2a", "booking_type": "hold", "send_email": false, "passengers": [ { "type": "adult", "gender": "FEMALE", "first_name": "Maria", "last_name": "Garcia", "date_of_birth": "1991-03-22", "identity_documents": [ { "type": "passport", "number": "B87654321", "issuing_country": "DZ", "expiry_date": "2030-11-15" } ] } ], "contact_details": { "email": "traveler@example.com", "phone_number": "+15555550123" } }' ``` **2 adults and a child** **Request** ```bash curl https://api.fx-port.com/api/v1/flights/book_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "search_id": "fs_dfx_88eb64c9-6c36-47e6-bdaa-9ad3c1377ac4", "offer_id": "fx_offer_b7a95439", "booking_type": "issue", "send_email": false, "passengers": [ { "type": "adult", "gender": "MALE", "first_name": "Omar", "last_name": "Nguyen", "date_of_birth": "1985-04-10", "identity_documents": [ { "type": "passport", "number": "P11223344", "issuing_country": "DZ", "expiry_date": "2031-04-18" } ] }, { "type": "adult", "gender": "FEMALE", "first_name": "Priya", "last_name": "Nguyen", "date_of_birth": "1987-08-20", "identity_documents": [ { "type": "passport", "number": "P44332211", "issuing_country": "DZ", "expiry_date": "2030-12-01" } ] }, { "type": "child", "gender": "MALE", "first_name": "Leo", "last_name": "Nguyen", "date_of_birth": "2018-05-14", "identity_documents": [ { "type": "passport", "number": "P99887766", "issuing_country": "DZ", "expiry_date": "2029-05-14" } ] } ], "contact_details": { "email": "traveler@example.com", "phone_number": "+15555550123" } }' ``` ## Response example: instant issue A Duffel instant issue: the supplier prices in USD and the agency settles in DZD, so `exchangeRate` is at the top level. The itinerary, the traveler pricing and the owner are shortened here; they have the shape described in [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md). ~~~json { "environment": "sandbox", "success": true, "requestId": "7b166b31", "searchId": "fs_dfx_88eb64c9-6c36-47e6-bdaa-9ad3c1377ac4", "offerId": "fx_offer_b7a95439", "processingTime": 0.408, "exchangeRate": 250.0, "booking": { "reference": "ord_0000BC0UuqWJKkrDO331Xc", "status": "confirmed", "pnr": "BOKT52", "type": "instant", "createdAt": "2026-11-01T14:24:41.453265Z", "cancelledAt": null, "content": "managed", "syncedAt": "2026-11-01T14:24:41Z", "availableActions": ["cancel", "change"], "voidableUntil": null, "cancellation": null }, "supplier": { "id": "duffel_1", "technology": "duffel", "name": "Duffel", "offerId": "off_034q4rP6NMvbRSHMkUPbTE" }, "agency": { "currency": "DZD" }, "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16342.51, "taxesAndFees": 5216.69, "agencyCommission": 21.56, "finalPrice": 21580.76, "b2bPrice": 21559.2, "additionalServices": [] }, "payment": { "status": "paid", "paidAt": "2026-11-01T14:24:41Z", "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "awaitingPayment": false }, "documents": [{ "type": "electronic_ticket", "uniqueIdentifier": "1", "passengerIds": ["pas_0000BC0UogJGYlTqCNHhye"] }], "bookingReferences": [ { "pnr": "BOKT52", "carrier": { "iataCode": "ZZ", "name": "Duffel Airways", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/ZZ.svg", "logoLockupUrl": null } } ], "passengers": [ { "id": "pas_0000BC0UogJGYlTqCNHhye", "type": "ADULT", "title": "mr", "givenName": "Audit", "familyName": "Tester", "gender": "m", "bornOn": "1990-01-15", "email": "traveler@example.com", "phoneNumber": "+15555550123", "infantPassengerId": null, "loyaltyProgrammeAccounts": [], "ticketNumber": "1" } ], "travelerPricing": ["..."], "itinerary": ["..."], "conditions": { "refundBeforeDeparture": { "allowed": false }, "changeBeforeDeparture": { "allowed": true } }, "owner": { "id": "arl_00009VME7D6ivUu8dn35WK", "iataCode": "ZZ", "name": "Duffel Airways", "conditionsOfCarriageUrl": "https://duffelairways.com/dummy-url/conditions-of-carriage", "...": "..." }, "services": [], "meta": { "supportHold": false, "supportVoid": false, "voidDeadline": null, "voidWindowEndsAt": null, "airlineInitiatedChanges": [], "changes": [], "users": [], "orderMetadata": null }, "postProcessing": { "bookingDbId": "8f1e6b52-3c1d-4d62-9d8a-0c6a1f6e5a11", "bucketLink": null, "emailSent": false }, "balanceDebited": false, "transactionCreated": false, "payoutCreated": false } ~~~ ## Response example: hold The same request with `"booking_type": "hold"` on an Amadeus Algeria offer. The agency and the supplier price in DZD, so there is no `exchangeRate`. The order is unpaid: `booking.status` is `pending` and `payment.awaitingPayment` is `true`. Real response of 2026-11-01, trimmed, with the agency id and the contact data replaced by fake values (the shortened parts are marked): ~~~json { "environment": "sandbox", "success": true, "requestId": "66e4b1a8", "searchId": "fs_85ac5f12-7456-4edf-a2fb-1d51239d278b", "offerId": "fx_offer_9de60e2a", "processingTime": 2.624, "booking": { "reference": "eJzTd9ePNA9wdA0FAAs8AmE", "status": "pending", "pnr": "Y7PAEU", "type": "hold", "createdAt": "2026-11-01T15:26:00.000", "cancelledAt": null, "content": "managed", "syncedAt": "2026-11-01T15:26:00.000", "availableActions": ["confirm", "cancel"], "voidableUntil": null, "cancellation": null }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus AQC", "offerId": "3" }, "agency": { "currency": "DZD" }, "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 3160.0, "taxesAndFees": 1765.44, "agencyCommission": 4.93, "finalPrice": 4930.37, "b2bPrice": 4925.44, "additionalServices": [], "acd": { "applied": false } }, "payment": { "status": "awaiting_payment", "paidAt": null, "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "awaitingPayment": true }, "documents": [], "bookingReferences": [{ "pnr": "Y7PAEU", "carrier": { "iataCode": "AH", "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg" } }], "passengers": [ { "id": "1", "type": "ADULT", "title": "mr", "givenName": "JAMES", "familyName": "SMITH", "gender": "male", "bornOn": "1988-06-15", "email": "traveler@example.com", "phoneNumber": "15555550123", "infantPassengerId": null, "loyaltyProgrammeAccounts": [], "ticketNumber": null } ], "travelerPricing": ["..."], "itinerary": ["..."], "conditions": { "refundBeforeDeparture": { "allowed": true }, "changeBeforeDeparture": { "allowed": true } }, "owner": { "id": null, "iataCode": "AH", "name": "Air Algerie", "...": "..." }, "services": [], "meta": { "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "voidWindowEndsAt": null, "airlineInitiatedChanges": [], "changes": [], "users": [], "orderMetadata": null }, "fareRules": { "refund": { "allowed": true }, "exchange": { "allowed": true }, "agencyCurrency": "DZD" }, "postProcessing": { "bookingDbId": "d6a05380-ccab-4a2f-bc13-f65ae9fdad7e", "bucketLink": null, "emailSent": false }, "balanceDebited": false, "transactionCreated": false, "payoutCreated": false } ~~~ Keep `booking.reference` as the `order_id` of the next calls and continue with [Quote a held booking](https://fx-port.com/docs/flights/get-order-quote.md) and [Pay a held booking](https://fx-port.com/docs/flights/pay-order.md), or release the hold with [Cancel a held booking](https://fx-port.com/docs/flights/cancel-order.md). ## Key fields | Field | Description | |---|---| | `booking.reference` | The supplier's **order id**. Use it as `order_id` in the later calls (twin: `booking.order_id`). In `cancel_order` the same key holds the PNR: read `orderId` there. | | `booking.pnr` | The airline record locator. **Not unique across suppliers**: use `reference` as the key. | | `booking.status` | `confirmed` for an instant issue, `pending` for an unpaid hold (later calls say `awaiting_payment`). | | `booking.type` | `instant` or `hold`; `pay_order` turns a `hold` into `instant`. | | `booking.availableActions` | What the supplier lets you do next: `confirm`, `cancel`, `change`. | | `booking.voidableUntil` | The end of the same-day void window, in UTC: midnight in the supplier's time zone of the day the PNR was **paid** (not created). `null` for an unpaid hold or when voiding is not supported. | | `booking.createdAt` | A timestamp whose format depends on the supplier: with `Z` (Duffel) or without a zone (Amadeus). | | `supplier` | `id`, `technology`, `name` and `offerId` (the supplier's own offer id). `name` is `Amadeus AQC` here and `Amadeus Algeria` in a search: use `supplier.id`. | | `payment` | `status` (`paid` or `awaiting_payment`), `paidAt`, `paymentRequiredBy`, `priceGuaranteeExpiresAt`, `awaitingPayment`. | | `documents[n]` | The e-ticket: `uniqueIdentifier` is the ticket number (Amadeus `124-2446977396`), or a short opaque id in the Duffel sandbox (`"1"`). Treat it as opaque. Empty for a hold. | | `passengers[n]` | The travelers as the supplier stored them. Values differ by supplier: `gender` is `m` (Duffel) or `male` (Amadeus), names are upper case on Amadeus. | | `exchangeRate` | At the top level, when `supplierCurrency` and `agencyCurrency` differ; see [Flight pricing](https://fx-port.com/docs/flights/pricing.md). | | `postProcessing.bookingDbId` | The FX-Port id of the booking: the `id` that [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) returns. | | `postProcessing.bucketLink` | The PDF link. `null` right after a booking: read it with `get_booking` or `get_ticket`. | | `requiresACD` | Present and `true` only for an ACD booking: upload passports with [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). | | `balanceDebited`, `transactionCreated`, `payoutCreated` | Sandbox only, always `false`: nothing moved. | Next to these keys the response has the snake_case twins `request_id`, `search_id`, `offer_id`, `processing_time`, `exchange_rate`, `booking.order_id`, `pricing.base_price` ... `pricing.b2b_price`, `meta.support_hold`, `support_void`, `void_deadline`, `supplier.supplier_id`, `supplier_name`, `supplier_offer_id`, and in each passenger `first_name`, `last_name`, `date_of_birth`, `phone_number`, `passenger_id`, `passenger_type`. ## Getting the confirmation PDF The PDF is generated after the booking, usually within 10 to 30 seconds. Poll [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) until `booking.bucket_link` has a value, or call [`get_ticket`](https://fx-port.com/docs/flights/get-ticket.md) until `ticket_ready` is `true`. ## Behaviour to know - **The checks run in this order**: the required fields, the booking lock of the offer, the search, the offer in that search, the priced offer, the ACD exemption, the hold capability, and (on live) the agency status and balance. Only then the supplier is called. - **One booking per offer.** The offer is locked while it is booked and after it succeeded: booking it again is `409 duplicate_booking` (with `pnr`). A booking that fails releases the offer. - **Duffel books one offer per search.** A second Duffel booking from the same `search_id` fails with `409 offer_request_already_booked`: search again. - **A failed booking leaves a trace.** A booking that the supplier refuses is stored with a reference that starts with `FAILED-` and the status `failed`; it shows in your history. Ignore it. ## Errors | Status | Code | When | What to do | |---|---|---|---| | `400` | `bad_request` | `search_id` or `offer_id` is missing: `Missing required parameters: search_id and offer_id are required`. | Send both. | | `400` | `missing_field` | `passengers` or one of `contact_details.email` and `contact_details.phone_number` is missing; or a passenger lacks `gender`, `first_name`, `last_name` or `date_of_birth`. | Send what `errors[0].field` names (for example `passengers[0].first_name`). | | `400` | `invalid_booking_type`, `invalid_passenger_type`, `invalid_gender`, `invalid_name`, `invalid_email`, `invalid_phone_number`, `invalid_date`, `invalid_country_code`, `invalid_commission_override`, `invalid_identifier`, `invalid_field_type` | A value is not accepted. `errors[0].field` names it. An unknown `booking_type` is refused here and never becomes an order. | Fix it. | | `400` | `too_many_passengers` | More than 18 passengers. | Split the booking. | | `400` | `hold_not_supported` | `hold` on an offer that cannot be held. | Book it with `issue`, or choose another offer. | | `402` | `insufficient_balance` | Live only: the balance is lower than `b2bPrice`. `details` has `available`, `required`, `currency`. | Top up, then retry. | | `403` | `read_write_permission_required` | A read-only key. | Use a read+write key. | | `403` | `agency_not_active` | Live only: the agency is not approved. | Contact support. | | `404` | `search_not_found` | The `search_id` is unknown, older than 24 hours, or belongs to another agency (the answer is the same). | Search again. | | `404` | `offer_not_found` | The offer is not in that search. | Check the pair `search_id` and `offer_id`. | | `404` | `offer_not_priced` | The offer was never priced (or the priced offer is older than 12 hours). | Call `price_flight`. | | `409` | `duplicate_booking` | The offer was already booked; `pnr` is the existing booking. | Read that booking. | | `409` | `offer_request_already_booked`, `offer_already_booked`, `order_already_exists` | The supplier says this offer or search was already booked (Duffel allows one booking per search). | Search again, or read the booking before you retry. | | `409` | `booking_in_progress`, `booking_lock_failed` | Another request is booking this offer. | Wait, then read the booking before you retry. | | `409` | `acd_exemption_invalid` | The ACD exemption of the search is not valid any more. | Search again without it. | | `422` | `validation_type`, `born_on_does_not_match` and other supplier codes | The supplier refused a detail of the booking: the body has `error.code`, `error.type` (`validation_error`) and `error.details[]` (with `source.field`). | Fix the detail. | | `503` | The supplier's own code, or `supplier_error` | The supplier itself failed: it answered with a status of 500 or more (`meta.supplierStatusCode`). | Read the booking before you retry: a booking can exist at the airline. | | `503` | `balance_lock_timeout` | Live only, for a booking that is paid when it is made (`issue`): another operation of your agency holds the balance in the same currency for a moment. **Nothing was booked and nothing was charged**, and the offer is released. The message is always the fixed sentence `The agency balance is busy with another operation. Retry in a few seconds.`; `errors[0].details.currency` is the currency of your balance. No `Retry-After` header is sent. | Send the same request again after a few seconds. | | `500` | `post_booking_failed` | The booking was created at the supplier but FX-Port could not finish storing it. The message is generic and the body carries the `requestId`. | Do **not** book again: look for the booking in your history or contact support with the `request_id`. | A failed booking is always a JSON body: `409` when the offer or search was already booked, `503` when the supplier failed with a 5xx status (or your balance was busy, see above), `422` for every other refusal. You never receive an HTML `502` page from this endpoint. The refusal of the supplier is in `error.code`, `error.type` and `error.message`. See [Errors](https://fx-port.com/docs/errors.md) for the body of an error. ## Related - [Price a flight offer](https://fx-port.com/docs/flights/price.md), [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md) and [Booking flows](https://fx-port.com/docs/flights/booking-flows.md). - [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md) and [Get the ticket PDF](https://fx-port.com/docs/flights/get-ticket.md). --- # Quote a held booking > Get the current price and payment status of a held order before paying. Endpoint: `POST /api/v1/flights/get_order_quote` Source: https://fx-port.com/docs/flights/get-order-quote Get the current quote of an order: its price, its status and whether it can still be paid. The step is **optional**: call it when you want to preview or refresh the price of a held order before you pay. **Permission:** Read. **Rate group:** booking (1,200 requests per minute per key). ## Identifying the order Send **one** of these; the first one present wins, in this order. Another one that holds a different value is dropped, and an `ignored_field` warning names it. | Field | Description | |---|---| | `order_id` | The supplier's order id: `booking.reference` of the booking response. **Preferred.** | | `booking_id` | The PNR of the booking. | | `pnr` | The airline record locator. | | `supplier_id` | Not needed: the supplier is read from the booking. | The order must be a booking of **your agency** in the environment of your key. An unknown order, an order of another agency and the FX-Port UUID (which is not accepted here) all answer the same `404 not_found`, before any supplier is asked. > [!WARNING] > **PNRs are not unique.** The same PNR can exist at another supplier or airline, so a lookup by PNR can be ambiguous. Use `order_id` whenever you can. Legacy names: `orderId`, `bookingId`, `supplierId` still work and are answered with a `deprecated_field` warning. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/get_order_quote \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "eJzTd9ePNA9wdA0FAAs8AmE" }' ``` ## Response example The held Amadeus Algeria order from [Book a flight](https://fx-port.com/docs/flights/book.md#response-example-hold), before it is paid. An Amadeus order is answered from the stored booking, without calling the supplier. ~~~json { "environment": "sandbox", "success": true, "requestId": "469d8803", "orderId": "eJzTd9ePNA9wdA0FAAs8AmE", "processingTime": 0.134, "orderQuote": { "orderId": "eJzTd9ePNA9wdA0FAAs8AmE", "bookingReference": "Y7PAEU", "status": "awaiting_payment", "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 3160.0, "taxesAndFees": 1765.44, "agencyCommission": 4.93, "finalPrice": 4930.37, "b2bPrice": 4925.44, "additionalServices": [], "totalAmount": 4925.44 }, "paymentStatus": { "awaitingPayment": true, "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "priceGuaranteed": true, "canPay": true }, "liveMode": false }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus Algeria" }, "agency": { "currency": "DZD" }, "meta": { "priceGuaranteed": true, "paymentDeadline": null, "canPay": true } } ~~~ ## Field meanings | Field | Meaning | |---|---| | `orderId` | The supplier's order id (twin: `order_id`). | | `orderQuote.bookingReference` | The PNR (twin: `orderQuote.pnr`). | | `orderQuote.status` | `awaiting_payment` for a held order; `confirmed` or `cancelled` otherwise. | | `orderQuote.pricing` | The price block of [Flight pricing](https://fx-port.com/docs/flights/pricing.md). **`b2bPrice` and `totalAmount` are the same number: what will be debited from your balance.** | | `orderQuote.paymentStatus` | `awaitingPayment`, `paymentRequiredBy` (the deadline, when the supplier gives one), `priceGuaranteeExpiresAt`, `priceGuaranteed`, `canPay`. | | `orderQuote.liveMode` | `false` in the sandbox, `true` on live. | | `supplier`, `agency` | The supplier of the order, and your agency's id and currency. | | `meta` | `priceGuaranteed`, `paymentDeadline`, `canPay`. | | `exchangeRate` | Only when the currencies differ. On a **Duffel** order it is an object `{ "rate", "marginPercent", "fromCurrency", "toCurrency" }`, not a number. | A **Duffel** order is answered by the supplier, live: the quote adds `orderQuote.pricing.taxAmount` and `originalTaxAmount`, the re-priced amounts can differ from what was charged at booking (21,434.22 against 21,505.29 in our test), and an order that is already paid has `canPay: false` and `priceGuaranteed: false`. An order of an ACD booking has `orderQuote.pricing.acd`; see [Flight pricing](https://fx-port.com/docs/flights/pricing.md#the-acd-block-amadeus-algeria). > [!WARNING] > **`canPay` is not a guarantee.** Payment can still fail because the PNR expired or was cancelled, the deadline passed, or the airline released the reservation. The answer of [`pay_order`](https://fx-port.com/docs/flights/pay-order.md) is the only truth: handle a payment failure even when `canPay` was `true` a moment ago. ## Errors An order that is not a booking of your agency is a `404` with the text `Booking not found for order_id: ` and the code `not_found`. Unknown orders and orders of other agencies are answered **identically**: ~~~json { "environment": "sandbox", "detail": "Booking not found for order_id: ZZZZZZ", "success": false, "errors": [ { "code": "not_found", "type": "not_found", "title": "Not found", "message": "Booking not found for order_id: ZZZZZZ", "field": null, "details": {}, "documentation_url": "https://fx-port.com/docs/errors#not_found" } ], "meta": { "request_id": "req_8f3a61c2d9e4", "status": 404 }, "request_id": "req_8f3a61c2d9e4" } ~~~ When the supplier refuses the quote for an order that FX-Port knows (a Duffel order that is gone at the supplier, for example), the answer is **not** a `404`: it is `200` with `success: false` and the supplier's own error. Real shape of 2026-11-01: ~~~json { "environment": "sandbox", "success": false, "requestId": "0582790e", "orderId": "ZZZZZZ", "processingTime": 0.085, "error": { "message": "The resource you are trying to access does not exist.", "code": "not_found", "type": "invalid_request_error", "details": [{ "title": "Not found", "type": "invalid_request_error", "message": "The resource you are trying to access does not exist.", "code": "not_found" }] }, "supplier": { "id": "duffel_1", "technology": "duffel", "name": "Duffel" }, "meta": {} } ~~~ Always read `success`. `errors[0].type` is `supplier_error` (or `not_found`) for these answers. | Status | Code | When | |---|---|---| | `200` | `not_found`, other supplier codes | `success: false`: the supplier refused the quote. | | `400` | `bad_request` | None of `order_id`, `booking_id` and `pnr` was sent: `Missing required parameter: order_id (or booking_id / pnr)`. | | `400` | `invalid_booking_id` | The id is not a valid token: letters, digits and `- _ . : = ~`, up to 128 characters. | | `400` | `invalid_json`, `invalid_body` | The body is not a JSON object. | | `404` | `not_found` | The order is not a booking of your agency in this environment. | ## Related - [Pay a held booking](https://fx-port.com/docs/flights/pay-order.md) and [Cancel a held booking](https://fx-port.com/docs/flights/cancel-order.md). - [Book a flight](https://fx-port.com/docs/flights/book.md) and [Booking flows](https://fx-port.com/docs/flights/booking-flows.md). --- # Pay a held booking > Pay a held order, issue the tickets and debit the agency balance. Requires Read+Write permission. Endpoint: `POST /api/v1/flights/pay_order` Source: https://fx-port.com/docs/flights/pay-order Pay for a held booking and issue the tickets. The agency balance is debited by `pricing.b2bPrice`. **Permission:** Read+Write. **Rate group:** booking (1,200 requests per minute per key). In the sandbox no money moves. The **amount and the currency are resolved by the server** from the stored booking. Do **not** send `currency`: the request is refused with `400` (`Do not pass 'currency'. Currency is resolved automatically...`). An `amount` is not read; it is ignored. ## Identifying the order | Field | Description | |---|---| | `order_id` | The supplier's order id (`booking.reference` of the booking response). **Preferred.** | | `booking_id` | The PNR of the booking. | | `pnr` | The airline record locator. Not unique: prefer `order_id`. | The first one present wins, in this order; another one that holds a different value is dropped, and an `ignored_field` warning names it. The FX-Port UUID is not accepted. Other fields: | Field | Type | Description | |---|---|---| | `payment_type` | string | `balance` (default), `card` or `arc_bsp_cash`. Only **Duffel** reads it; Amadeus Algeria and Travelport pay from the balance whatever you send. | | `three_d_secure_session_id` | string | For a Duffel card payment that needs 3-D Secure. | | `price_guaranteed` | boolean | Default `true`: pay the price of the hold. Leave it out. `false` asks FX-Port to re-price at payment time. | | `supplier_id` | string | Only used when the booking has no supplier of its own; default `duffel_1`. | Legacy names: `orderId`, `bookingId`, `paymentType`, `threeDSecureSessionId`, `priceGuaranteed`, `supplierId` still work and are answered with a `deprecated_field` warning. > [!WARNING] > **Payment cannot revive an expired hold.** `pay_order` fails when the PNR expired or was cancelled, the deadline has passed or the airline released the hold early; airlines can release a PNR before the time that was displayed. Read `success`, `paymentSucceeded` and `paymentDetails.status` of the answer: do not confirm the traveler, and do not charge them in your own system, until the payment succeeded. > [!CAUTION] > `pay_order` has **no `send_email` switch**: the confirmation e-mail is always requested (also in the sandbox, where `emailSent` can turn `true`). ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/pay_order \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "payment_type": "balance" }' ``` ## Response example The held Amadeus Algeria order of the previous pages, paid. Real response of 2026-11-01, trimmed (the itinerary, the traveler pricing and the owner are shortened); the agency and the contact data are fake. There is no `exchangeRate` because both currencies are DZD. ~~~json { "environment": "sandbox", "success": true, "paymentSucceeded": true, "requestId": "1ad06b56", "orderId": "eJzTd9ePNA9wdA0FAAs8AmE", "processingTime": 2.981, "paymentDetails": { "paymentId": null, "status": "succeeded", "type": "issuance", "paymentAmount": 4925.44, "currency": "DZD", "createdAt": "2026-11-01T15:26:21.896668", "message": "Payment successful! Tickets have been issued." }, "booking": { "reference": "eJzTd9ePNA9wdA0FAAs8AmE", "status": "confirmed", "pnr": "Y7PAEU", "type": "instant", "createdAt": "2026-11-01T15:26:21.896668", "cancelledAt": null, "content": "managed", "syncedAt": "2026-11-01T15:26:21.896668", "availableActions": [], "voidableUntil": "2026-11-01T23:00:00Z", "cancellation": null }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus AQC", "offerId": "1" }, "agency": { "currency": "DZD" }, "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 3160.0, "taxesAndFees": 1765.44, "agencyCommission": 4.93, "finalPrice": 4930.37, "b2bPrice": 4925.44, "additionalServices": [], "acd": { "applied": false } }, "payment": { "status": "paid", "paidAt": "2026-11-01T15:26:21.896710", "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "awaitingPayment": false }, "documents": [ { "type": "electronic_ticket", "documentType": "ETICKET", "uniqueIdentifier": "124-2446977397", "status": "ISSUED", "passengerIds": ["1"], "segmentIds": ["1"] } ], "bookingReferences": [{ "pnr": "Y7PAEU", "carrier": { "iataCode": "AH", "name": "Air Algerie" } }], "passengers": [ { "id": "1", "type": "ADULT", "title": "mr", "givenName": "JAMES", "familyName": "SMITH", "gender": "male", "bornOn": "1988-06-15", "email": "TRAVELER@EXAMPLE.COM", "phoneNumber": "15555550123", "infantPassengerId": null, "loyaltyProgrammeAccounts": [], "ticketNumber": "124-2446977397" } ], "travelerPricing": ["..."], "itinerary": ["..."], "internalBooking": { "bookingId": "Y7PAEU", "internalId": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7" }, "meta": { "paymentStatus": "paid", "orderConfirmed": true, "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z" }, "postProcessing": { "bucketLink": null, "emailSent": true } } ~~~ ## Field meanings | Field | Meaning | |---|---| | `paymentSucceeded` | `true` or `false`: check it **and** `paymentDetails.status`. | | `paymentDetails.status` | `succeeded` once the tickets are issued. | | `paymentDetails.type` | `issuance` for a hold that is paid and ticketed. | | `paymentDetails.paymentAmount` | What was debited: equals `pricing.b2bPrice`. | | `booking.status` | `confirmed` now (it was `pending` while held); `booking.type` is `instant`. | | `booking.voidableUntil` | The end of the same-day void window: midnight in the supplier's time zone of the day of the **payment**. A hold created days ago is voidable until midnight of the day it is paid. | | `documents[n].uniqueIdentifier` | The e-ticket number. | | `pricing` | The price block; `pricing.acd` on Amadeus Algeria. See [Flight pricing](https://fx-port.com/docs/flights/pricing.md). | | `requiresACD` | Present and `true` for an ACD booking: send the passports. | | `internalBooking.internalId` | The FX-Port id of the booking (a UUID): the same as `id` of [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) and `postProcessing.bookingDbId` of [`book_flight`](https://fx-port.com/docs/flights/book.md). Use it as the key of the booking in your own database. `internalBooking.bookingId` is the PNR. | Rounding can make `finalPrice` and `b2bPrice` one hundredth different from `get_booking`: see [Flight pricing](https://fx-port.com/docs/flights/pricing.md). ## Errors A refusal by the supplier is a `200` with `success: false` and `paymentSucceeded: false`: **the HTTP status is not enough**. `error.code` is a number as text for Amadeus and a name for Duffel. | Where | What you see | Meaning | |---|---|---| | `200`, `success: false` | `error.code` `4926`, "The flightOrder is already issued" | The order was already paid. | | `200`, `success: false` | `error.code` `9112`, "NEED ITINERARY for record ..." | The reservation is cancelled or expired (a cancelled hold). | | `200`, `success: false` | `order_type_not_eligible_for_payment` (Duffel), with a `userMessage` | A Duffel order that was bought instantly cannot be paid later. | | `400` | `bad_request` | None of `order_id`, `booking_id`, `pnr` was sent (`Missing required parameter: order_id (or booking_id / pnr)`). | | `400` | `bad_request` | `currency` was in the body (`Do not pass 'currency'...`). | | `400` | `invalid_booking_id`, `invalid_payment_type`, `invalid_identifier`, `invalid_field_type` | An id is not a valid token (letters, digits and `- _ . : = ~`, up to 128 characters), `payment_type` is not `balance`, `card` or `arc_bsp_cash`, or `price_guaranteed` is not a boolean. | | `402` | `insufficient_balance` (`details`: `available`, `required`, `currency`) | Live only. | | `403` | `read_write_permission_required` | A read-only key. | | `403` | `agency_not_active` | Live only. | | `404` | `not_found` | The order is not a booking of your agency in this environment: `Booking not found for order_id: . Cannot process payment.` An unknown order and the order of another agency are answered the same, and nothing is paid. | | `503` | `balance_lock_timeout` | Live only: another operation of your agency holds the balance in the same currency for a moment. **Nothing was paid**: the lock is taken before the supplier is called, so you can send the same request again after a few seconds. The message is always the fixed sentence `The agency balance is busy with another operation. Retry in a few seconds.`; `errors[0].details.currency` is the currency of your balance. No `Retry-After` header is sent. | Do not retry a payment blindly: read the booking with [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) first. A second `pay_order` for an order that is paid answers `4926`, which is harmless. `503 balance_lock_timeout` is safe to repeat: the payment never started. ## Related - [Quote a held booking](https://fx-port.com/docs/flights/get-order-quote.md), [Cancel a held booking](https://fx-port.com/docs/flights/cancel-order.md) and [Booking flows](https://fx-port.com/docs/flights/booking-flows.md). --- # Cancel a held booking > Cancel a held (unpaid) flight reservation to release the PNR. This is not a refund. Endpoint: `POST /api/v1/flights/cancel_order` Source: https://fx-port.com/docs/flights/cancel-order Cancel a **held (unpaid)** reservation. The reservation is deleted at the supplier and the **PNR is released**. **Permission:** Read+Write. **Rate group:** booking (1,200 requests per minute per key). > [!CAUTION] > **This is not a refund and it does not apply to issued (paid) tickets.** A held reservation was never paid, so nothing is refunded: the reservation is deleted and the PNR released. To get money back for an issued **Duffel** order use [Cancel quote](https://fx-port.com/docs/flights/cancel-quote.md) and [Confirm cancel](https://fx-port.com/docs/flights/confirm-cancel.md); void, exchange or refund of an issued **Amadeus Algeria** ticket is manual: see [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md). ## Why cancel a hold If you create a hold and abandon it, the airline can **refuse a new booking for the same passenger** on the same route while the old reservation is pending. Cancel the old hold to release the PNR before you book that passenger again. ## Identifying the order | Field | Description | |---|---| | `order_id` | The supplier's order id returned when the hold was created (`booking.reference`). **Preferred.** | | `booking_id` | The PNR of the booking. | | `pnr` | The airline record locator. | Send one: the first one present wins, in this order, and another one that holds a different value is dropped with an `ignored_field` warning. PNRs are not unique across suppliers: prefer `order_id`. Legacy names: `orderId`, `bookingId`. ## Conditions The call is refused unless **all** of these hold: - the booking belongs to your agency (another agency's booking is answered like an unknown one); - the supplier is Amadeus Algeria or Travelport (a Duffel order is answered with `400`: Duffel has no holds); - the booking status is `awaiting_payment` (a `confirmed` or already `cancelled` booking is `409 invalid_booking_status`). ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/cancel_order \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "eJzTd9ePNA90dHUGAAsuAlA" }' ``` ## Response example Real response of 2026-11-01 for a hold created two seconds earlier: ~~~json { "environment": "sandbox", "success": true, "requestId": "11487aa1", "orderId": "eJzTd9ePNA90dHUGAAsuAlA", "processingTime": 2.147, "booking": { "reference": "Y7QAEC", "status": "cancelled", "pnr": "Y7QAEC", "type": "hold", "createdAt": "2026-11-01T15:26:27.977426+00:00", "cancelledAt": "2026-11-01T15:26:30.748257", "content": "managed", "syncedAt": "2026-11-01T15:26:30.748257", "availableActions": [], "voidableUntil": null, "cancellation": { "type": "void", "deleted": true, "deletedAt": "2026-11-01T15:26:30.748257" } }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus Algeria", "offerId": null }, "agency": { "currency": "DZD" }, "cancellation": { "status": "deleted", "deleted": true, "deletedAt": "2026-11-01T15:26:30.748257", "refundAmount": 0, "refundCurrency": "DZD", "refundTo": null, "message": "Reservation deleted successfully." }, "meta": { "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "voidWindowEndsAt": null, "airlineInitiatedChanges": [], "changes": [], "users": [], "orderMetadata": { "supplierStatusCode": 204, "supplierDeleteErrorIgnored": false, "supplierDeleteErrorCode": 204, "supplierDeleteErrorMessage": null } }, "postProcessing": { "bookingStatusUpdated": true } } ~~~ ## Field meanings | Field | Meaning | |---|---| | `orderId` | The supplier's order id of the booking (twin: `order_id`). | | `booking.reference` | **The PNR in this endpoint** (in the book and pay responses it is the order id): read `orderId` for the order. | | `booking.status` | `cancelled` on success. | | `booking.availableActions` | Empty: nothing more can be done with this order. | | `booking.cancellation` and `cancellation` | `deleted: true` when the reservation was released at the supplier. | | `cancellation.refundAmount` | Always `0` here: a hold was never paid. | | `cancellation.message` | A human-readable outcome, for your logs. | | `postProcessing.bookingStatusUpdated` | `true` when the FX-Port booking is now `cancelled`. | Cancelling a hold does not touch your balance: nothing was ever debited. The booking stays readable with `get_booking` (status `cancelled`). ## Errors | Status | Code | When | What to do | |---|---|---|---| | `400` | `bad_request` | None of `order_id`, `booking_id`, `pnr` (`Missing required parameter: order_id (or booking_id / pnr)`). | Send one. | | `400` | `bad_request` | The booking is not an Amadeus Algeria or Travelport hold (for example a Duffel order). Message: "Delete reservation is currently only supported for Amadeus Algeria and Travelport hold bookings." | For an issued Duffel order see [Cancel quote](https://fx-port.com/docs/flights/cancel-quote.md). | | `403` | `read_write_permission_required` | A read-only key. | Use a read+write key. | | `404` | `not_found` | The booking is unknown for your agency and environment: `Booking not found for order_id: `. The booking of another agency is answered the same. | Check the id. | | `409` | `invalid_booking_status` | The booking is not `awaiting_payment`: it is `confirmed` (paid) or already `cancelled`. | Read it with `get_booking`. | | `500` | `booking_update_failed` | The reservation was deleted but the FX-Port booking could not be updated. | Read the booking; contact support with the `request_id` if it is still `awaiting_payment`. | A supplier failure while deleting is answered with the supplier's status and message. ## Related - [Book a flight](https://fx-port.com/docs/flights/book.md) (hold), [Pay a held booking](https://fx-port.com/docs/flights/pay-order.md) and [Booking flows](https://fx-port.com/docs/flights/booking-flows.md#cancelling). --- # Quote a cancellation > Ask what cancelling an issued Duffel order would refund. Nothing is cancelled until you confirm it. Endpoint: `POST /api/v1/flights/cancel_quote` Source: https://fx-port.com/docs/flights/cancel-quote Ask what cancelling an **issued Duffel order** would refund. The call creates a **pending cancellation** at the supplier with the refund amount; nothing is cancelled and no money moves until you confirm it with [`confirm_cancel`](https://fx-port.com/docs/flights/confirm-cancel.md). **Permission:** Read. **Rate group:** default (6,000 requests per minute per key). Only **Duffel** orders: an order of another supplier is answered `400`. > [!CAUTION] > **Only the confirmation cancels.** A quote is valid for one hour (`cancellation.expiresAt`). The amount is the supplier's offer **for that moment**: ask again if you wait. Whether an order can be cancelled at all depends on the airline and the fare: many fares answer `order_not_cancellable`. This is the self-serve refund path of Duffel orders. Held reservations are cancelled with [`cancel_order`](https://fx-port.com/docs/flights/cancel-order.md); void, exchange and refund of Amadeus Algeria tickets are manual: see [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md). ## Request body | Field | Type | Required | Description | |---|---|---|---| | `order_id` | string | Yes | The **supplier's order id** of the booking (`ord_...`, the `booking.reference` of the booking response, `amadeus_id` in `get_booking`). `booking_id` and `pnr` are accepted as other names of the same field (the first one present wins): FX-Port finds the booking of your agency and sends the supplier's own order id to Duffel, so a PNR works too. Prefer the order id: a PNR is not unique. | | `supplier_id` | string | No | Default `duffel_1`. A value that does not start with `duffel` is `400`. | Legacy names: `orderId`, `bookingId`, `supplierId`. If `order_id`, `booking_id` and `pnr` hold different values, the first one wins and an `ignored_field` warning names the others. Only bookings of your agency in the environment of your key can be quoted: an unknown order and the order of another agency are both `404 booking_not_found`, before the supplier is asked. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/cancel_quote \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "ord_0000BC0VDONwA54LrfoCkm" }' ``` ## Response example A refundable Duffel order (real response of 2026-11-01). The airline refunds USD 76.86, FX-Port credits DZD 19,215.00 to the balance of the agency: ~~~json { "environment": "sandbox", "success": true, "requestId": "2cb63f69", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "processingTime": 0.128, "cancellation": { "cancellationId": "ore_0000BC0VG9WBaFAZ83u3yy", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "status": "pending", "refund": { "originalRefundCurrency": "USD", "originalRefundAmount": 76.86, "refundCurrency": "DZD", "refundAmount": 19215.0, "refundTo": "FX-Port Balance", "refundToRaw": "balance", "convertedSupplierRefundAmount": 19215.0, "isFullBalanceRefund": false, "kioskCommissionRefund": 0.0 }, "expiresAt": "2026-11-01T15:28:32Z", "confirmedAt": null, "createdAt": "2026-11-01T14:28:32.488592Z", "airlineCredits": null, "liveMode": false }, "supplier": { "id": "duffel_1", "technology": "duffel", "name": "Duffel" }, "agency": { "currency": "DZD" }, "exchangeRate": { "rate": 250.0, "marginPercent": 0.0, "fromCurrency": "USD", "toCurrency": "DZD" }, "meta": { "quoteValidUntil": "2026-11-01T15:28:32Z", "isConfirmed": false, "canConfirm": true } } ~~~ ## Field meanings | Field | Meaning | |---|---| | `cancellation.cancellationId` | `ore_...`: the id to send to `confirm_cancel` (twin: `cancellation_id`). | | `cancellation.status` | `pending` until confirmed. | | `cancellation.refund.refundAmount`, `refundCurrency` | What your balance receives, in the agency currency. | | `cancellation.refund.originalRefundAmount`, `originalRefundCurrency` | The airline's amount in the supplier's currency. | | `cancellation.refund.refundTo`, `refundToRaw` | `FX-Port Balance` and `balance`: the refund goes to your FX-Port balance. | | `cancellation.refund.isFullBalanceRefund` | Whether the refund covers the whole price. | | `cancellation.refund.kioskCommissionRefund` | A commission refund, when there is one. | | `cancellation.expiresAt`, `meta.quoteValidUntil` | The end of the validity of the quote, in UTC. | | `cancellation.airlineCredits` | An airline credit instead of money, when the airline offers one; `null` otherwise. | | `meta.canConfirm` | `true` when `confirm_cancel` will be accepted. | | `exchangeRate` | The rate between the supplier's currency and yours, as an **object**. | ## Errors The supplier's refusals are `200` answers with `success: false` (read `success`): `errors[0].type` is `supplier_error`. | Where | `error.code` | Meaning | |---|---|---| | `200`, `success: false` | `order_not_cancellable` | "This order cannot be cancelled through the API." The airline or the fare does not allow it; use [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md). | | `200`, `success: false` | `already_cancelled` | The order was cancelled before. | | `200`, `success: false` | `not_found` | FX-Port knows the booking but Duffel does not know the order. | | `400` | `bad_request` | None of `order_id`, `booking_id` and `pnr` was sent (`Missing required parameter: order_id`), or the supplier is not Duffel. | | `400` | `invalid_booking_id`, `invalid_supplier_selection` | An id or the `supplier_id` is not a valid token. | | `404` | `booking_not_found` | No booking of your agency in this environment has this reference. An unknown order and the order of another agency are answered the same. | ## Related - [Confirm cancel](https://fx-port.com/docs/flights/confirm-cancel.md) (the next step) and [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md). - [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md): after the confirmation the status is `partial_refund`. --- # Confirm a cancellation > Confirm a quoted cancellation of an issued Duffel order: the order is cancelled and the refund goes to your balance. Irreversible. Endpoint: `POST /api/v1/flights/confirm_cancel` Source: https://fx-port.com/docs/flights/confirm-cancel Confirm the cancellation of an issued **Duffel order** that you quoted with [`cancel_quote`](https://fx-port.com/docs/flights/cancel-quote.md). The order is cancelled at the airline and the refund is credited to your FX-Port balance. **Permission:** Read+Write. **Rate group:** default (6,000 requests per minute per key). > [!CAUTION] > **This cannot be undone.** After the confirmation the order is cancelled for good and the refund of the quote is what you get. Show the amount of `cancel_quote` to your customer **before** you call this endpoint, and call it once: a second call answers `already_cancelled`. ## Request body | Field | Type | Required | Description | |---|---|---|---| | `cancellation_id` | string | Yes | The `ore_...` id of `cancel_quote` (`cancellation.cancellationId`). | | `order_id` | string | Yes | The supplier's order id of the booking (`booking_id` and `pnr` are accepted as other names). It makes FX-Port check that the booking is yours **and** update the booking (status, refund) and the balance. It is required together with `cancellation_id`: without it the call is `400 missing_field`, and nothing is cancelled. | | `supplier_id` | string | No | Default `duffel_1`. | Legacy names: `cancellationId`, `orderId`, `bookingId`, `supplierId`. If `order_id`, `booking_id` and `pnr` hold different values, the first one wins and an `ignored_field` warning names the others. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/confirm_cancel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "cancellation_id": "ore_0000BC0VG9WBaFAZ83u3yy", "order_id": "ord_0000BC0VDONwA54LrfoCkm" }' ``` ## Response example Real response of 2026-11-01: ~~~json { "environment": "sandbox", "success": true, "requestId": "64666d3c", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "processingTime": 0.245, "confirmation": { "cancellationId": "ore_0000BC0VG9WBaFAZ83u3yy", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "status": "confirmed", "refund": { "originalRefundCurrency": "USD", "originalRefundAmount": 76.86, "refundCurrency": "DZD", "refundAmount": 19215.0, "refundTo": "FX-Port Balance", "refundToRaw": "balance", "convertedSupplierRefundAmount": 19215.0, "isFullBalanceRefund": false, "kioskCommissionRefund": 0.0 }, "confirmedAt": "2026-11-01T14:30:04.851325Z", "createdAt": "2026-11-01T14:28:32.488592Z", "airlineCredits": null, "liveMode": false, "message": "Order cancellation confirmed. Refund will be processed." }, "supplier": { "id": "duffel_1", "technology": "duffel", "name": "Duffel" }, "agency": { "currency": "DZD" }, "exchangeRate": { "rate": 250.0, "marginPercent": 0.0, "fromCurrency": "USD", "toCurrency": "DZD" }, "meta": { "cancellationConfirmedAt": "2026-11-01T14:30:04.851325Z", "refundProcessing": { "method": "FX-Port Balance", "estimatedDays": "Immediate", "description": "Refund credited to your FX-Port balance" } }, "postProcessing": { "refundMethod": "balance", "emailSent": false } } ~~~ | Field | Meaning | |---|---| | `confirmation.status` | `confirmed`. | | `confirmation.refund` | The refund of the quote: `refundAmount` in your currency, `originalRefundAmount` in the supplier's. | | `confirmation.message` | A sentence for your logs. | | `meta.refundProcessing` | How the refund is paid: `FX-Port Balance`, `Immediate`. | | `postProcessing.refundMethod` | `balance`. | After the confirmation [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) shows the status `partial_refund` with `refund_amount` (19215.0 here, of 21505.29 paid), `refund_currency` and `refund_method: "balance"`. ## Errors The supplier's refusals are `200` answers with `success: false` (read `success`). | Where | `error.code` | Meaning | |---|---|---| | `200`, `success: false` | `already_cancelled` | The order was already cancelled. | | `200`, `success: false` | `not_found` | Duffel does not know this `cancellation_id`. | | `400` | `bad_request` | No `cancellation_id` (`Missing required parameter: cancellation_id`), or the supplier is not Duffel. | | `400` | `missing_field` | No `order_id` (`errors[0].field` is `order_id`): it is required with `cancellation_id`. | | `400` | `invalid_cancellation_id`, `invalid_booking_id` | An id is not a valid token (letters, digits and `- _ . : = ~`, up to 128 characters). | | `403` | `read_write_permission_required` | A read-only key. | | `404` | `booking_not_found` | `order_id` is not a booking of your agency in this environment. An unknown order and the order of another agency are answered the same, and nothing is cancelled. | ## Related - [Cancel quote](https://fx-port.com/docs/flights/cancel-quote.md) and [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md). --- # Get a flight booking > Retrieve the full booking through one booking_id field that accepts an internal UUID, a supplier order id or a PNR, with the PDF link. Endpoint: `POST /api/v1/flights/get_booking` Source: https://fx-port.com/docs/flights/get-booking Retrieve a booking by its PNR, its supplier order id or its FX-Port id: the status, the prices, the ticket number, the links of the PDF tickets and the ACD state. **Permission:** Read. **Rate group:** booking (1,200 requests per minute per key). ## Identifying the booking The body has one field, `booking_id`, and its value can be any of these: | `booking_id` value | Description | |---|---| | The FX-Port booking `id` (a UUID) | **Recommended**: stable and unambiguous. Returned as `postProcessing.bookingDbId` by [book](https://fx-port.com/docs/flights/book.md) and as `id` here. | | The supplier order id | `booking.reference` of the booking response: an `ord_...` for Duffel, a base64-like text for Amadeus. Stored in the `amadeus_id` column for **both** suppliers. | | The PNR | The airline record locator. Not unique across suppliers. | The lookup tries the PNR first, then the order id, then the UUID, inside your agency and your key's environment. A booking that is not yours, or that belongs to the other environment, is answered `404 booking_not_found`. > [!WARNING] > Prefer the UUID or the order id: **PNRs can collide** across suppliers and airlines. | Field | Type | Required | Description | |---|---|---|---| | `booking_id` | string | Yes | One of the three identifiers above. `order_id` and `pnr` are accepted as other names of the same field. | | `include_api_response` | boolean | No | `true` adds `booking.apiResponse`: the stored answer of the booking call, with the full passenger list. Default `false`. | Legacy names: `bookingId`, `orderId` and `includeApiResponse` still work and are answered with a `deprecated_field` warning. If `booking_id` is sent together with `order_id` or `pnr` and the values differ, `booking_id` wins and an `ignored_field` warning names the dropped one. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/get_booking \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7" }' ``` ## Response example An Amadeus Algeria booking, held and then paid (real response of 2026-11-01, trimmed: the internal columns are left out and the links and ids are fake). `order_id`, `pnr`, `pdf_url` and `pdf_url_no_price` are the snake_case twins of `amadeus_id`, `booking_id`, `bucket_link` and `bucket_link_no_price`; `acd_required` and `requires_acd` say whether ACD passports are expected: ~~~json { "environment": "sandbox", "success": true, "requestId": "0c2b315c", "processingTime": 0.272, "booking": { "id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "booking_id": "Y7PAEU", "amadeus_id": "eJzTd9ePNA9wdA0FAAs8AmE", "order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "pnr": "Y7PAEU", "status": "confirmed", "created_at": "2026-11-01T15:26:14.871035+00:00", "paid_at": "2026-11-01T15:26:21.961664+00:00", "updated_at": "2026-11-01T15:26:21.998944+00:00", "total_price": 4930.37, "agency_commission": 4.93, "currency": "DZD", "departure_airport": "ALG", "arrival_airport": "ORN", "departure_date": "2026-12-22T19:45:00", "arrival_date": "2026-12-22T20:45:00", "return_departure_date": null, "return_arrival_date": null, "included_checked_bags": "0", "ticketing_agreement": "DELAY_TO_CANCEL", "supplier_name": "Amadeus Algeria", "supplier_id": "amadeus_aqc_dz_1", "ticket_number": "124-2446977397", "bucket_link": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "bucket_link_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf", "pdf_url": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "pdf_url_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf", "email_sent": false, "require_acd": false, "acd_required": false, "requires_acd": false, "acd_submitted": false, "acd_links": null, "acd_amount": null, "acd_paid": false, "acd_documents_revision": 0, "acd_documents": null, "has_changed": false, "new_flight_details": null, "voided_at": null, "refund_amount": null, "refund_currency": null, "refund_method": null, "agent_id": null, "environment": "sandbox", "pricingBreakdown": { "supplier_currency": "DZD", "agency_currency": "DZD", "base_price": 3160.0, "taxes_and_fees": 1765.44, "agency_commission": 4.93, "final_price": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:22.176516+00:00", "environment": "sandbox" }, "travelerPricing": [ { "traveler_id": "1", "traveler_type": "ADULT", "fare_option": "STANDARD", "supplier_currency": "DZD", "agency_currency": "DZD", "base": 3160.0, "total": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:15.133066+00:00", "environment": "sandbox" } ], "balanceOperations": [] } } ~~~ The booking is a **database row**, so its keys are `snake_case`; only `pricingBreakdown`, `travelerPricing`, `balanceOperations` and `apiResponse` are camelCase. The row has more keys than shown (the void history, the details of a changed schedule ...): they are internal, can change and are not part of the contract. Use the keys of the table below. The ids of your agency and of the API key are not returned, and neither are the storage keys of the ACD passports. The booking also carries the twins `order_id` (next to `amadeus_id`), `pnr` (next to `booking_id`), `pdf_url` and `pdf_url_no_price` (next to `bucket_link` and `bucket_link_no_price`). ## Key fields | Field | Description | |---|---| | `id` | The FX-Port UUID of the booking. **Use it as the key** when you store or re-read a booking. | | `booking_id` | The PNR. Not unique; prefer `id`. | | `amadeus_id` | The supplier's order id. The name is historical: it also holds the `ord_...` of a Duffel order. This is the `order_id` of `pay_order`, `cancel_order` and `cancel_quote`. | | `status` | `confirmed`, `awaiting_payment` (a hold), `cancelled`, `partial_refund` (a Duffel order cancelled with a refund), `failed` (the supplier refused the booking). Other values (`voided`, `refunded`, `expired`) can appear for changed orders. | | `total_price`, `agency_commission`, `currency` | The final price, your commission and the currency of the agency. | | `pricingBreakdown` | `supplier_currency`, `agency_currency`, `base_price`, `taxes_and_fees`, `agency_commission`, `final_price`, `b2b_price`: **`b2b_price` is what was debited**. The rate and the ACD details are not here. | | `travelerPricing[n]` | The price of each traveler, as in [search](https://fx-port.com/docs/flights/search.md#travelerpricingn-fields) but in snake_case. | | `departure_airport`, `arrival_airport`, `departure_date`, `arrival_date`, `return_departure_date`, `return_arrival_date` | The route; the dates are local times without a zone. | | `ticket_number` | The e-ticket number(s), as one text (several numbers are joined by commas). Can be `null` for a while after an instant Amadeus booking: see [Get the ticket number](https://fx-port.com/docs/flights/get-ticket-number.md). In the Duffel sandbox it is the text `1`. | | `bucket_link`, `bucket_link_no_price` | The PDF of the ticket with and without the fare. `null` until generated. **These are public links**: see [Get the ticket PDF](https://fx-port.com/docs/flights/get-ticket.md). | | `ticketing_agreement` | The ticketing-time-limit agreement of an Amadeus booking, for example `DELAY_TO_CANCEL`. | | `require_acd`, `acd_required`, `requires_acd`, `acd_submitted` | The ACD state. `acd_required` and `requires_acd` are `true` for an ACD booking whose passports FX-Port did not waive (the same value as `acd.acd_required` of `get_acd_documents`); `require_acd` is the stored flag. `acd_submitted` is a boolean, never `null`: `false` until every traveler has a passport. See [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). | | `acd_documents`, `acd_links`, `acd_amount`, `acd_paid`, `acd_documents_revision` | The passports of an ACD booking. `acd_documents` is `null` until a passport is uploaded through the API, then it has one entry for each traveler: `traveler_id`, `traveler_name`, `traveler_type`, `link`, `filename`, `content_type`, `size` (bytes), `format`, `uploaded_at`, `replaced_count`, `previous_links[]` (`link`, `uploaded_at`, `replaced_at`: the files that were replaced) and `source`. `acd_links` lists the addresses of every passport file, also the ones FX-Port staff added from the dashboard. **These links are public**: see [Security: file links are public](https://fx-port.com/docs/flights/acd-documents.md#security-file-links-are-public). FX-Port's own storage keys are never returned. For the state of each traveler use [`get_acd_documents`](https://fx-port.com/docs/flights/acd-documents.md). `acd_amount` is the ACD amount in euros, saved when the set became complete (informational); `acd_paid` says whether FX-Port's team marked it as paid; `acd_documents_revision` goes up with each upload that stored a file. | | `has_changed`, `new_flight_details` | Set when the airline changed the schedule after ticketing. | | `refund_amount`, `refund_currency`, `refund_method` | The refund of a cancelled Duffel order (`partial_refund`, method `balance`). | | `voided_at` | When the ticket was voided. | | `email_sent` | Whether the confirmation e-mail went out. | | `environment` | `sandbox` or `live`. | | `balanceOperations` | The ledger entries of this booking (debits, refunds), each with only `id`, `flight_booking_id`, `operation`, `amount`, `currency`, `description`, `created_at`, `b2b_price` and `final_price`. Empty in the sandbox. | ### An ACD booking with a passport The ACD keys of the booking of an ACD fare whose passport was uploaded and then replaced once (real response of 2026-11-01, trimmed to these keys; the name and the links are fake). Every other key is the one of the first example. ~~~json { "booking": { "booking_id": "Y7HWMD", "require_acd": true, "acd_required": true, "requires_acd": true, "acd_submitted": true, "acd_links": ["https://s3.example.com/fx-port/acd_passports/Y7HWMD/1/20261101142432_087a6dc8.png"], "acd_amount": 235.0, "acd_paid": false, "acd_documents_revision": 2, "acd_documents": [ { "traveler_id": "1", "traveler_name": "JANE DOE", "traveler_type": "ADULT", "link": "https://s3.example.com/fx-port/acd_passports/Y7HWMD/1/20261101142432_087a6dc8.png", "filename": "rescan.png", "content_type": "image/png", "size": 69, "format": "png", "uploaded_at": "2026-11-01T14:24:33.057044+00:00", "replaced_count": 1, "previous_links": [ { "link": "https://s3.example.com/fx-port/acd_passports/Y7HWMD/1/20261101142430_9b354245.pdf", "uploaded_at": "2026-11-01T14:24:31.256905+00:00", "replaced_at": "2026-11-01T14:24:33.057044+00:00" } ], "source": "api" } ] } } ~~~ ### The PDF The PDF is generated after the booking, typically in 10 to 30 seconds: **Step 1** Call `get_booking` and look at `booking.bucket_link`. **Step 2** If it is `null`, the PDF is still being made: wait 5 to 10 seconds and read again. A **failed** booking never gets one, so stop after about two minutes. **Step 3** When it has a value it is the download address of the PDF. A **held** booking gets a **hold voucher**, not a ticket; after the payment the link points to the ticket. `POST /flights/get_ticket` returns the same links with a `ticket_ready` flag. ## Errors | Status | Code | When | What to do | |---|---|---|---| | `400` | `bad_request` | No `booking_id` (`booking_id is required`). | Send it. | | `400` | `invalid_booking_id` | `booking_id` is not a valid token: letters, digits and `- _ . : = ~`, up to 128 characters. | Send the id as you received it. | | `400` | `invalid_json`, `invalid_body` | The body is not a JSON object. | Send a JSON object. | | `404` | `booking_not_found` | No booking with that id in your agency and environment (a booking of another agency is answered the same way). | Check the id and the key. | ## Related - [Book a flight](https://fx-port.com/docs/flights/book.md), [Get the ticket PDF](https://fx-port.com/docs/flights/get-ticket.md) and [Get the ticket number](https://fx-port.com/docs/flights/get-ticket-number.md). - [Booking detail](https://fx-port.com/docs/account/booking-detail.md): the same booking across products, by GET. --- # Get the ticket PDF > Returns the ticket PDF download URL. Poll until ticket_ready is true. Endpoint: `POST /api/v1/flights/get_ticket` Source: https://fx-port.com/docs/flights/get-ticket Returns the download links of the ticket PDF of a booking. The PDF is generated after the booking: call again until `ticket_ready` is `true`. **Permission:** Read. **Rate group:** default (6,000 requests per minute per key). > [!NOTE] > **A simpler way:** [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) returns the same links as `booking.bucket_link` and `booking.bucket_link_no_price`, together with the rest of the booking. Use `get_ticket` when you only need the links and a ready flag. > [!WARNING] > **The links are public.** Anybody who has a `download_url` can open the PDF, with no key and no login, and the address of a voucher can be guessed from the PNR and the name. The PDF holds the traveler's name, the itinerary and the fare. Treat the links as sensitive: do not log them, do not put them in e-mails to third parties, and prefer to download the PDF on your server and serve it to your customer yourself. See [Security](https://fx-port.com/docs/security.md#links-to-tickets-vouchers-and-documents-are-not-private). ## Request body | Field | Type | Required | Description | |---|---|---|---| | `booking_id` | string | Yes | The PNR, the supplier order id or the FX-Port UUID of the booking. `order_id` and `pnr` are accepted as other names. | Legacy names: `bookingId` and `orderId`. If `booking_id` is sent together with `order_id` or `pnr` and the values differ, `booking_id` wins and an `ignored_field` warning names the dropped one. A booking of another agency, or of the other environment, is answered `404 booking_not_found`. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/get_ticket \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7PAEU" }' ``` ## Response: not ready Real response of 2026-11-01, a second after a hold was created: ~~~json { "environment": "sandbox", "success": true, "requestId": "76be69dc", "ticket_ready": false, "booking_id": "Y7PAEU", "message": "Ticket is not ready yet. Please retry in a moment." } ~~~ ## Response: ready ~~~json { "environment": "sandbox", "success": true, "requestId": "71fdaeb7", "ticket_ready": true, "booking_id": "Y7PAEU", "download_url": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "download_url_with_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "download_url_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf" } ~~~ | Field | Meaning | |---|---| | `ticket_ready` | `true` when a link exists. **It does not mean that an e-ticket exists** (see below). | | `booking_id` | **Echoes the value you sent** (the PNR if you sent the PNR, the order id if you sent the order id). It is not normalised. | | `download_url`, `download_url_with_price` | The same address: the PDF with the fare receipt (twin: `pdf_url`). | | `download_url_no_price` | A second PDF of the same ticket without prices (twin: `pdf_url_no_price`). Can be `null`. | | `message` | Only when `ticket_ready` is `false`. | ## What `ticket_ready` means `ticket_ready` is `true` as soon as the booking has a PDF link, whatever the status of the booking: - a **held** booking has a **hold voucher**, and the flag can be `true` before the payment; - a **cancelled** hold keeps its voucher link and stays `true`; - an instant **ACD booking** can be `true` before the airline has returned an e-ticket number: use [`get_ticket_number`](https://fx-port.com/docs/flights/get-ticket-number.md) to know whether tickets were issued; - a booking with the status `failed` stays `false` for ever. ## Polling 1. Book, then keep the order id (or the PNR). 2. Wait about 10 seconds and call `get_ticket`. 3. While `ticket_ready` is `false`, call again every 10 seconds, for about two minutes. **Stop then**: a failed booking never gets a PDF. Check the status with `get_booking`. 4. When it is `true`, download the PDF from `download_url`. ## Errors | Status | Code | When | What to do | |---|---|---|---| | `400` | `bad_request` | No `booking_id` (`booking_id is required`). | Send it. | | `400` | `invalid_booking_id` | `booking_id` is not a valid token. | Send the id as you received it. | | `400` | `invalid_json`, `invalid_body` | The body is not valid JSON, or not a JSON object. | Send a JSON object. | | `404` | `booking_not_found` | No such booking in your agency and environment. | Check the id and the key. | ## Related - [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md) and [Get the ticket number](https://fx-port.com/docs/flights/get-ticket-number.md). - [Security](https://fx-port.com/docs/security.md): links to tickets, vouchers and documents. --- # Get the ticket number > Ask for the e-ticket number of a booking: FX-Port asks the airline when the number is not stored yet. Endpoint: `POST /api/v1/flights/get_ticket_number` Source: https://fx-port.com/docs/flights/get-ticket-number Returns the **e-ticket number** of a booking, asking the airline for it when it is not stored yet. Use it after an instant booking whose ticket number has not arrived, or to find out whether the tickets of a booking were issued. **Permission:** Read. **Rate group:** default (6,000 requests per minute per key). > [!NOTE] > This is a Read endpoint that can **do work**: when the number is not stored, FX-Port asks the supplier (Amadeus, Travelport or Duffel) for the order, stores the ticket number on the booking and can send the confirmation e-mail that was held back until the ticket exists. Calling it twice is harmless. Do not call it in a tight loop: wait 5 to 10 seconds between calls. ## Request body | Field | Type | Required | Description | |---|---|---|---| | `booking_id` | string | Yes | The PNR, the supplier order id or the FX-Port UUID. `order_id` and `pnr` are accepted as other names. | Legacy names: `bookingId` and `orderId`. If `booking_id` is sent together with `order_id` or `pnr` and the values differ, `booking_id` wins and an `ignored_field` warning names the dropped one. Only bookings of your agency in the environment of your key are answered (a sandbox key never reads a live booking); anything else, including the booking of another agency, is `404 booking_not_found`. ## Request example **Request** ```bash curl https://api.fx-port.com/api/v1/flights/get_ticket_number \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7PAEU" }' ``` ## Response: not yet A hold has no ticket; an instant booking can have none for a few seconds. Real response of 2026-11-01 for a hold: ~~~json { "environment": "sandbox", "success": true, "requestId": "921cee35", "ticket_available": false, "ticket_number": null, "booking_id": "Y7PAEU", "supplier_order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "message": "The airline has not returned a ticket number yet. Please retry shortly.", "booking_response": { "...": "the stored booking, see below" } } ~~~ ## Response: issued The same booking after `pay_order`: ~~~json { "environment": "sandbox", "success": true, "requestId": "a8e2164f", "ticket_available": true, "ticket_number": "124-2446977397", "booking_id": "Y7PAEU", "supplier_order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "booking_response": { "...": "the stored booking, see below" } } ~~~ | Field | Meaning | |---|---| | `ticket_available` | `true` when the airline has returned a ticket number. | | `ticket_number` | The e-ticket number, for example `124-2446977397` (Amadeus); in the Duffel sandbox the text `1`. When there are several tickets they are joined in one text. `null` until available. | | `booking_id` | The PNR (twin: `pnr`). | | `supplier_order_id` | The supplier's order id (twin: `order_id`). | | `message` | Only when `ticket_available` is `false`. | | `booking_response` | The stored answer of the booking, in the shape of the [book](https://fx-port.com/docs/flights/book.md#response-example-instant-issue) response (booking, supplier, pricing, passengers, itinerary, documents ...), without the supplier's costs and commissions. Use [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) for a stable description of the booking. | ## Errors | Status | Code | When | What to do | |---|---|---|---| | `400` | `bad_request` | No `booking_id` (`booking_id is required`). | Send it. | | `400` | `invalid_booking_id`, `invalid_json`, `invalid_body` | The id is not a valid token, or the body is not a JSON object. | Fix the request. | | `400` | `unsupported_supplier` | The booking is not an Amadeus, Travelport or Duffel booking. | Use `get_booking`. | | `404` | `booking_not_found` | No such booking for your agency. | Check the id. | ## Related - [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md) and [Get the ticket PDF](https://fx-port.com/docs/flights/get-ticket.md). - [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md): the `ticket_number` of an ACD traveler is `null` until the airline returns it. --- # ACD passport uploads > Upload, add and replace the passport copies that an ACD booking needs: one for every traveler of the booking, sent after the booking with upload_acd_documents. Endpoint: `POST /api/v1/flights/upload_acd_documents` Source: https://fx-port.com/docs/flights/acd-documents Some flight bookings are **ACD bookings**. FX-Port needs **one passport copy for every traveler on the booking**, and you send the copies after the booking exists. This page is the reference for the two endpoints that do it: `get_acd_documents` tells you what a booking needs and what it already has, `upload_acd_documents` adds or replaces the files. | Method | Path | Permission | Body | Description | |---|---|---|---|---| | `POST` | `/api/v1/flights/get_acd_documents` | Read | JSON | The ACD state of a booking: how many passports are needed, which travelers have one, the upload limits | | `POST` | `/api/v1/flights/upload_acd_documents` | **Read+Write** | `multipart/form-data` | Add one, several or all passports, or replace passports that were already sent | Both are in the default rate group (6,000 requests per minute per key). They answer only for bookings of your agency and of the environment of your key: a sandbox key never sees a live booking, and **a booking of another agency looks exactly like a booking that does not exist** (`404 BOOKING_NOT_FOUND`, see [Which bookings you can reach](#which-bookings-you-can-reach)). ## What ACD means for a booking An ACD booking is a booking made with an **ACD fare**. Only the Amadeus Algeria supplier (`amadeus_aqc_dz_1`) sells them. The price of such a fare is adjusted by a rule (see [Flight pricing](https://fx-port.com/docs/flights/pricing.md#the-acd-block-amadeus-algeria)), and the booking is completed with passports: - **One passport per traveler on the booking.** The travelers are the passengers stored on the booking, so the list exists as soon as the booking exists. No ticket is needed: `ticket_number` is `null` for a hold, and for an instant booking until the airline returns it. - **Upload at any time after the booking was created**, also while it is still a hold ([Hold bookings](#hold-bookings)). A booking that is cancelled, failed, voided, refunded or expired refuses uploads (`BOOKING_NOT_ELIGIBLE`). - **The set is complete** when every traveler has a passport. The booking then becomes `acd_submitted: true`, and for an issued booking in the live environment FX-Port's ACD team is told by e-mail ([Notifications](#notifications)). - **A wrong or unreadable scan is replaced** with `replace=true`. FX-Port keeps the replaced file and tells the ACD team about the change. - Duffel bookings are never ACD bookings: their offers have no `acd` key, and `get_acd_documents` answers `acd_required: false`. ## Recognise an ACD booking The flag has several spellings, one for each step of the booking flow: | Where | Field | For an ACD booking | |---|---|---| | Search offers ([`get_flights`](https://fx-port.com/docs/flights/search.md)) and priced offers ([`price_flight`](https://fx-port.com/docs/flights/price.md)) | `pricing.acd.applied` | `true`. `false` on the other Amadeus Algeria offers; Duffel offers have no `acd` key. | | The same offers | `requiresACD` | `true`. Top level of the offer, **present only when true**. | | [`book_flight`](https://fx-port.com/docs/flights/book.md) and [`pay_order`](https://fx-port.com/docs/flights/pay-order.md) answers | `pricing.acd.applied`, `requiresACD` | The same two fields, copied from the priced offer. | | [`get_booking`](https://fx-port.com/docs/flights/get-booking.md) | `booking.require_acd` | `true`. Twins: `booking.acd_required`, `booking.requires_acd`. | | [Account history](https://fx-port.com/docs/account/history.md) and [booking detail](https://fx-port.com/docs/account/booking-detail.md) | `requireAcd` and `record.require_acd` | `true` | | `get_acd_documents` | `acd.acd_required` | `true`. The value to trust before you upload: it is `false` when FX-Port waived ACD for the booking (`acd.acd_waived`). | > [!TIP] > Decide with `get_acd_documents`: upload when `acd.acd_required` is `true` **and** `acd.can_upload` is `true`. Take the traveler ids from `acd.travelers` and `acd.missing_traveler_ids`: that list is the one the upload is checked against. ## Test it in the sandbox ACD fares exist only on `amadeus_aqc_dz_1`, and in the sandbox a search from **CDG to ALG** on that supplier returns **only** ACD fares: all 62 offers of a search on 2026-11-01 had `pricing.acd.applied: true`, and so did every offer of every earlier run (38 to 62 offers). Five calls give you a booking that needs passports. > [!WARNING] > Sandbox uploads are really stored, and the file URL that comes back is **public** ([Security: file links are public](#security-file-links-are-public)). Use fake files in the sandbox, never the passport of a real person. The files below are a valid 1×1 PNG and a minimal PDF: FX-Port checks the content of a file, not who it belongs to. **Step 1: Search CDG → ALG on the ACD supplier** ~~~bash curl --request POST 'https://api.fx-port.com/api/v1/get_flights' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --header 'Content-Type: application/json' \ --compressed \ --data '{ "origin": "CDG", "destination": "ALG", "departure_date": "2026-12-20", "cabin_class": "economy", "passengers": { "adults": 1 }, "supplier_id": "amadeus_aqc_dz_1" }' ~~~ Use a date 30 to 60 days ahead. Every offer carries the flag: ~~~json { "id": "fx_offer_3cb9ee1c", "requiresACD": true, "pricing": { "agencyCurrency": "DZD", "finalPrice": 52078.03, "acd": { "applied": true, "scenario": "B", "...": "..." } } } ~~~ Other routes also give ACD fares: a departure from Algeria to some countries does (ALG → JFK: 146 of 146 offers on 2026-11-01), most other Algerian routes do not (ALG → CDG: 1 offer of 90, a mixed-carrier fare). In production, read `pricing.acd.applied` of the offer you book. In a search that also covers Duffel (no `supplier_id`), only the Amadeus Algeria offers have it. **Step 2: Price the offer** Send the `searchId` of the supplier entry and the `id` of the offer to [`price_flight`](https://fx-port.com/docs/flights/price.md) (`search_id`, `offer_id`). The priced offer has `pricing.acd` and `requiresACD` again. **Step 3: Book it** [`book_flight`](https://fx-port.com/docs/flights/book.md) with `"booking_type": "issue"` (instant) or `"hold"`, and `"send_email": false`. The answer says `"requiresACD": true`. Keep the PNR (`booking.pnr`): it is the `booking_id` of the ACD calls. ~~~json { "success": true, "booking": { "status": "confirmed", "pnr": "Y7HWMD", "type": "instant" }, "requiresACD": true, "pricing": { "acd": { "applied": true, "scenario": "B", "...": "..." }, "...": "..." } } ~~~ A hold of the cheapest offers can fail at the supplier (we saw 2 holds of 3 fail on 2026-11-01); book another offer, or use `issue`. Each failed booking leaves a `FAILED-…` record: see [the errors of the book call](https://fx-port.com/docs/flights/book.md#errors). **Step 4: Read the state** ~~~bash curl --request POST 'https://api.fx-port.com/api/v1/flights/get_acd_documents' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --header 'Content-Type: application/json' \ --data '{ "booking_id": "Y7HWMD" }' ~~~ Expect `acd_required: true`, `required_documents` equal to the number of passengers, `missing_traveler_ids: ["1"]` and `can_upload: true`. **Step 5: Create two fake files and upload them** ~~~bash echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC' | base64 -d > passport-1.png printf '%%PDF-1.4\n1 0 obj<>endobj\n2 0 obj<>endobj\n3 0 obj<>endobj\ntrailer<>\n%%%%EOF\n' > passport-2.pdf curl --request POST 'https://api.fx-port.com/api/v1/flights/upload_acd_documents' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --form booking_id=Y7HWMD \ --form traveler_ids=1 \ --form documents=@passport-1.png ~~~ Use your own PNR. The booking is now `acd_submitted: true`. The answer says `notification.reason: "sandbox_environment"` (an issued booking) or `"hold_booking"` (a hold): the sandbox never sends the e-mail. The second file is for a booking with two passengers: [the program examples](#upload-examples) send both. ## How it works **Step 1** **Check what is needed** Call `get_acd_documents` with the booking. The response lists every traveler of the booking with a `traveler_id`, a `status` (`missing` or `uploaded`) and the ticket number when there is one. Each traveler needs **one passport**, so `required_documents` is the number of travelers. **Step 2** **Upload passports** Send `upload_acd_documents` as `multipart/form-data`. You can send **one passport**, **several passports** or **all of them at once**. Each file is linked to its traveler through `traveler_ids`. **Step 3** **Submission completes automatically** When every traveler has a passport, the booking switches to `acd_submitted: true` and FX-Port's ACD team is notified (see [Notifications](#notifications) for when the e-mail is sent). If you send the passports over several requests, you do not need to do anything else. **Step 4** **Replace a passport if needed** To correct a wrong or unreadable scan, upload the new file for that traveler with `replace=true`. The new file becomes the current passport, the old one is kept in the history, and the ACD team is told about the change. ## POST /api/v1/flights/get_acd_documents **Permission:** Read. The body is JSON. ### Request body | Field | Type | Required | Description | |---|---|---|---| | `booking_id` | string | Yes | The PNR, the supplier order id or the FX-Port booking UUID. `order_id` and `pnr` are accepted as other names of the same field. Letters, digits and `- _ . : = ~`, up to 128 characters. | Legacy names: `bookingId` and `orderId` still work and are answered with a `deprecated_field` warning. If `booking_id` is sent together with `order_id` or `pnr` and the values differ, `booking_id` wins and an `ignored_field` warning names the dropped one. **Request** ```bash curl https://api.fx-port.com/api/v1/flights/get_acd_documents \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7IA34" }' ``` ### Response A booking with three travelers, one passport uploaded. Real response of 2026-11-01, traveler data replaced by a fake name, bucket address replaced: ~~~json { "environment": "sandbox", "success": true, "requestId": "8201f6f6", "booking_id": "Y7IA34", "acd": { "booking_id": "Y7IA34", "acd_required": true, "acd_waived": false, "acd_submitted": false, "acd_paid": false, "complete": false, "can_upload": true, "blocked_reason": null, "required_documents": 3, "uploaded_documents": 1, "missing_documents": 2, "missing_traveler_ids": ["1", "3"], "travelers": [ { "traveler_id": "1", "traveler_name": "JANE DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null }, { "traveler_id": "2", "traveler_name": "JOHN DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "uploaded", "document": { "filename": "john-passport.pdf", "content_type": "application/pdf", "size_bytes": 482113, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/2/20261101142618_ec9632ab.pdf", "uploaded_at": "2026-11-01T14:26:18.120000+00:00", "replaced_count": 0 } }, { "traveler_id": "3", "traveler_name": "JAMES DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null } ], "unassigned_documents": [], "revision": 1, "limits": { "accepted_formats": ["avif", "bmp", "gif", "heif", "jp2", "jpeg", "jxl", "pdf", "png", "tiff", "webp"], "accepted_extensions": ["avif", "bmp", "dib", "gif", "heic", "heif", "j2k", "jfif", "jp2", "jpeg", "jpg", "jpx", "jxl", "pdf", "pjp", "pjpeg", "png", "tif", "tiff", "webp"], "max_file_size_bytes": 10485760, "max_request_size_bytes": 26214400, "max_documents_per_request": 3 } } } ~~~ The top-level `booking_id` is the PNR, whatever identifier you sent (twin: `pnr`). `requestId` has the twin `request_id`. Every other key is already `snake_case`. ### `acd` fields | Field | Type | Description | |---|---|---| | `booking_id` | string | The PNR of the booking (twin: `pnr`). | | `acd_required` | boolean | `true` when the booking needs ACD passports and FX-Port did not waive them. | | `acd_waived` | boolean | `true` when FX-Port marked ACD as not required for this booking. No upload is accepted then. You cannot set it. | | `acd_submitted` | boolean | `true` once every traveler has a passport. Never `null`: `false` before. Also `true` for a hold that has a complete set. | | `acd_paid` | boolean | `true` once FX-Port's team has marked the ACD amount of the booking as paid. You cannot set it. | | `complete` | boolean | Every required passport is uploaded. Always `false` when ACD is not required. | | `can_upload` | boolean | Whether `upload_acd_documents` accepts files for this booking right now. | | `blocked_reason` | string or null | Why `can_upload` is `false`: see [`blocked_reason`](#blocked_reason-values). `null` otherwise. | | `required_documents` | integer | The number of passports needed: one for each traveler of the booking. `0` when ACD is not required. | | `uploaded_documents`, `missing_documents` | integer | Counts toward `required_documents`. | | `missing_traveler_ids` | string[] | The travelers who still need a passport. Use these values in `traveler_ids`. | | `travelers[]` | object[] | Every traveler of the booking, listed **even when ACD is not required**. | | `unassigned_documents[]` | object[] | Files that FX-Port holds for the booking but could not match to a traveler: `{ "download_url" }`. They are kept, but they do not count as a traveler's passport. | | `revision` | integer | Increases by one with each upload that stored a file. `0` for a booking without any. | | `limits` | object | The upload limits: see below. Read them instead of hardcoding them. | ### `travelers[]` fields | Field | Type | Description | |---|---|---| | `traveler_id` | string | The id to send in `traveler_ids`. It is the id of the passenger on the booking: `"1"`, `"2"`, `"3"` (position) for Amadeus Algeria, `pas_...` for Duffel. | | `traveler_name` | string | The name on the booking. | | `traveler_type` | string | The passenger type, for example `ADULT`. | | `ticket_number` | string or null | The e-ticket number. **`null` until the airline returns one**: always for a hold, and for an instant booking whose number has not arrived (the sandbox instant ACD booking in these examples never got one). Ask [`get_ticket_number`](https://fx-port.com/docs/flights/get-ticket-number.md) to fetch it. | | `status` | string | `uploaded` or `missing`. | | `document` | object or null | The current passport file; `null` while the traveler is `missing`. | | `document.filename` | string | The file name you sent. | | `document.content_type` | string | The type FX-Port detected from the content (for example `application/pdf`, `image/jpeg`), not the one you sent. | | `document.size_bytes` | integer | The size of the stored file. | | `document.download_url` | string | A **public** address of the file. Treat it as sensitive: see [Security](#security-file-links-are-public). | | `document.uploaded_at` | string | ISO 8601, UTC, with `+00:00`. | | `document.replaced_count` | integer | How many times the passport was replaced. | ### `limits` fields | Field | Value | |---|---| | `accepted_formats` | The 11 formats (see [Accepted files](#accepted-files)). | | `accepted_extensions` | The 20 extensions of those formats. | | `max_file_size_bytes` | `10485760` (10 MiB). | | `max_request_size_bytes` | `26214400` (25 MiB): all files of a request together. | | `max_documents_per_request` | The number of travelers of the booking. | ### A booking that does not need ACD `get_acd_documents` does **not** fail for a booking without ACD: it answers `200`, lists the travelers with `status: "missing"` and says why you cannot upload. Only `upload_acd_documents` answers `409 ACD_NOT_REQUIRED`. Real response of 2026-11-01 for an Amadeus Algeria booking without ACD (`limits` shortened): ~~~json { "environment": "sandbox", "success": true, "requestId": "8b5b5c52", "booking_id": "Y7HKJM", "acd": { "booking_id": "Y7HKJM", "acd_required": false, "acd_waived": false, "acd_submitted": false, "acd_paid": false, "complete": false, "can_upload": false, "blocked_reason": "ACD_NOT_REQUIRED", "required_documents": 0, "uploaded_documents": 0, "missing_documents": 0, "missing_traveler_ids": [], "travelers": [ { "traveler_id": "1", "traveler_name": "JOHN SMITH", "traveler_type": "ADULT", "ticket_number": "124-2446977395", "status": "missing", "document": null } ], "unassigned_documents": [], "revision": 0, "limits": { "max_file_size_bytes": 10485760, "max_request_size_bytes": 26214400, "max_documents_per_request": 1, "...": "..." } } } ~~~ ### `blocked_reason` values | Value | Meaning | Reproduce in the sandbox | |---|---|---| | `ACD_NOT_REQUIRED` | The booking has no ACD (every Duffel booking, most Algerian routes). | Yes: any booking without ACD. | | `ACD_WAIVED` | FX-Port marked ACD as not required for this booking. | No: an FX-Port setting. | | `BOOKING_NOT_ELIGIBLE` | The status of the booking is `cancelled`, `failed`, `voided`, `refunded` or `expired`. | Not run: cancel an ACD hold with [`cancel_order`](https://fx-port.com/docs/flights/cancel-order.md). | | `TRAVELERS_NOT_AVAILABLE` | The booking has no traveler data, so files cannot be matched. Contact support. | No. | The order of the checks is the order of the table: a booking without ACD is `ACD_NOT_REQUIRED` even if it is also cancelled. ### Errors of get_acd_documents `MISSING_BOOKING_ID`, `INVALID_BOOKING_ID`, `INVALID_JSON`, `INVALID_BODY` (400), `BOOKING_NOT_FOUND` (404), `INTERNAL_ERROR` (500): see [Error codes](#error-codes). The authentication errors are described in [Errors](https://fx-port.com/docs/errors.md). ## POST /api/v1/flights/upload_acd_documents **Permission:** Read+Write. A Read key is refused with `403 read_write_permission_required` before anything is read. The body is `multipart/form-data`: a JSON body is refused (`415 UNSUPPORTED_MEDIA_TYPE`). ### Request (`multipart/form-data`) | Field | Type | Required | Description | |---|---|---|---| | `booking_id` | text | Yes | The PNR, the supplier order id or the FX-Port booking UUID. `order_id` and `pnr` are accepted as other names. | | `traveler_ids` | text | Yes | The travelers the files belong to, **in the same order as `documents`**. Send the field once for each file, or one comma-separated value (`1,2`), or a JSON array as text (`["1","2"]`). Spaces and empty entries are dropped. A traveler may appear once per request. | | `documents` | file, repeated | Yes | One passport file for each traveler. The field name must be `documents` (the other names below are accepted as legacy). | | `replace` | text | No | `true` to overwrite travelers that already have a passport. `true`, `1`, `yes`, `on` and `false`, `0`, `no`, `off` in any letter case; empty means `false`; anything else is `400 INVALID_REPLACE_FLAG`. Default `false`. | ### Rules of the multipart request - **Matched by order.** The first `traveler_ids` value goes with the first `documents` file, the second with the second, and so on. The two lists must have the same length (`DOCUMENT_COUNT_MISMATCH`) and a traveler can appear once per request (`DUPLICATE_TRAVELER_ID`). - **Fewer files than travelers is fine.** Send what you have; the booking stays `acd_submitted: false` until the last missing traveler is uploaded. More files than travelers on the booking is `TOO_MANY_DOCUMENTS`. - **`replace` is request-wide.** With `replace=true`, travelers who already have a passport get it replaced (`status: "replaced"`) and travelers who have none are added (`status: "uploaded"`), so you can do both in one request. - **Do not set the `Content-Type` header yourself** in an HTTP library: it must carry the multipart boundary that the library creates. - **The content decides.** The file name and the `Content-Type` of a part are not used to accept a file ([Accepted files](#accepted-files)). - **Limits of the request:** at most 40 files, 64 form fields and 64 KiB for one text field, and 10 MiB per file and 25 MiB for all files together ([Size limits](#size-limits)). - **Nothing is stored unless everything is valid.** If any file is rejected, no file of the request is stored and the booking does not change. ### Legacy names These spellings still work and are answered with a `deprecated_field` warning. Send the name in the second column. | Legacy name | Use instead | |---|---| | `bookingId`, `orderId` | `booking_id` | | `travelerIds`, `travelerId`, `traveler_id` | `traveler_ids` | | `files`, `file`, `document`, `documents[]` | `documents` | `order_id` and `pnr` are other names of `booking_id` that are not deprecated. When the form holds several spellings of the booking with different values, the first one of this list wins and an `ignored_field` warning names each dropped one: `booking_id`, `bookingId`, `order_id`, `orderId`, `pnr`. The same booking twice, or an empty value, gives no `ignored_field` warning. ### Upload examples The same request in every language of the site. The program sends two passports for one booking (traveler 1 and traveler 2, matched by order) and reads the answer the way the sections below describe it. Put your PNR in `BOOKING_ID`, the `traveler_id` values of `get_acd_documents` in `PASSPORTS` and the paths of your files. For the sandbox, create `passport-1.png` and `passport-2.pdf` as shown in [Test it in the sandbox](#test-it-in-the-sandbox). **Upload the passports of an ACD booking: one file per traveler, matched by order** **cURL** ```bash # traveler_ids and documents are matched by order: the first id goes with the first file. # Do not set Content-Type yourself: curl sends multipart/form-data with its own boundary. curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \ --request POST "https://api.fx-port.com/api/v1/flights/upload_acd_documents" \ --header "Authorization: Bearer fxp_test_YOUR_KEY" \ --form booking_id=Y7IA34 \ --form traveler_ids=1 \ --form traveler_ids=2 \ --form documents=@passport-1.png \ --form documents=@passport-2.pdf \ --form replace=false ``` **Node.js** ```javascript // Node.js 18 or later. fetch, FormData and Blob are built in: no library is needed. import { readFile } from "node:fs/promises"; import { basename } from "node:path"; const API_KEY = process.env.FXPORT_API_KEY ?? "fxp_test_YOUR_KEY"; const BASE_URL = "https://api.fx-port.com/api/v1"; const BOOKING_ID = "Y7IA34"; const REPLACE = "false"; // "true" overwrites a passport that was already uploaded // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. const PASSPORTS = [ ["1", "passport-1.png"], ["2", "passport-2.pdf"], ]; const form = new FormData(); form.append("booking_id", BOOKING_ID); for (const [travelerId] of PASSPORTS) form.append("traveler_ids", travelerId); for (const [, path] of PASSPORTS) form.append("documents", new Blob([await readFile(path)]), basename(path)); form.append("replace", REPLACE); const response = await fetch(BASE_URL + "/flights/upload_acd_documents", { method: "POST", // No Content-Type: fetch sends multipart/form-data with its own boundary. headers: { Authorization: "Bearer " + API_KEY }, body: form, }); const body = await response.json(); console.log("HTTP", response.status); if (response.status === 200 || response.status === 207) { // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers. console.log(body.message); for (const item of body.results) console.log(item.index, item.traveler_id, item.filename, item.status); console.log("still missing:", body.acd.missing_traveler_ids); console.log("notification:", body.notification); } else { // Nothing was saved. The code says why; item problems are in results. console.log(body.code, "-", body.message); for (const item of body.results ?? []) console.log(item.index, item.traveler_id, item.status, item.error?.code); } ``` **Python** ```python import os import requests API_KEY = os.environ.get("FXPORT_API_KEY", "fxp_test_YOUR_KEY") BASE_URL = "https://api.fx-port.com/api/v1" BOOKING_ID = "Y7IA34" REPLACE = "false" # "true" overwrites a passport that was already uploaded # One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. PASSPORTS = [("1", "passport-1.png"), ("2", "passport-2.pdf")] response = requests.post( BASE_URL + "/flights/upload_acd_documents", # No Content-Type: requests sends multipart/form-data with its own boundary. headers={"Authorization": "Bearer " + API_KEY}, # A list value becomes a repeated field: traveler_ids=1, traveler_ids=2. data={"booking_id": BOOKING_ID, "traveler_ids": [t for t, _ in PASSPORTS], "replace": REPLACE}, # The same field name, once per file; the order matches traveler_ids. files=[("documents", (path, open(path, "rb"))) for _, path in PASSPORTS], timeout=120, ) body = response.json() print("HTTP", response.status_code) if response.status_code in (200, 207): # 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers. print(body["message"]) for item in body["results"]: print(item["index"], item["traveler_id"], item["filename"], item["status"]) print("still missing:", body["acd"]["missing_traveler_ids"]) print("notification:", body["notification"]) else: # Nothing was saved. The code says why; item problems are in results. print(body["code"], "-", body["message"]) for item in body.get("results", []): print(item["index"], item["traveler_id"], item["status"], item.get("error", {}).get("code")) ``` **PHP** ```php true, CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $apiKey], // An array body makes cURL send multipart/form-data with its own boundary. CURLOPT_POSTFIELDS => [ "booking_id" => $bookingId, "traveler_ids" => $travelerId, "documents" => new CURLFile($path), "replace" => $replace, ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, ]); $raw = curl_exec($curl); if ($raw === false) { fwrite(STDERR, curl_error($curl) . "\n"); exit(1); } $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $body = json_decode($raw, true); echo "traveler $travelerId: HTTP $status\n"; if ($status === 200 || $status === 207) { echo " " . $body["message"] . "\n"; echo " still missing: " . json_encode($body["acd"]["missing_traveler_ids"]) . "\n"; } else { // Nothing was saved for this traveler. The code says why. echo " " . $body["code"] . " - " . $body["message"] . "\n"; } } ``` **Go** ```go package main import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "time" ) type passport struct{ travelerID, path string } func check(err error) { if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func main() { apiKey := os.Getenv("FXPORT_API_KEY") if apiKey == "" { apiKey = "fxp_test_YOUR_KEY" } bookingID := "Y7IA34" replace := "false" // "true" overwrites a passport that was already uploaded // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. passports := []passport{{"1", "passport-1.png"}, {"2", "passport-2.pdf"}} var payload bytes.Buffer form := multipart.NewWriter(&payload) check(form.WriteField("booking_id", bookingID)) for _, p := range passports { check(form.WriteField("traveler_ids", p.travelerID)) } for _, p := range passports { content, err := os.ReadFile(p.path) check(err) part, err := form.CreateFormFile("documents", filepath.Base(p.path)) check(err) _, err = part.Write(content) check(err) } check(form.WriteField("replace", replace)) check(form.Close()) request, err := http.NewRequest("POST", "https://api.fx-port.com/api/v1/flights/upload_acd_documents", &payload) check(err) request.Header.Set("Authorization", "Bearer "+apiKey) request.Header.Set("Content-Type", form.FormDataContentType()) // multipart/form-data; boundary=... client := &http.Client{Timeout: 120 * time.Second} response, err := client.Do(request) check(err) defer response.Body.Close() raw, err := io.ReadAll(response.Body) check(err) var body map[string]any check(json.Unmarshal(raw, &body)) fmt.Println("HTTP", response.StatusCode) if response.StatusCode == 200 || response.StatusCode == 207 { // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers. fmt.Println(body["message"]) acd, _ := body["acd"].(map[string]any) fmt.Println("still missing:", acd["missing_traveler_ids"]) fmt.Println("notification:", body["notification"]) } else { // Nothing was saved. The code says why; item problems are in results. fmt.Println(body["code"], "-", body["message"]) } } ``` **Java** ```java import java.io.ByteArrayOutputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.List; import java.util.UUID; // Java 11 or later: java FxPortExample.java. The JDK has no JSON parser, so the answer is printed as it is; // use Jackson or Gson to read it. public class FxPortExample { public static void main(String[] args) throws Exception { String apiKey = System.getenv().getOrDefault("FXPORT_API_KEY", "fxp_test_YOUR_KEY"); String bookingId = "Y7IA34"; String replace = "false"; // "true" overwrites a passport that was already uploaded // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. List passports = List.of(new String[] {"1", "passport-1.png"}, new String[] {"2", "passport-2.pdf"}); String boundary = "fxport" + UUID.randomUUID().toString().replace("-", ""); ByteArrayOutputStream body = new ByteArrayOutputStream(); field(body, boundary, "booking_id", bookingId); for (String[] passport : passports) field(body, boundary, "traveler_ids", passport[0]); for (String[] passport : passports) { Path file = Path.of(passport[1]); write(body, "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"documents\"; filename=\"" + file.getFileName() + "\"\r\n" + "Content-Type: application/octet-stream\r\n\r\n"); body.write(Files.readAllBytes(file)); write(body, "\r\n"); } field(body, boundary, "replace", replace); write(body, "--" + boundary + "--\r\n"); HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.fx-port.com/api/v1/flights/upload_acd_documents")) .timeout(Duration.ofSeconds(120)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "multipart/form-data; boundary=" + boundary) .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray())) .build(); HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); // 200: every passport was saved. 207: some were saved and some were not. 4xx and 5xx: nothing was saved. System.out.println("HTTP " + response.statusCode()); System.out.println(response.body()); } private static void field(ByteArrayOutputStream out, String boundary, String name, String value) throws Exception { write(out, "--" + boundary + "\r\n" + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n"); } private static void write(ByteArrayOutputStream out, String text) throws Exception { out.write(text.getBytes(StandardCharsets.UTF_8)); } } ``` **C#** ```csharp // .NET 8 or later: dotnet run using System.Net.Http.Headers; using System.Text.Json; var apiKey = Environment.GetEnvironmentVariable("FXPORT_API_KEY") ?? "fxp_test_YOUR_KEY"; const string BaseUrl = "https://api.fx-port.com/api/v1"; const string BookingId = "Y7IA34"; const string Replace = "false"; // "true" overwrites a passport that was already uploaded // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. var passports = new[] { (TravelerId: "1", FilePath: "passport-1.png"), (TravelerId: "2", FilePath: "passport-2.pdf") }; using var form = new MultipartFormDataContent(); form.Add(new StringContent(BookingId), "booking_id"); foreach (var passport in passports) form.Add(new StringContent(passport.TravelerId), "traveler_ids"); foreach (var passport in passports) form.Add(new ByteArrayContent(await File.ReadAllBytesAsync(passport.FilePath)), "documents", Path.GetFileName(passport.FilePath)); form.Add(new StringContent(Replace), "replace"); // No Content-Type header: MultipartFormDataContent sends multipart/form-data with its own boundary. using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) }; http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); using var response = await http.PostAsync(BaseUrl + "/flights/upload_acd_documents", form); var status = (int)response.StatusCode; using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); var body = document.RootElement; Console.WriteLine("HTTP " + status); if (status == 200 || status == 207) { // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers. Console.WriteLine(body.GetProperty("message").GetString()); foreach (var item in body.GetProperty("results").EnumerateArray()) Console.WriteLine($"{item.GetProperty("index")} {item.GetProperty("traveler_id")} {item.GetProperty("filename")} {item.GetProperty("status")}"); Console.WriteLine("still missing: " + body.GetProperty("acd").GetProperty("missing_traveler_ids")); Console.WriteLine("notification: " + body.GetProperty("notification")); } else { // Nothing was saved. The code says why; item problems are in results. Console.WriteLine(body.GetProperty("code").GetString() + " - " + body.GetProperty("message").GetString()); } ``` **Swift** ```swift // macOS 12 / iOS 15 or later: swift main.swift import Foundation let apiKey = ProcessInfo.processInfo.environment["FXPORT_API_KEY"] ?? "fxp_test_YOUR_KEY" let baseURL = "https://api.fx-port.com/api/v1" let bookingID = "Y7IA34" let replace = "false" // "true" overwrites a passport that was already uploaded // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. let passports: [(travelerID: String, path: String)] = [("1", "passport-1.png"), ("2", "passport-2.pdf")] let boundary = "fxport-" + UUID().uuidString var payload = Data() func append(_ text: String) { payload.append(Data(text.utf8)) } func field(_ name: String, _ value: String) { append("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n") } field("booking_id", bookingID) for passport in passports { field("traveler_ids", passport.travelerID) } for passport in passports { let file = URL(fileURLWithPath: passport.path) append("--\(boundary)\r\nContent-Disposition: form-data; name=\"documents\"; filename=\"\(file.lastPathComponent)\"\r\nContent-Type: application/octet-stream\r\n\r\n") payload.append(try Data(contentsOf: file)) append("\r\n") } field("replace", replace) append("--\(boundary)--\r\n") var request = URLRequest(url: URL(string: baseURL + "/flights/upload_acd_documents")!) request.httpMethod = "POST" request.timeoutInterval = 120 request.setValue("Bearer " + apiKey, forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = payload let (data, response) = try await URLSession.shared.data(for: request) let status = (response as! HTTPURLResponse).statusCode let body = try JSONSerialization.jsonObject(with: data) as! [String: Any] print("HTTP", status) if status == 200 || status == 207 { // 200: every passport was saved. 207: some were saved and some were not; retry only the missing travelers. print(body["message"] ?? "") for case let item as [String: Any] in body["results"] as? [Any] ?? [] { print(item["index"] ?? "", item["traveler_id"] ?? "", item["filename"] ?? "", item["status"] ?? "") } let acd = body["acd"] as? [String: Any] print("still missing:", acd?["missing_traveler_ids"] ?? []) print("notification:", body["notification"] ?? "") } else { // Nothing was saved. The code says why; item problems are in results. print(body["code"] ?? "", "-", body["message"] ?? "") } ``` **Kotlin** ```kotlin import java.io.ByteArrayOutputStream import java.net.URI import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.file.Files import java.nio.file.Path import java.time.Duration import java.util.UUID // Kotlin on Java 11 or later. The JDK has no JSON parser, so the answer is printed as it is; // use kotlinx.serialization or Jackson to read it. fun main() { val apiKey = System.getenv("FXPORT_API_KEY") ?: "fxp_test_YOUR_KEY" val bookingId = "Y7IA34" val replace = "false" // "true" overwrites a passport that was already uploaded // One entry per traveler: the traveler_id from get_acd_documents and that traveler's passport file. val passports = listOf("1" to "passport-1.png", "2" to "passport-2.pdf") val boundary = "fxport" + UUID.randomUUID().toString().replace("-", "") val body = ByteArrayOutputStream() fun write(text: String) = body.write(text.toByteArray()) fun field(name: String, value: String) = write("--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n") field("booking_id", bookingId) for ((travelerId, _) in passports) field("traveler_ids", travelerId) for ((_, path) in passports) { val file = Path.of(path) write("--$boundary\r\nContent-Disposition: form-data; name=\"documents\"; filename=\"${file.fileName}\"\r\nContent-Type: application/octet-stream\r\n\r\n") body.write(Files.readAllBytes(file)) write("\r\n") } field("replace", replace) write("--$boundary--\r\n") val request = HttpRequest.newBuilder(URI.create("https://api.fx-port.com/api/v1/flights/upload_acd_documents")) .timeout(Duration.ofSeconds(120)) .header("Authorization", "Bearer $apiKey") .header("Content-Type", "multipart/form-data; boundary=$boundary") .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray())) .build() val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()) // 200: every passport was saved. 207: some were saved and some were not. 4xx and 5xx: nothing was saved. println("HTTP " + response.statusCode()) println(response.body()) } ``` Replace a passport that was already uploaded: the same request with `replace=true` (`--form replace=true`, or `REPLACE = "true"` in the programs). ~~~bash curl --request POST 'https://api.fx-port.com/api/v1/flights/upload_acd_documents' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --form booking_id=Y7IA34 \ --form traveler_ids=2 \ --form documents=@john-passport-rescan.jpg \ --form replace=true ~~~ - Without `replace=true`, a file for a traveler who already has a passport is rejected with `409 DOCUMENT_ALREADY_UPLOADED` and **nothing is changed**. This prevents accidental overwrites. - With `replace=true`, the new file becomes the current passport, the old one is kept in the history, `status` is `replaced` and `document.replaced_count` goes up by one. - If the booking was already fully submitted, the ACD team is told about the change (`notification.type: "updated"`). The booking stays `acd_submitted: true`. ### Response: success (`200`) Every file was stored. Real response of 2026-11-01 for the last two travelers of a three-traveler booking (fake names, bucket address replaced): ~~~json { "environment": "sandbox", "success": true, "status": "success", "requestId": "58bfcf4a", "booking_id": "Y7IA34", "message": "2 documents saved.", "summary": { "received": 2, "uploaded": 2, "replaced": 0, "failed": 0 }, "results": [ { "index": 0, "traveler_id": "1", "traveler_name": "JANE DOE", "filename": "passport-1.png", "status": "uploaded", "content_type": "image/png", "size_bytes": 69, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/1/20261101142620_28e115c8.png" }, { "index": 1, "traveler_id": "3", "traveler_name": "JAMES DOE", "filename": "passport-3.pdf", "status": "uploaded", "content_type": "application/pdf", "size_bytes": 191, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/3/20261101142620_4194315e.pdf" } ], "acd": { "booking_id": "Y7IA34", "acd_required": true, "acd_submitted": true, "complete": true, "can_upload": true, "required_documents": 3, "uploaded_documents": 3, "missing_documents": 0, "missing_traveler_ids": [], "revision": 2, "...": "same shape as get_acd_documents" }, "notification": { "sent": false, "type": "submitted", "skipped": true, "reason": "sandbox_environment" }, "warnings": [] } ~~~ | Field | Description | |---|---| | `status` | `success` (every file stored) or `partial_success` (some stored, some failed: HTTP `207`). | | `message` | A sentence: `1 document saved.`, `2 documents saved.`. | | `summary` | The counts of this request: `received`, `uploaded` (new passports), `replaced`, `failed`. | | `results[]` | One entry for each file, in the order you sent them: see below. | | `acd` | The state of the booking **after** this request, in the shape of `get_acd_documents`. | | `notification` | Whether FX-Port's ACD team was notified: see [Notifications](#notifications). | | `warnings[]` | Problems that did not stop the upload, such as `EMAIL_SEND_FAILED`, and notices about your request (`deprecated_field`). Your files are saved. | ### `results[]` fields | Field | Description | |---|---| | `index` | The position of the file in your request, from 0. | | `traveler_id`, `traveler_name` | The traveler of this file. `traveler_name` is `null` when the id is not on the booking. | | `filename` | The file name you sent (`document-` when the part had none). | | `status` | `uploaded` (a new passport), `replaced` (a passport that existed was replaced), `failed` (this file has a problem, see `error`), `skipped` (this file is valid, but another file of the request failed, so it was not processed). | | `content_type`, `size_bytes`, `download_url` | Only for `uploaded` and `replaced`: the detected type, the size and the **public** address of the stored file. | | `error` | Only for `failed`: `{ "code", "message" }` and, depending on the code, `expected_traveler_ids`, `accepted_extensions`, `max_file_size_bytes` or `reason` (`storage_error`, for `STORAGE_UPLOAD_FAILED`). | ### Response: partial success (`207`) The files are validated together first. If they are valid, they are stored one by one. If the storage fails for some of them, the files that were stored are kept and the answer is **HTTP `207`** with `success: true` and `status: "partial_success"`. This cannot be provoked in the sandbox; the shape comes from the service code and its tests: ~~~json { "success": true, "status": "partial_success", "message": "1 of 2 documents saved; 1 failed — retry the failed travelers.", "summary": { "received": 2, "uploaded": 1, "replaced": 0, "failed": 1 }, "results": [ { "index": 0, "traveler_id": "1", "traveler_name": "JANE DOE", "filename": "passport-1.png", "status": "uploaded", "content_type": "image/png", "size_bytes": 69, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/1/20261101142620_28e115c8.png" }, { "index": 1, "traveler_id": "3", "traveler_name": "JAMES DOE", "filename": "passport-3.pdf", "status": "failed", "error": { "code": "STORAGE_UPLOAD_FAILED", "message": "The document could not be stored. Retry this traveler.", "reason": "storage_error" } } ], "acd": { "missing_traveler_ids": ["3"], "...": "..." }, "notification": { "sent": false, "skipped": true, "reason": "awaiting_remaining_documents" }, "warnings": [] } ~~~ Retry **only the travelers listed in `acd.missing_traveler_ids`**: their files were not saved, so `replace=true` is not needed. The `reason` of a failed storage is always the fixed text `storage_error`: the message of the storage system is never returned. A `207` has a second cause: another request stored a passport for the same traveler a moment before yours. That item is `failed` with `DOCUMENT_ALREADY_UPLOADED`. ### Response: failure (`4xx` / `5xx`) The whole request is checked first, so a failure lists every problem at once, **no file is stored and the booking does not change**. Real response of 2026-11-01 (two files, two different problems; `accepted_extensions` shortened): ~~~json { "environment": "sandbox", "success": false, "status": "failed", "error": "validation_failed", "code": "VALIDATION_FAILED", "message": "2 of 2 documents were rejected. Nothing was uploaded.", "results": [ { "index": 0, "traveler_id": "1", "traveler_name": "JANE DOE", "filename": "bad.pdf", "status": "failed", "error": { "code": "UNSUPPORTED_FILE_TYPE", "message": "'bad.pdf' is not a supported passport document. Upload a PDF or a JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000 or JPEG XL image.", "accepted_extensions": ["avif", "bmp", "dib", "..."] } }, { "index": 1, "traveler_id": "2", "traveler_name": "JOHN DOE", "filename": "empty.pdf", "status": "failed", "error": { "code": "EMPTY_FILE", "message": "Uploaded document 'empty.pdf' is empty." } } ], "acd": { "booking_id": "Y7IA34", "acd_required": true, "complete": false, "...": "the state, unchanged" }, "requestId": "34d61957" } ~~~ - **One problem for every rejected file**, or only one file: `code` and the HTTP status are that problem. With several files the message starts with the count: `2 documents were rejected: A passport is already uploaded for traveler '1'. Send replace=true to replace it.` - **Different problems:** `VALIDATION_FAILED`, with the status of the problems when they share one, `400` otherwise. Read `results[].error`. - Valid files of a failed request have `status: "skipped"`. Send them again with the corrected request. - **Request-shape errors** (missing field, count mismatch ...) carry only counters: `received_traveler_ids`, `received_documents`, `expected_travelers`, `duplicate_traveler_ids`. - A failure that depends on the booking (its state, too many files, the total size) also carries the current `acd` state, so you do not need another call. Real response of a booking that does not need ACD: ~~~json { "environment": "sandbox", "success": false, "status": "failed", "error": "acd_not_required", "code": "ACD_NOT_REQUIRED", "message": "This booking does not require ACD documents.", "acd": { "booking_id": "Y7HKJM", "acd_required": false, "can_upload": false, "blocked_reason": "ACD_NOT_REQUIRED", "...": "..." }, "requestId": "5b73272a" } ~~~ ### The order of the checks A request is checked in this order; the first problem found is the answer. This is what makes the same mistake answer the same code every time: 1. Authentication, permission, rate limit and size of the whole body (`413 request_too_large` above 26 MiB). 2. The body is multipart (`UNSUPPORTED_MEDIA_TYPE`, `INVALID_MULTIPART`). 3. `booking_id` (`MISSING_BOOKING_ID`, `INVALID_BOOKING_ID`), then `replace` (`INVALID_REPLACE_FLAG`). 4. `MISSING_TRAVELER_IDS` or `MISSING_DOCUMENTS`, then `DOCUMENT_COUNT_MISMATCH`, then `DUPLICATE_TRAVELER_ID`. 5. The booking is looked up among the bookings of your agency and of your environment (`BOOKING_NOT_FOUND`). A booking of another agency is not found: it answers exactly like an id that does not exist. 6. The state of the booking (`409`: `ACD_NOT_REQUIRED`, `ACD_WAIVED`, `BOOKING_NOT_ELIGIBLE`, `TRAVELERS_NOT_AVAILABLE`). **This comes before the files are looked at**: a booking without ACD answers `409` even for an unreadable file. 7. `TOO_MANY_DOCUMENTS`. 8. Each file, in order, and the first problem of a file wins: `UNKNOWN_TRAVELER_ID`, `DOCUMENT_ALREADY_UPLOADED` (unless `replace`), `EMPTY_FILE`, `FILE_TOO_LARGE`, `UNSUPPORTED_FILE_TYPE`. 9. `REQUEST_TOO_LARGE` (the files together are over 25 MiB). It wins over the problems of step 8. 10. If any file failed: nothing is stored; `VALIDATION_FAILED` or the common code. 11. Storage (`STORAGE_UNAVAILABLE`, `STORAGE_UPLOAD_FAILED`) and the write (`CONCURRENT_UPDATE`). ## Accepted files FX-Port checks the **actual content** of a file. The file name and the `Content-Type` you send are not used to decide whether a file is accepted. A text file renamed to `.pdf` is rejected with `UNSUPPORTED_FILE_TYPE`; a PDF named `passport.jpg` is accepted. The stored extension follows the detected format. | Format | Extensions | How it is recognised | |---|---|---| | PDF | `.pdf` | `%PDF-` in the first 1 KB | | JPEG | `.jpg`, `.jpeg`, `.jfif`, `.pjpeg`, `.pjp` | The JPEG signature | | PNG | `.png` | The PNG signature | | WebP | `.webp` | `RIFF` with `WEBP` | | HEIC / HEIF (iPhone photos) | `.heic`, `.heif` | An ISO media `ftyp` box with a HEIF brand | | AVIF | `.avif` | An ISO media `ftyp` box with an AVIF brand | | TIFF (scanners) | `.tif`, `.tiff` | The TIFF signature (classic and BigTIFF) | | BMP | `.bmp`, `.dib` | `BM` and a known DIB header | | GIF | `.gif` | `GIF87a` or `GIF89a` | | JPEG 2000 | `.jp2`, `.jpx`, `.j2k` | The JP2 signature or a J2K codestream | | JPEG XL | `.jxl` | The JPEG XL signature | SVG, Office documents, archives (ZIP) and every other type are **not** accepted. An empty file is `EMPTY_FILE`. ### Size limits | Limit | Value | Error when exceeded | |---|---|---| | Per file | **10 MiB** (10,485,760 bytes). A file of 10 MiB + 1 byte is refused. | `413 FILE_TOO_LARGE` | | All files of a request together | **25 MiB** (26,214,400 bytes) | `413 REQUEST_TOO_LARGE` | | The whole request body | 26 MiB | `413 request_too_large`, answered by the platform before the request is read | | Files per request | Up to the number of travelers of the booking, one file per traveler, and at most 40 | `400 TOO_MANY_DOCUMENTS`, `400 DUPLICATE_TRAVELER_ID`, `400 INVALID_MULTIPART` | The messages of the API say "MB"; the numbers are MiB (1,048,576 bytes). If a large group would go over 25 MiB, split the passports over several requests: each request adds to what was already uploaded. `get_acd_documents` returns the limits in `acd.limits`. ## Notifications When a passport set becomes complete, FX-Port's ACD team is told by e-mail. The e-mail goes to FX-Port, not to you or to the traveler; `notification` only reports what happened, and nothing has to be done with it. The API sends it from an upload only: paying a hold does not send it. | Situation | `notification` | Sandbox | |---|---|---| | The set is still incomplete after this request | `{ "sent": false, "skipped": true, "reason": "awaiting_remaining_documents" }` | Yes | | The set is complete on a **hold** (unpaid) booking | `{ "sent": false, "skipped": true, "reason": "hold_booking" }` | Yes | | The set became complete on an **issued** booking | Live: `{ "sent": true, "type": "submitted", "skipped": false }`. Sandbox: `{ "sent": false, "type": "submitted", "skipped": true, "reason": "sandbox_environment" }` | Yes (sandbox form) | | A passport was replaced on a booking that was already complete | Live: `{ "sent": true, "type": "updated", "skipped": false }`. Sandbox: `{ "sent": false, "type": "updated", "skipped": true, "reason": "sandbox_environment" }` | Yes (sandbox form) | | The e-mail could not be sent | `{ "sent": false, "type": "...", "error": "EMAIL_SEND_FAILED", "reason": "email_error" }` and a `warnings[]` entry `EMAIL_SEND_FAILED`. `reason` is a fixed text: the mail system's own message is never returned (quote the `requestId` to support). Your files are saved and the team still sees them in its queue. | No | The sandbox never sends the e-mail. Only the `type` key is missing from the first two forms. ## Hold bookings A hold has a traveler list and no tickets, and it accepts uploads: - `get_acd_documents` lists the travelers with `ticket_number: null`, and `can_upload` is `true`. - When the set is complete, `acd_submitted` becomes `true` **before payment**, and the answer says `notification.reason: "hold_booking"`: no e-mail is sent for an unpaid hold. - When you pay the hold with [`pay_order`](https://fx-port.com/docs/flights/pay-order.md) the files stay and the booking stays `acd_submitted: true`. The e-mail to the ACD team is sent from an upload on an issued booking: the first complete set (`submitted`) or the replacement of a passport of a complete set (`updated`). - A hold that is not paid in time is cancelled by FX-Port, and a hold that you cancel with [`cancel_order`](https://fx-port.com/docs/flights/cancel-order.md) is cancelled at once: the booking is then `cancelled` and refuses uploads (`BOOKING_NOT_ELIGIBLE`). ## Error codes Every error of these endpoints is a **flat** body: `success: false`, `status: "failed"`, `error` (the code in lower case), `code` (the code in upper case), `message` and the keys listed with the code, plus `requestId`. The unified error body of [Errors](https://fx-port.com/docs/errors.md) is added next to them: `errors[0].code` is the **lower-case** code (`acd_not_required`) and `errors[0].type` follows the status (`validation_error`, `not_found`, `conflict`, `internal_error`). The codes below are written in upper case, as in `code`. The last column says whether you can provoke the error in the sandbox: **Yes** (reproduced on 2026-11-01), **Not run** (should be reproducible, not run), **No** (cannot be provoked from outside; the shape is taken from the service code and its tests). | HTTP | `code` | When | What to do | Sandbox | |---|---|---|---|---| | `400` | `MISSING_BOOKING_ID` | No `booking_id` (a JSON field, or a form field for the upload). | Send it. | Yes: leave it out. | | `400` | `INVALID_BOOKING_ID` | `booking_id` is not an identifier: letters, digits and `- _ . : = ~`, up to 128 characters. | Send the PNR, order id or UUID as returned by `book_flight`. | Not run: send `A B`. | | `400` | `INVALID_JSON` | `get_acd_documents`: the body is not valid JSON. | Send one JSON object with `Content-Type: application/json`. | Not run: send `{`. | | `400` | `INVALID_BODY` | `get_acd_documents`: the body is JSON but not an object, or is nested too deeply, or holds a text value that is too long. | Send one JSON object. | Not run: send `[]`. | | `400` | `INVALID_MULTIPART` | The multipart body cannot be read, or has more than 40 files, 64 fields or a text field over 64 KiB. | Check the form; split a huge upload. | Not run. | | `400` | `MISSING_TRAVELER_IDS` | No `traveler_ids`. | Send one for each file. | Yes: leave it out. | | `400` | `MISSING_DOCUMENTS` | No file in `documents`. | Send at least one file. | Yes: send no file. | | `400` | `DOCUMENT_COUNT_MISMATCH` | `traveler_ids` and `documents` have different lengths. The body has `received_traveler_ids` and `received_documents`. | They are matched by order: one id for each file. | Yes: `traveler_ids=1,2` with one file. | | `400` | `DUPLICATE_TRAVELER_ID` | A traveler appears twice. The body has `duplicate_traveler_ids`. | One file for each traveler and request. | Yes: `traveler_ids=1,1`. | | `400` | `TOO_MANY_DOCUMENTS` | More files than travelers on the booking. The body has `expected_travelers` and `received_documents`. | Send at most one file for each traveler. | Yes: two files for a one-traveler booking. | | `400` | `UNKNOWN_TRAVELER_ID` | A traveler is not on the booking (`results[].error.expected_traveler_ids` lists the valid ones). | Use the `traveler_id` values of `get_acd_documents`. | Yes: `traveler_ids=9`. | | `400` | `EMPTY_FILE` | A file has no content. | Send the real file. | Yes: an empty file. | | `400` | `INVALID_REPLACE_FLAG` | `replace` is not `true`/`false` (or `1`, `yes`, `on`, `0`, `no`, `off`). | Send `true` or `false`. | Yes: `replace=maybe`. | | `400` | `VALIDATION_FAILED` | Several files have different problems. | Read `results[].error`, fix, send again. | Yes: two bad files. | | `401` | `authentication_required`, `invalid_api_key` | The key is missing or invalid. These errors use the `{"detail": {...}}` body of [Errors](https://fx-port.com/docs/errors.md). | Send `Authorization: Bearer `. | Yes: no key. | | `403` | `read_write_permission_required` | `upload_acd_documents` with a Read key. Body: `{"detail": {"error": "read_write_permission_required", ...}}`. | Use a Read+Write key. | Yes: a Read key. | | `404` | `BOOKING_NOT_FOUND` | None of **your** bookings in the environment of your key matches `booking_id`: the id is unknown, or the booking belongs to another agency or to the other environment (all three answer the same way). | Check the id and the environment of the key. | Yes: an unknown id. | | `409` | `DOCUMENT_ALREADY_UPLOADED` | The traveler already has a passport. Nothing was changed. | Send `replace=true` to replace it. | Yes: upload twice. | | `409` | `ACD_NOT_REQUIRED` | The booking does not need ACD documents. The body carries `acd`. | Do not upload. | Yes: a booking without ACD. | | `409` | `ACD_WAIVED` | FX-Port marked ACD as not required for this booking. | Do not upload. | No. | | `409` | `BOOKING_NOT_ELIGIBLE` | The booking is cancelled, failed, voided, refunded or expired. The body has `booking_status`. | Do not upload. | Not run: cancel an ACD hold. | | `409` | `TRAVELERS_NOT_AVAILABLE` | The booking has no traveler data. | Contact support. | No. | | `409` | `CONCURRENT_UPDATE` | Another request changed the documents of this booking at the same moment. | Call `get_acd_documents`, then send what is still missing. | No. | | `413` | `FILE_TOO_LARGE` | One file is over 10 MiB (`max_file_size_bytes`). | Compress or rescan it. | Yes: 10 MiB + 1 byte. | | `413` | `REQUEST_TOO_LARGE` | The files together are over 25 MiB. The body has `max_request_size_bytes`. | Split them over several requests. | Yes: three files of 9.5 MiB. | | `413` | `request_too_large` | The whole body is over 26 MiB; refused by the platform, with `max_bytes`. | Split the upload. | Not run. | | `415` | `UNSUPPORTED_FILE_TYPE` | The content is not a PDF or a supported image. `results[].error.accepted_extensions` lists the extensions. | Send a PDF, JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000 or JPEG XL file. | Yes: text renamed `.pdf`. | | `415` | `UNSUPPORTED_MEDIA_TYPE` | `upload_acd_documents` was not sent as `multipart/form-data`. | Send a multipart form. | Not run: send JSON. | | `429` | `rate_limit_exceeded` | Too many requests. | Wait for the `Retry-After` seconds. | No. | | `502` | `STORAGE_UPLOAD_FAILED` | No file could be stored. Nothing changed. Every `results[].error` has `reason: "storage_error"`, a fixed text: the storage's own message is never returned. Cloudflare can replace the body of a `502` by its own HTML page: treat any `502` of this endpoint the same way. | Retry the request. | No. | | `503` | `STORAGE_UNAVAILABLE` | Document storage is not available. | Retry later. | No. | | `500` | `INTERNAL_ERROR` | Unexpected error. | Call `get_acd_documents` to see what was stored before you retry. | No. | ### Which bookings you can reach A key finds only the bookings of its own agency and of its own environment. These three cases answer the **same** `404 BOOKING_NOT_FOUND`, with the same message (`No flight booking found for ''.`) and the same shape, so an answer never tells you whether a booking exists: - an id that does not exist; - a booking of another agency; - a booking of the other environment (a sandbox key does not read a live booking, and the other way round). A booking that has no agency attached is hidden from API keys as well. The lookup runs among your own bookings, so when two agencies hold the same PNR, each one finds its own. `403 BOOKING_FORBIDDEN` exists only for FX-Port's own tools (the dashboard and internal callers): a key never receives it. ## Retries and idempotency - **Read before you write.** Call `get_acd_documents` first and use its `traveler_id` values and `missing_traveler_ids`. Do not build traveler ids yourself. - **Without `replace`, a retry is safe.** A traveler who was already saved is rejected with `DOCUMENT_ALREADY_UPLOADED` and nothing changes, so a passport is never overwritten without you asking for it. Retry only the travelers in `missing_traveler_ids`. - **With `replace=true`, a retry is not harmless.** It replaces the file again: `replaced_count` goes up, `revision` goes up, and in the live environment the ACD team gets another `updated` e-mail. Use `replace=true` only for a correction. - **After a timeout or a dropped connection**, do not resend blindly: call `get_acd_documents` and send what is missing. - **Send one request at a time for a booking.** Two requests that write the same booking at the same moment can end with `CONCURRENT_UPDATE`; read the state and send again. - **Read the status.** `200` means every file was saved, `207` means only some were (read `results`), `4xx` means nothing changed. A `5xx` after the files were stored cannot be told from the outside: call `get_acd_documents`. - **`502 STORAGE_UPLOAD_FAILED` and `503 STORAGE_UNAVAILABLE`** changed nothing: retry the same request after a short wait. ## Security: file links are public `download_url` (here and in [`get_ticket`](https://fx-port.com/docs/flights/get-ticket.md), `bucket_link` in other answers) is an address **anyone can open without a key**. It is the only protection a file has: whoever holds the address can download the passport. - Treat every `download_url` as a secret: do not log it, do not put it in a URL you show to other users, do not send it by e-mail or to analytics, and do not store it longer than you need. - Download the file on your server when you need it, and give your users your own, access-controlled copy. - A passport copy is personal data. Collect it with the traveler's consent and keep it only as long as the booking needs it. - The sandbox stores files the same way: **never upload a real passport in the sandbox**. ## Same conventions as the rest of the flights API | Topic | In these endpoints | |---|---| | Identifier | `booking_id` takes the PNR, the supplier order id or the FX-Port UUID, like [`get_booking`](https://fx-port.com/docs/flights/get-booking.md), [`get_ticket`](https://fx-port.com/docs/flights/get-ticket.md) and [`get_ticket_number`](https://fx-port.com/docs/flights/get-ticket-number.md). The lookup tries the PNR, then the order id, then the UUID (only a value shaped like a UUID), among the bookings of your agency. PNRs are not unique across suppliers: prefer the UUID or the order id. The answer's `booking_id` is always the PNR (twin: `pnr`). | | Names | Requests use `snake_case` (`booking_id`, `traveler_ids`, `documents`, `replace`); the older spellings still work ([Legacy names](#legacy-names)). The answers are `snake_case` except `requestId` (twin: `request_id`). `?naming=snake` returns every key in `snake_case`: see [Conventions](https://fx-port.com/docs/conventions.md). | | Request id | `requestId` in the body (twin `request_id`) and the `X-Request-Id` header. Quote it when you contact support. | | Errors | The flat body above, plus the unified `errors[]` of [Errors](https://fx-port.com/docs/errors.md). Authentication and permission errors use the `{"detail": {...}}` body. | | Permission | `get_acd_documents` is Read. `upload_acd_documents` is Read+Write: a Read key gets `403 read_write_permission_required`. | | Environment | Every answer starts with `"environment"`. A key sees only the bookings of its own environment and agency; the others answer `404 BOOKING_NOT_FOUND`, like an unknown id ([Which bookings you can reach](#which-bookings-you-can-reach)). | | Rate limit | The default group: 6,000 requests per minute per key. The body of an upload is limited to 26 MiB. See [Rate limits](https://fx-port.com/docs/rate-limits.md). | | Warnings | A legacy spelling is answered with a top-level `warnings` entry `deprecated_field`; an unknown field is noticed as `ignored_field`. See [Legacy names](https://fx-port.com/docs/legacy-names.md). | ## Integration tips - **Sandbox** keys work with sandbox bookings only. Files are stored and the state is tracked like in production, but no e-mail is sent. - **Dashboard uploads count too.** Passports uploaded from the FX-Port dashboard appear in `get_acd_documents`, and you can replace them through the API. - **Voided travelers drop out.** The number of required passports follows the travelers that are still on the booking: a passenger removed by a void is no longer in `travelers` and no longer counts toward `required_documents`. - **Send a `User-Agent` of your own.** Cloudflare blocks the default agents of Python's `urllib` and Perl's `libwww` with an error 1010 page before the request reaches the API. ## Related - [Flight pricing](https://fx-port.com/docs/flights/pricing.md#the-acd-block-amadeus-algeria): the `acd` block of the price. - [Book a flight](https://fx-port.com/docs/flights/book.md), [Pay a held order](https://fx-port.com/docs/flights/pay-order.md) and [Cancel a held order](https://fx-port.com/docs/flights/cancel-order.md). - [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md): `require_acd` and `acd_submitted` of a booking. - [Sandbox](https://fx-port.com/docs/sandbox.md) and [Errors](https://fx-port.com/docs/errors.md). --- # Hotels API overview > Geo search around a point, name search, hotel content, rates, pre-book, bookings that confirm at once, vouchers and cancellation. Source: https://fx-port.com/docs/hotels Search, price and book hotels worldwide through one API. Hotel content comes as compact **mini** cards for result lists and as a **full** document for the hotel page: [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md). For a summary of what the API covers, who it is for and how to get access, see the [hotel booking API for travel agencies](https://fx-port.com/b2b-hotels-api). **The flow: [locations](https://fx-port.com/docs/hotels/locations.md) → [geo search](https://fx-port.com/docs/hotels/search.md) → [rates](https://fx-port.com/docs/hotels/rates.md) → [pre_book](https://fx-port.com/docs/hotels/pre-book.md) → [book](https://fx-port.com/docs/hotels/book.md).** Turn a typed city into coordinates, run a **geo search** (`POST /hotels/search_advanced` with `latitude`, `longitude` and `radius`) to list the hotels around them with their prices, read the rooms and the cancellation terms of the hotel the user opens, lock the rate and book it. Then read the booking, get the voucher, or cancel. **Booking a hotel confirms it: there is no hold or pay-later option for API keys, and your agency is debited as part of the booking, when it is confirmed** (the sandbox debits nothing). > [!CAUTION] > **In the sandbox you can search and price any hotel, but you can only book the test hotels** > > A sandbox key searches and prices **any** hotel on real supplier inventory, but it can **book only the supplier's test hotels**: `test_hotel_do_not_book` (every residency: use this one) and `test_hotel` (residency `dz`, `ru` or `us` only). `pre_book` on any other hotel answers `409 hotel_not_bookable_in_sandbox` and lists the test hotels; `book_hotel` on it would fail with `order_not_found`. Every sandbox booking is a real order on the supplier's test contract: **cancel it**. [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md) has the ids, the date rules and a script that books and cancels. A hotel booking, end to end. Actors: Your server, FX-Port API, Hotel supplier. 1. Your server to FX-Port API: GET /hotels/locations/search 2. FX-Port API to Your server: Coordinates of the destination 3. Your server to FX-Port API: POST /hotels/search_advanced (geo search) 4. FX-Port API to Your server: Mini hotel cards with prices 5. Your server to FX-Port API: POST /hotels/get_hotel_info (hotel page) 6. Your server to FX-Port API: POST /hotels/get_rates 7. FX-Port API to Your server: Rooms, rates, book_hash 8. Your server to FX-Port API: POST /hotels/pre_book 9. FX-Port API to Your server: Locked price, partnerOrderId 10. Your server to FX-Port API: POST /hotels/book_hotel 11. FX-Port API to Hotel supplier: Book the room 12. Your server to FX-Port API: GET /bookings/{id}?product=hotels 13. FX-Port API to Your server: Confirmed booking with the voucher ## Endpoints All paths are below `https://api.fx-port.com`. **Read** works with any key; **Read+Write** needs a key with read_write permission (a read key answers `403 read_write_permission_required`). | Method | Path | Permission | What it does | |---|---|---|---| | `GET` | `/api/v1/hotels/locations/search` | Read | Turn a city, an area or an airport typed by a user into coordinates for the geo search. [Search locations](https://fx-port.com/docs/hotels/locations.md) | | `POST` | `/api/v1/hotels/search_advanced` | Read | **Geo search**: the hotels around a point (or one hotel by id), as mini cards with prices. [Geo search](https://fx-port.com/docs/hotels/search.md) | | `GET` / `POST` | `/api/v1/hotels/name-search` / `/api/v1/hotels/search-by-name` | Read | Find a hotel by name. [Hotel name search](https://fx-port.com/docs/hotels/name-search.md) | | `POST` | `/api/v1/hotels/search`, `/api/v1/hotels/details` | Read | Content of up to 200 hotels by id, as mini cards or full documents. [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) | | `POST` | `/api/v1/hotels/get_hotel_info` | Read | The full content of one hotel for its page, and the content of a hotel FX-Port does not hold yet. [Hotel details](https://fx-port.com/docs/hotels/hotel-details.md) | | `POST` | `/api/v1/hotels/get_rates` | Read | Every room and rate of a hotel for a stay, with cancellation terms. [Get hotel rates](https://fx-port.com/docs/hotels/rates.md) | | `POST` | `/api/v1/hotels/pre_book` | **Read+Write** | Lock the rate you chose. [Pre-book a rate](https://fx-port.com/docs/hotels/pre-book.md) | | `POST` | `/api/v1/hotels/book_hotel` | **Read+Write** | Book the locked rate for the guests of each room. [Book a hotel](https://fx-port.com/docs/hotels/book.md) | | `GET` | `/api/v1/bookings/{booking_id}?product=hotels` | Read | The status of a booking, with its price and voucher links. [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md) | | `GET` | `/api/v1/history?product=hotels` | Read | The hotel bookings of your agency, newest first. [Booking history](https://fx-port.com/docs/account/history.md) | | `GET` | `/api/v1/hotels/get_voucher` | Read | The links of the voucher PDFs. [Get the hotel voucher](https://fx-port.com/docs/hotels/voucher.md) | | `POST` | `/api/v1/hotels/cancel_hotel` | **Read+Write** | Cancel a confirmed booking. [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md) | The [API reference](https://fx-port.com/docs/api.md) has every field, parameter and error of each call. ## The flow, step by step **Step 1: Find the place** [`locations/search`](https://fx-port.com/docs/hotels/locations.md) turns a typed city into `latitude` and `longitude`, the input of the geo search. [`name-search`](https://fx-port.com/docs/hotels/name-search.md) finds a hotel by name and gives its `id`. **Step 2: Geo search** [`search_advanced`](https://fx-port.com/docs/hotels/search.md) is the **geo search**: send `latitude`, `longitude` and a `radius` (or a `hotel_id`) with the dates and the guests. It answers the **mini cards** in `hotels_details` and the price of each hotel in `hotels_pricing`. Join them by hotel id. Hotels that have a price but no card are in `missing_hotel_ids`: see [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md). **Step 3: Show the hotel and its rooms** When the user opens a hotel, call [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) for its full page and [`get_rates`](https://fx-port.com/docs/hotels/rates.md) for its rooms. Each rate has a `bookHash` and a cancellation schedule. **Step 4: Lock the rate** [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md) with the `bookHash` re-checks the price with the supplier and answers a `partnerOrderId`: the id of the booking you are about to make. **Step 5: Book** [`book_hotel`](https://fx-port.com/docs/hotels/book.md) with that id as `booking_id`, the guests of each room and the contact. It answers `pending`. **Step 6: Wait for the confirmation** Poll [`GET /bookings/{booking_id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md) every 2 seconds until `status` is `confirmed` (4 to 14 seconds). FX-Port sends **no webhooks**. **Step 7: Voucher, or cancel** [`get_voucher`](https://fx-port.com/docs/hotels/voucher.md) gives the voucher links. [`cancel_hotel`](https://fx-port.com/docs/hotels/cancel.md) cancels a confirmed booking. What you carry from one call to the next: | From | You keep | You send it to | |---|---|---| | `locations/search` | `latitude`, `longitude` | `search_advanced` (the geo search) | | `name-search`, `search_advanced` | the hotel `id` | `hotel_id` of `get_rates` and `get_hotel_info` | | `get_rates` | `rates[].bookHash` (twin `book_hash`) | `book_hash` of `pre_book` | | `pre_book` | `partnerOrderId` (twin `booking_id`) | `booking_id` of `book_hotel`, `get_voucher`, `cancel_hotel` and the path of `GET /bookings/{booking_id}` | ## Before you start | | | |---|---| | **Base URL and key** | `https://api.fx-port.com/api/v1`. A key starting with `fxp_test_` works on the sandbox and `fxp_live_` on live: [Authentication](https://fx-port.com/docs/authentication.md). | | **Permissions** | Searches, content, rates, vouchers and booking reads need **Read**. `pre_book`, `book_hotel` and `cancel_hotel` need **Read+Write**. | | **Names** | Fields you send are `snake_case` (`hotel_id`, `book_hash`, `booking_id`). The older spellings (`id`, `hash`, `partner_order_id`, `hotelId` ...) still work and are answered with a `warnings` array. Answers keep their keys, mostly camelCase after `get_rates`, and add snake_case twins of the keys you feed back; `?naming=snake` returns snake_case only. See [Conventions](https://fx-port.com/docs/conventions.md#naming). | | **Currency** | Prices come in the currency of your agency, resolved from your key. **Never send `currency`**: it is refused. See [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md). | | **Money** | The traveler pays `totalPrice`; your agency is debited `totalPrice - agencyCommission`. There is no `b2bPrice` in the camelCase price blocks. | | **Stays** | `checkin` and `checkout` as `YYYY-MM-DD`, 1 to 30 nights, check-in up to 400 days ahead. [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md): 9 rooms at most, one `{ adults, children }` object per room. | | **Values** | A value written in another well-defined way is read, and the answer says so in `warnings` (`coerced_value`): a number as text, a date with a midnight time, `GB-SCT` as residency, a radius outside 100 to 70,000 meters. Only what cannot be read is refused. See [Values the API reads for you](https://fx-port.com/docs/hotels/search.md#values-the-api-reads-for-you). | | **Rate limits** | Per key and per minute: 6,000 for searches, rates, content and reads (a booking, a voucher), 1,200 for `pre_book`, `book_hotel` and `cancel_hotel`. Searches also have a ceiling on the calls that run at the same time. [Rate limits](https://fx-port.com/docs/rate-limits.md). | | **Webhooks** | None. Poll the booking. | | **Time** | A search around a point takes 4 to 20 seconds, `get_rates` 0.5 to 5, `pre_book` 4 to 5, `book_hotel` 3 to 6, `cancel_hotel` 1 to 4. A booking is confirmed 4 to 14 seconds after `book_hotel`. | ## Concepts - [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md): The test hotels, what happens with any other hotel, and a script that books and cancels. - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md): Compact cards for lists, the full document for the hotel page, and the hotels that have a price but no card yet. - [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md): Several rooms, adults, children and their ages, residency and guest names. - [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md): What each price field means and what your agency is debited. ## Errors Every failed call answers one body, whatever the endpoint: `errors[]` with a stable `code`, next to the older keys. Branch on the `code`: see [Errors](https://fx-port.com/docs/errors.md). The ones you meet in the hotel flow: | HTTP | Code | What to do | |---|---|---| | `400` | `missing_field`, `invalid_date`, `stay_too_long`, `invalid_guests`, `invalid_residency` ... | The request is wrong. A missing required field is `missing_field`: `errors[0].field` names it (the current name) and `errors[0].details.missing_fields` lists all of them when several are missing. Read `errors[0].field` and `message`, fix it. | | `400` | `invalid_status` | The booking is not `confirmed`: wait, or it is already cancelled. | | `400` | `rate_not_found` | The rate expired or its price rose: call `get_rates` again. | | `403` | `read_write_permission_required` | Use a Read+Write key. | | `404` | `booking_not_found`, `pdf_not_found` | No such booking of your agency; or the voucher is not made yet (`get_voucher`): ask again in 2 seconds. | | `409` | `hotel_not_bookable_in_sandbox` | Sandbox: book `test_hotel_do_not_book`. | | `409` | `duplicate_booking` | The booking exists. Do not call `book_hotel` again: read its status. | | `409` | `cached_offer_required` | The offer of `pre_book` was not found (older than one hour on live, or locked by another agency): `pre_book` again. | | `422` | `supplier_rejected_request` | The supplier refused well formed dates, guests, residency or radius (`search_advanced`): change them. | | `429` | `rate_limit_exceeded` | Wait for `Retry-After`, then back off. | | `502` | `supplier_error`, `supplier_invalid_response`, `bad_gateway` | The supplier could not answer, or answered in a form that could not be used. Retry a **read** with backoff. For `book_hotel` and `cancel_hotel`, read the booking first. | | `503` | `supplier_busy`, `service_overloaded` | The supplier, or the service, is at capacity. The call was **refused before anything was sent**: nothing was searched, locked, booked or debited. Wait the seconds of `Retry-After`, then repeat the same call. | | `503` | `supplier_unavailable`, `supplier_timeout` | The supplier could not be reached or did not answer. Retry a **read** with backoff. For `cancel_hotel`, read the booking first. | A `200` is not always a hotel: **`get_rates` answers `200` with an empty `rates`** when a hotel is unknown, closed, not available for the dates or not available for the residency, and `search_advanced` answers `200` with `no_hotels_found`. Check the content of a `200`. ### What is safe to repeat | Call | Safe to repeat? | |---|---| | Locations, name search, `search_advanced`, content, `get_rates`, `get_voucher`, reading a booking | Yes. | | `pre_book` | Yes: the same rate and the same `price_increase_percent` within a minute get the same answer. | | `book_hotel` | **No.** A second call answers `409 duplicate_booking` and does not reach the supplier. After a timeout or a `5xx` (except `503 supplier_busy`, which was refused before anything was sent), read `GET /bookings/{booking_id}`: if it exists, the booking was made. | | `cancel_hotel` | Yes after a timeout: a second call settles it, also when the supplier had already cancelled. Once the cancellation is recorded, a further call answers `400 invalid_status`. | ## Questions? Email the FX-Port developer team at [dev@fx-port.com](mailto:dev@fx-port.com) for integration help, feature requests, or to report an API error. Quote the `request_id` of the failed call. --- # Sandbox testing > Which hotel ids are safe to test with in the sandbox, and why booking any other hotel fails. Source: https://fx-port.com/docs/hotels/sandbox-testing In the sandbox you can **search and price any hotel**, but you can **book only the supplier's test hotels**. This page lists them, says what happens with any other hotel, and gives a script that runs the whole booking flow on a test hotel and cancels it. > [!CAUTION] > **Book only the test hotels, and cancel what you book** > > Sandbox search and pricing run on real supplier inventory, but booking is possible only on two supplier test properties: `test_hotel_do_not_book` (every residency, use this one) and `test_hotel` (residency `dz`, `ru` or `us` only). `pre_book` on any other hotel answers `409 hotel_not_bookable_in_sandbox` and lists the test hotels. Every sandbox booking is a **real order on the supplier's test contract**: cancel it with [`cancel_hotel`](https://fx-port.com/docs/hotels/cancel.md). It is free until 21:00 UTC on the check-in date. ## The test hotels | Hotel id | `hid` | Works with residency | Notes | |---|---|---|---| | `test_hotel_do_not_book` | 8473727 | **Every residency** (tried `gb`, `dz` and `fr`) | **Use this one.** Tegucigalpa, Honduras. Its cheapest rate, "Standard room", costs 1 EUR a night. Free cancellation until 21:00 UTC on the check-in date. | | `test_hotel` | 8526976 | `dz`, `ru` and `us` only | "Test Hotel (Do Not Book)", Belogorsk, Russia. With `gb` or `fr` the search answers `no_hotels_found` and `get_rates` answers an empty list. Free cancellation until 00:00 UTC on the check-in date. | The residency decides, not the dates: four date windows failed the same way with `gb`, and two worked with `dz`. The "do not book" in the names is the supplier's label for its test properties, and these two are the only ones you can book. Other hotels with a similar name (`do_not_book`, `do_not_book_2` in the [name search](https://fx-port.com/docs/hotels/name-search.md)) have content only and cannot be booked. Use these values for every example while you build: | | | |---|---| | `hotel_id` | `test_hotel_do_not_book` | | `residency` | `gb` | | `checkin` / `checkout` | a date 30 days ahead, and 3 days later | | `guests` | `[{ "adults": 2, "children": [] }]` | | Rate to book | the cheapest one (1 EUR a night, 3 EUR for the stay) | ## What happens with another hotel | Call | Any other hotel in the sandbox | |---|---| | [`search_advanced`](https://fx-port.com/docs/hotels/search.md), [`get_rates`](https://fx-port.com/docs/hotels/rates.md), [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md), [`POST /hotels/search`](https://fx-port.com/docs/hotels/hotel-data.md) | Work on **real hotels and real prices**. A 5 km search around Paris returns 1,818 hotels. | | [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md) | `409 hotel_not_bookable_in_sandbox`. `errors[0].details.test_hotels` lists the two hotels that can be booked. | | [`book_hotel`](https://fx-port.com/docs/hotels/book.md) | Cannot work: the supplier has no order to finish and answers `order_not_found`. | Check `formData.orderId` after `pre_book`: `null` means the supplier opened no order, whatever the status says. ## Dates and stays - **Check-in** from yesterday (the API accepts it for time zones) to 400 days ahead. The test hotels accepted every date we tried. - **Stay** of 1 to 30 nights. A stay of 35 nights is refused by the supplier. The API checks this before it calls the supplier and answers `stay_too_long`. - **Free cancellation** ends on the check-in date, at 21:00 UTC for `test_hotel_do_not_book` and at 00:00 UTC for `test_hotel` (see `cancellationPolicy.freeCancellationBefore`, a UTC time without zone suffix). Cancel before and `cancel_hotel` answers `cancellationType: "free"` and the booking becomes `refunded`. - **Rates other than the cheapest** are priced like real inventory (up to 1,164 EUR for 3 nights on the test hotels). Pick the cheapest rate in tests so that the supplier bill stays at 3 EUR. ## Sandbox bookings are real test orders The sandbox keys use the supplier's **test credentials**. A booking you make there is a real order on a test contract, with a real confirmation and a real voucher. The supplier does not clean them up: every booking we made stayed `confirmed` until we cancelled it. So: - cancel every booking you make, in the same script that makes it; - book the cheapest rate; - keep `send_email` at `false` unless you want the confirmation e-mail; - do not run load tests: a few bookings per minute are plenty, and the supplier itself limits a shared account to 30 `pre_book` and 30 `book_hotel` calls a minute. ## Run the whole flow The program below searches the test hotel, takes its cheapest rate, locks it, books it for two adults, waits until the booking is confirmed, reads the voucher and cancels the booking. It refuses to run with a key that does not start with `fxp_test_`, and it computes the dates from today. Use a key with **Read + Write** permission. ~~~bash export FXP_KEY="fxp_test_YOUR_KEY" ~~~ **Python** ~~~python """Search, price, pre-book, book, read, get the voucher and cancel one night of the sandbox test hotel.""" import os import sys import time from datetime import date, timedelta import requests KEY = os.environ["FXP_KEY"] if not KEY.startswith("fxp_test_"): sys.exit("This script makes a real supplier test booking: use a sandbox key (fxp_test_...).") API = "https://api.fx-port.com/api/v1" HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"} HOTEL = "test_hotel_do_not_book" # the only hotel every residency can book in the sandbox def call(method, path, **kwargs): response = requests.request(method, API + path, headers=HEADERS, timeout=120, **kwargs) body = response.json() if not response.ok: sys.exit(f"{method} {path} failed with {response.status_code}: {body}") return body checkin = date.today() + timedelta(days=30) stay = { "checkin": checkin.isoformat(), "checkout": (checkin + timedelta(days=3)).isoformat(), "residency": "gb", "language": "en", "guests": [{"adults": 2, "children": []}], } # 1. Search: a hotel card and the price of its cheapest rate for the whole stay search = call("POST", "/hotels/search_advanced", json={**stay, "hotel_id": HOTEL}) price = search["hotels_pricing"][HOTEL] print(f"1. search {search['status']}: {price['final_price']} {price['agency_currency']}") # 2. Rates: every room and rate of the hotel. Take the cheapest one (1 EUR per night). rates = call("POST", "/hotels/get_rates", json={**stay, "hotel_id": HOTEL})["data"]["rates"] rate = min(rates, key=lambda r: r["pricing"]["totalPrice"]) print(f"2. rates {len(rates)} rates, cheapest: {rate['room']['name']}, {rate['pricing']['totalPrice']} {rate['pricing']['agencyCurrency']}") # 3. Pre-book: lock the rate. The sandbox only accepts a test hotel here. pre = call("POST", "/hotels/pre_book", json={"book_hash": rate["bookHash"], "price_increase_percent": 0}) order_id = pre["partnerOrderId"] if not pre["formData"]["orderId"]: sys.exit("pre_book did not open a supplier order: this hotel cannot be booked in the sandbox.") print(f"3. pre_book order {order_id}, price changed: {pre['priceChanged']}") # 4. Book: one object per room, one entry per guest. Do not send this call twice for one order. booking = call("POST", "/hotels/book_hotel", json={ "booking_id": order_id, "rooms": [{"guests": [ {"first_name": "Jane", "last_name": "Smith"}, {"first_name": "John", "last_name": "Smith"}, ]}], "user": {"email": "traveler@example.com", "phone": "+15555550123"}, "send_email": False, }) print(f"4. book_hotel {booking['status']}") # 5. Poll the booking until it is confirmed (usually 4 to 14 seconds). FX-Port sends no webhooks. started = time.time() while True: record = call("GET", f"/bookings/{order_id}", params={"product": "hotels"})["data"] if record["status"] != "pending" or time.time() - started > 90: break time.sleep(2) print(f"5. booking {record['status']} after {time.time() - started:.0f} s, your price {record['netPrice']} {record['currency']}") if record["status"] != "confirmed": sys.exit(f"The booking ended as {record['status']}: nothing to cancel.") # 6. Voucher: the PDF link can appear a few seconds after the booking is confirmed. for _ in range(10): voucher = requests.get(f"{API}/hotels/get_voucher", headers=HEADERS, params={"booking_id": order_id}, timeout=60) if voucher.ok: print("6. voucher ", voucher.json()["data"]["voucherUrl"]) break time.sleep(2) # 7. Cancel: free until 21:00 UTC on the check-in day. Always cancel what you book in the sandbox. for attempt in range(3): try: cancel = requests.post(f"{API}/hotels/cancel_hotel", headers=HEADERS, json={"booking_id": order_id}, timeout=60) except requests.Timeout: continue if cancel.ok: refund = cancel.json()["refund"] print(f"7. cancel {cancel.json()['bookingStatus']}: {refund['cancellationType']}, refunded {refund['agencyAmount']} {refund['currency']}") break time.sleep(3) # a supplier timeout answers with a 5xx once in a while; the booking stays confirmed, so call again else: sys.exit(f"Cancel {order_id} by hand: POST /hotels/cancel_hotel with booking_id {order_id}") ~~~ **JavaScript** ~~~js // Search, price, pre-book, book, read, get the voucher and cancel one night of the sandbox test hotel. // Node.js 18 or later. Run: node e2e.mjs const KEY = process.env.FXP_KEY; if (!KEY?.startsWith("fxp_test_")) { throw new Error("This script makes a real supplier test booking: use a sandbox key (fxp_test_...)."); } const API = "https://api.fx-port.com/api/v1"; const HOTEL = "test_hotel_do_not_book"; // the only hotel every residency can book in the sandbox const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function call(method, path, { body, query } = {}) { const url = API + path + (query ? "?" + new URLSearchParams(query) : ""); const response = await fetch(url, { method, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(120_000), }); const data = await response.json(); return { ok: response.ok, status: response.status, data }; } async function must(method, path, options) { const result = await call(method, path, options); if (!result.ok) throw new Error(`${method} ${path} failed with ${result.status}: ${JSON.stringify(result.data)}`); return result.data; } const day = (offset) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); const stay = { checkin: day(30), checkout: day(33), residency: "gb", language: "en", guests: [{ adults: 2, children: [] }], }; // 1. Search: a hotel card and the price of its cheapest rate for the whole stay const search = await must("POST", "/hotels/search_advanced", { body: { ...stay, hotel_id: HOTEL } }); const price = search.hotels_pricing[HOTEL]; console.log(`1. search ${search.status}: ${price.final_price} ${price.agency_currency}`); // 2. Rates: every room and rate of the hotel. Take the cheapest one (1 EUR per night). const { data: { rates } } = await must("POST", "/hotels/get_rates", { body: { ...stay, hotel_id: HOTEL } }); const rate = rates.reduce((best, r) => (r.pricing.totalPrice < best.pricing.totalPrice ? r : best)); console.log(`2. rates ${rates.length} rates, cheapest: ${rate.room.name}, ${rate.pricing.totalPrice} ${rate.pricing.agencyCurrency}`); // 3. Pre-book: lock the rate. The sandbox only accepts a test hotel here. const pre = await must("POST", "/hotels/pre_book", { body: { book_hash: rate.bookHash, price_increase_percent: 0 } }); const orderId = pre.partnerOrderId; if (!pre.formData.orderId) throw new Error("pre_book did not open a supplier order: this hotel cannot be booked in the sandbox."); console.log(`3. pre_book order ${orderId}, price changed: ${pre.priceChanged}`); // 4. Book: one object per room, one entry per guest. Do not send this call twice for one order. const booking = await must("POST", "/hotels/book_hotel", { body: { booking_id: orderId, rooms: [{ guests: [{ first_name: "Jane", last_name: "Smith" }, { first_name: "John", last_name: "Smith" }] }], user: { email: "traveler@example.com", phone: "+15555550123" }, send_email: false, }, }); console.log(`4. book_hotel ${booking.status}`); // 5. Poll the booking until it is confirmed (usually 4 to 14 seconds). FX-Port sends no webhooks. const started = Date.now(); let record; do { await sleep(2000); ({ data: record } = await must("GET", `/bookings/${orderId}`, { query: { product: "hotels" } })); } while (record.status === "pending" && Date.now() - started < 90_000); console.log(`5. booking ${record.status} after ${Math.round((Date.now() - started) / 1000)} s, your price ${record.netPrice} ${record.currency}`); if (record.status !== "confirmed") throw new Error(`The booking ended as ${record.status}: nothing to cancel.`); // 6. Voucher: the PDF link can appear a few seconds after the booking is confirmed. for (let attempt = 0; attempt < 10; attempt++) { const voucher = await call("GET", "/hotels/get_voucher", { query: { booking_id: orderId } }); if (voucher.ok) { console.log("6. voucher ", voucher.data.data.voucherUrl); break; } await sleep(2000); } // 7. Cancel: free until 21:00 UTC on the check-in day. Always cancel what you book in the sandbox. let cancelled = false; for (let attempt = 0; attempt < 3 && !cancelled; attempt++) { const cancel = await call("POST", "/hotels/cancel_hotel", { body: { booking_id: orderId } }).catch(() => null); if (cancel?.ok) { const { bookingStatus, refund } = cancel.data; console.log(`7. cancel ${bookingStatus}: ${refund.cancellationType}, refunded ${refund.agencyAmount} ${refund.currency}`); cancelled = true; } else { await sleep(3000); // a supplier timeout answers with a 5xx once in a while; the booking stays confirmed, so call again } } if (!cancelled) throw new Error(`Cancel ${orderId} by hand: POST /hotels/cancel_hotel with booking_id ${orderId}`); ~~~ **curl** ~~~bash #!/usr/bin/env bash # Search, price, pre-book, book, read, get the voucher and cancel one night of the sandbox test hotel. # Needs curl and jq. Run: FXP_KEY=fxp_test_YOUR_KEY bash e2e.sh set -euo pipefail case "${FXP_KEY:-}" in fxp_test_*) ;; *) echo "Set FXP_KEY to a sandbox key (fxp_test_...): this script makes a real supplier test booking." >&2; exit 1 ;; esac API="https://api.fx-port.com/api/v1" HOTEL="test_hotel_do_not_book" # the only hotel every residency can book in the sandbox CHECKIN=$(date -u -d "+30 days" +%F 2>/dev/null || date -u -v+30d +%F) CHECKOUT=$(date -u -d "+33 days" +%F 2>/dev/null || date -u -v+33d +%F) api() { curl -sS --fail-with-body --max-time 120 -H "Authorization: Bearer $FXP_KEY" -H "Content-Type: application/json" "$@"; } STAY=$(jq -n --arg in "$CHECKIN" --arg out "$CHECKOUT" \ '{checkin: $in, checkout: $out, residency: "gb", language: "en", guests: [{adults: 2, children: []}]}') # 1. Search: a hotel card and the price of its cheapest rate for the whole stay api -X POST "$API/hotels/search_advanced" -d "$(jq --arg h "$HOTEL" '. + {hotel_id: $h}' <<<"$STAY")" | jq -r --arg h "$HOTEL" '"1. search \(.status): \(.hotels_pricing[$h].final_price) \(.hotels_pricing[$h].agency_currency)"' # 2. Rates: every room and rate of the hotel. Take the cheapest one (1 EUR per night). RATES=$(api -X POST "$API/hotels/get_rates" -d "$(jq --arg h "$HOTEL" '. + {hotel_id: $h}' <<<"$STAY")") BOOK_HASH=$(jq -r '.data.rates | min_by(.pricing.totalPrice) | .bookHash' <<<"$RATES") jq -r '.data.rates | min_by(.pricing.totalPrice) as $r | "2. rates \(length) rates, cheapest: \($r.room.name), \($r.pricing.totalPrice) \($r.pricing.agencyCurrency)"' <<<"$RATES" # 3. Pre-book: lock the rate. The sandbox only accepts a test hotel here. PRE=$(api -X POST "$API/hotels/pre_book" -d "$(jq -n --arg h "$BOOK_HASH" '{book_hash: $h, price_increase_percent: 0}')") ORDER=$(jq -r '.partnerOrderId' <<<"$PRE") jq -e '.formData.orderId' <<<"$PRE" >/dev/null || { echo "pre_book did not open a supplier order: this hotel cannot be booked in the sandbox." >&2; exit 1; } echo "3. pre_book order $ORDER, price changed: $(jq -r '.priceChanged' <<<"$PRE")" # 4. Book: one object per room, one entry per guest. Do not send this call twice for one order. BOOKING=$(api -X POST "$API/hotels/book_hotel" -d "$(jq -n --arg id "$ORDER" '{ booking_id: $id, rooms: [{guests: [{first_name: "Jane", last_name: "Smith"}, {first_name: "John", last_name: "Smith"}]}], user: {email: "traveler@example.com", phone: "+15555550123"}, send_email: false }')") echo "4. book_hotel $(jq -r '.status' <<<"$BOOKING")" # 5. Poll the booking until it is confirmed (usually 4 to 14 seconds). FX-Port sends no webhooks. for _ in $(seq 45); do RECORD=$(api "$API/bookings/$ORDER?product=hotels") [ "$(jq -r '.data.status' <<<"$RECORD")" != "pending" ] && break sleep 2 done STATUS=$(jq -r '.data.status' <<<"$RECORD") echo "5. booking $STATUS, your price $(jq -r '.data.netPrice' <<<"$RECORD") $(jq -r '.data.currency' <<<"$RECORD")" [ "$STATUS" = "confirmed" ] || { echo "The booking ended as $STATUS: nothing to cancel." >&2; exit 1; } # 6. Voucher: the PDF link can appear a few seconds after the booking is confirmed. for _ in $(seq 10); do if URL=$(api "$API/hotels/get_voucher?booking_id=$ORDER" 2>/dev/null | jq -er '.data.voucherUrl'); then echo "6. voucher $URL" break fi sleep 2 done # 7. Cancel: free until 21:00 UTC on the check-in day. Always cancel what you book in the sandbox. for _ in 1 2 3; do if CANCEL=$(api -X POST "$API/hotels/cancel_hotel" -d "{\"booking_id\": \"$ORDER\"}"); then jq -r '"7. cancel \(.bookingStatus): \(.refund.cancellationType), refunded \(.refund.agencyAmount) \(.refund.currency)"' <<<"$CANCEL" exit 0 fi sleep 3 # a supplier timeout answers with a 5xx once in a while; the booking stays confirmed, so call again done echo "Cancel $ORDER by hand: POST /hotels/cancel_hotel with booking_id $ORDER" >&2 exit 1 ~~~ The program prints one line per step. This is a real run of the sandbox (2026-11-01): ~~~text 1. search complete: 1484.97 DZD 2. rates 14 rates, cheapest: Standard room, 1484.97 DZD 3. pre_book order HMVXZJZJ6, price changed: False 4. book_hotel pending 5. booking confirmed after 8 s, your price 1482.0 DZD 6. voucher https://files.example.com/hotel-bookings/HMVXZJZJ6-Jane-Smith.pdf 7. cancel refunded: free, refunded 1482.0 DZD ~~~ The amounts are in the currency of your agency (here `DZD`, at 285 per EUR in the sandbox). The three programs above, the Python, the JavaScript and the curl version, were each run in full on the sandbox and cancelled their booking. Timings of those runs: | Step | Time | |---|---| | `search_advanced` (one hotel) | 0.5 to 0.7 s | | `get_rates` | 0.5 to 0.7 s | | `pre_book` | 4.0 to 4.6 s | | `book_hotel` (answers `pending`) | 3.0 to 3.1 s | | Until `confirmed` | 7 to 10 s after the `book_hotel` answer (4 to 14 s over 9 runs) | | `get_voucher` | 0.4 s | | `cancel_hotel` | 3.6 to 3.9 s | | Whole program | 19 to 24 s | > [!NOTE] > The program prints the voucher as `https://files.example.com/...` here. The real link points to the public storage of FX-Port's vouchers. It is permanent and needs no key: do not post it where it should not be read (see [Voucher](https://fx-port.com/docs/hotels/voucher.md)). ## What the sandbox changes | | In the sandbox | |---|---| | **Balance** | Never debited, never credited. `book_hotel` adds `balanceDebited`, `transactionCreated` and `payoutCreated`, all `false`. They exist on a sandbox key only. `GET /balances` has nothing to show. | | **Webhooks** | None, here or on live. FX-Port does not call a URL of yours. Poll [`GET /bookings/{id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md). | | **Search and rates** | Real hotels, real prices, real cancellation schedules. | | **Bookings** | Real orders on the supplier's test contract, only on the two test hotels. | | **Voucher** | A real PDF at a public link, also for a cancelled booking. | | **Cancellation** | Works like live. `refundProcessed` is `true` and `refund` shows amounts, but no money moves. | | **Rate limits** | The same as live: [Rate limits](https://fx-port.com/docs/rate-limits.md). | ## Known quirks - **A booking is `pending` first.** `book_hotel` answers `pending` in 3 to 6 seconds and the booking is `confirmed` 4 to 14 seconds later. `cancel_hotel` before that answers `400 invalid_status`: only a `confirmed` booking can be cancelled. `invalid_status` also answers when the booking is already `refunded` or `failed`. - **The voucher can come a little later** than the status `confirmed`. `get_voucher` answers `404 pdf_not_found` until the PDF exists (up to about 10 seconds): try again in 2 seconds. - **A cancel can time out.** The supplier sometimes takes 30 seconds. The call then answers a `5xx` (`503 supplier_timeout`) and the cancellation may or may not have gone through; a second call settles it (0.8 s in our run), and so does reading the booking. The programs above retry three times. - **Never send `book_hotel` twice for one `booking_id`.** The second call answers `409 duplicate_booking` and does not reach the supplier. After a timeout or a `5xx` (except `503 supplier_busy`, refused before anything was sent), read the booking first. - **`pre_book` takes 4 to 5 seconds.** A `pre_book` repeated with the same rate and the same `price_increase_percent` within a minute gets the same answer instead of opening a second order. - **Names.** `first_name` and `last_name` are kept as letters, spaces and `- , . '`; digits and other symbols are replaced by a space. See [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md#guest-names-at-booking-time). ## Testing checklist Before you switch to a live key, run these against `test_hotel_do_not_book`: 1. **Locations**: [`locations/search`](https://fx-port.com/docs/hotels/locations.md) turns a city into coordinates. 2. **Name search**: [`name-search`](https://fx-port.com/docs/hotels/name-search.md) returns hotel ids for a name. 3. **Geo search**: [`search_advanced`](https://fx-port.com/docs/hotels/search.md) with coordinates and a `radius`, and with a hotel id. Render the mini cards with their prices and handle `missing_hotel_ids`. 4. **Hotel page**: [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) and [`get_rates`](https://fx-port.com/docs/hotels/rates.md), for one room and for two rooms with a child. 5. **Pre-book**: [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md) with the `bookHash`; check `formData.orderId`. 6. **Book**: [`book_hotel`](https://fx-port.com/docs/hotels/book.md) with the `partnerOrderId` as `booking_id`, with a child flagged `is_child` and `age`. 7. **Poll**: [`GET /bookings/{id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md) until `confirmed`. 8. **Voucher**: [`get_voucher`](https://fx-port.com/docs/hotels/voucher.md), and the retry while the answer is `404 pdf_not_found`. 9. **Cancel**: [`cancel_hotel`](https://fx-port.com/docs/hotels/cancel.md), and a second call that fails with `invalid_status`. 10. **Failure paths**: a read key on `pre_book` (`403`), a stay of 31 nights (`400 stay_too_long`), a hotel that is not a test hotel (`409 hotel_not_bookable_in_sandbox`) and a second `book_hotel` (`409 duplicate_booking`). ## Before you go live The requests and the answers do not change: only the key does. What differs on live: - Any hotel can be booked, with real money: the debit is `totalPrice - agencyCommission`, and the cancellation schedule of the rate applies, with real penalties. - `book_hotel` needs the offer that `pre_book` locked (`409 cached_offer_required` otherwise) and a balance that covers the debit (`402 insufficient_balance`). - The sandbox flags `balanceDebited`, `transactionCreated` and `payoutCreated` are absent. - Your agency must be approved (`403 agency_not_active` otherwise). Do not run the program above with a live key: it stops on purpose. --- # Mini and full hotel data > Use the compact mini data for search result lists and the full data only for the hotel page, with the size difference measured. Source: https://fx-port.com/docs/hotels/hotel-data Hotel content comes in two sizes, and you can use both: the **mini** card for every hotel of a result list, and the **full** document for the one hotel the user opens. Mini is a compact card of 13 fields, about **5 KB** a hotel. Full is the whole document, **35 to 45 KB** a hotel on average and up to 150 KB: **7 to 9 times larger**, and 92% of a mini card is photos and amenities. So build lists from mini data, fetch full data for one hotel when it is opened, and cache both. ## Which one to use | You are building | Use | Why | |---|---|---| | A **list of results** or map pins | `hotels_details[]` of [`search_advanced`](https://fx-port.com/docs/hotels/search.md), joined by hotel id with its `hotels_pricing` | One call gives the cards and the prices. Already mini. | | Cards for **hotel ids you already have** (favourites, name search hits, a saved list) | `POST /hotels/search?detail_level=minimal` with up to 200 ids | One call, from FX-Port's cache, no price. | | The card of a hotel in `missing_hotel_ids` | [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) with `hotel_id` and `detail_level=minimal` | The only call that asks the supplier when FX-Port does not hold the hotel yet. | | The **page of one hotel** the user opened | `get_hotel_info` (full), next to [`get_rates`](https://fx-port.com/docs/hotels/rates.md) | Description, every room type, policies, facts, contact details. | | The pages of **several hotels** at once (a comparison) | `POST /hotels/search` (full, snake_case) with their ids | One call, from the cache. | | A copy of the hotel documents in **your own catalogue** | `POST /hotels/details` | The stored document, with `images_ext` and `policy_struct`. | ## Mini data The mini card has 13 fields in `snake_case`. It is the same card wherever you get it: - `hotels_details[]` of `search_advanced` (automatic, with every search); - `POST /hotels/search?detail_level=minimal` (alias `mini`); - `POST /hotels/details?detail_level=minimal`; - `get_hotel_info` with `detail_level=minimal`. | Field | Type | What it holds | |---|---|---| | `id` | string | The FX-Port hotel id, a lower-case slug. Send it as `hotel_id`. | | `hid` | integer | The supplier's numeric hotel id. | | `name` | string | Name of the hotel. | | `kind` | string | Type of property: `Hotel`, `Apartment`, `Resort`, `Hostel` ... | | `address` | string | Street address, in English. | | `latitude`, `longitude` | number | Position, for the map. | | `postal_code` | string or `null` | Postal code; `null` for a minority of hotels. | | `region` | object | `{ id, country_code, iata, name, type, type_v2 }`: the city. | | `star_rating` | integer | 0 to 5. `0` means the property is not rated. | | `images` | array or `null` | Photo **URL templates**, best first (see [Images](#images)). `null` for a few hotels (62 of 1,818 around Paris). | | `serp_filters` | array of strings | Filter tags: `has_internet`, `has_parking`, `has_pool`, `has_spa`, `has_fitness`, `has_pets`, `has_kids`, `has_meal` ... | | `amenity_groups` | array | `[{ group_name, amenities[], non_free_amenities[] }]`. | ~~~json { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "latitude": 14.079872, "longitude": -87.21677, "postal_code": null, "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" }, "star_rating": 0, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg", "https://cdn.worldota.net/t/{size}/extranet/15/44/15440558c952ec4c3488730d1563dc9e8d25832c.JPEG" ], "serp_filters": ["has_internet", "has_parking", "has_spa", "has_pets", "has_jacuzzi", "kitchen"], "amenity_groups": [ { "amenities": ["24-hour reception", "Early check-in", "Late check-out"], "non_free_amenities": [], "group_name": "General" } ] } ~~~ The reference describes every field: [The Hotel mini object](https://fx-port.com/docs/api/objects/hotel-mini.md). A mini card has **no price, no room and no rate**. It is made for a list: where the hotel is, what it looks like, what it offers. The price of the cheapest rate is in `hotels_pricing`, and the rooms and cancellation terms come from [`get_rates`](https://fx-port.com/docs/hotels/rates.md). ## Full data The full document adds what the hotel page needs: the description, **every room type** with photos and amenities, the policies, the facts, the contact details and the times of check-in and check-out. It comes in three spellings: | Call | Keys | Notes | |---|---|---| | `POST /hotels/search` (default `detail_level=full`) | `snake_case` | 31 fields, from the cache. [The Hotel full object](https://fx-port.com/docs/api/objects/hotel-full.md). | | [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) (default) | `camelCase` | The same data with the room properties decoded into words. For a hotel that is not cached it asks the supplier. | | `POST /hotels/details` | `snake_case` | The stored document: the 31 fields plus `images_ext` and `policy_struct`. About 27% larger. | The fields of the full document (`snake_case` names; the camelCase document has the same data): | Field | What it holds | |---|---| | `id`, `hid`, `name`, `kind`, `hotel_chain` | Identity. `hotel_chain` is `No chain` for an independent hotel. | | `address`, `postal_code`, `latitude`, `longitude`, `region` | Place. | | `star_rating`, `star_certificate` | Stars, and the official certificate when there is one (`{ certificate_id, valid_to }`, else `null`). | | `email`, `phone` | Contact details of the hotel. | | `check_in_time`, `check_out_time`, `check_in_time_end` | **Times of day** (`14:00:00`, local time), not dates. `check_in_time_end` only for hotels that state it. | | `front_desk_time_start`, `front_desk_time_end` | Opening hours of the front desk, or `null`. | | `images`, `amenity_groups`, `serp_filters` | The same as in the mini card. | | `description_struct` | The description in titled blocks: `[{ title, paragraphs[] }]`. | | `metapolicy_struct` | The policies: `internet`, `meal`, `children_meal`, `extra_bed`, `cot`, `pets`, `shuttle`, `parking`, `children`, `visa`, `deposit`, `no_show`, `add_fee`, `check_in_check_out`. Read `children` before you assume a child stays free. | | `metapolicy_extra_info` | The house rules as free text, or `null`. | | `room_groups` | Every room type: `room_group_id`, `name`, `name_struct`, `rg_ext` (numeric codes), `room_amenities`, `images`. About 80% of the bytes of the document. | | `facts` | Floors, rooms, year built, year renovated, electricity, registration. Many are `null`. | | `payment_methods` | Payment methods accepted at the property. | | `keys_pickup` | How guests get the keys: `type`, `is_contactless`, `phone`, `email`, `apartment_office_address`, `apartment_extra_information`. | | `is_closed`, `is_gender_specification_required`, `deleted` | Flags. `is_closed: true` means `get_rates` finds no rate. | | `distance_center`, `giata_code` | Optional: present for some hotels only. | `get_hotel_info` spells these keys in camelCase (`starRating`, `amenityGroups`, `descriptionStruct`, `roomGroups`), and differs in six places: | In the snake_case document | In `get_hotel_info` | |---|---| | `amenity_groups[].non_free_amenities` | `amenityGroups[].paidAmenities` | | `check_in_time`, `check_out_time` | `checkIn`, `checkOut` (the snake_case names are returned as twins) | | `email`, `phone` | `contact: { email, phone }` | | `front_desk_time_start`, `front_desk_time_end` | `frontDeskHours: { start, end }` | | `room_groups[].rg_ext` (12 numeric codes) | decoded: `roomClass`, `quality`, `bathroom`, `hasPrivateBathroom`, `capacity`, `balcony`, `view`, `floor`, `bedrooms`, `beddingType`, `isFamily`, `isClub`, `genderRestriction` | | `description_struct[]` | the same blocks plus `html` | The entries inside `metapolicyStruct` keep their snake_case keys (`age_start`, `price_unit`, `meal_type`). ## How much smaller is mini Measured on the sandbox on 2026-11-01 (decoded JSON; "gzip" is what travels over the network): | What | Mini | Full | Full / mini | |---|---|---|---| | **One hotel**, the test hotel (7 photos, 11 amenity groups, 9 room types) | 2.3 KB (0.9 KB gzip) | 11.9 KB (3.2 KB gzip), `get_hotel_info` 13.1 KB | 5.2x (5.7x) | | **15 hotels** around Tegucigalpa | 79 KB, 5.3 KB a hotel (16.7 KB gzip) | 654 KB, 43.6 KB a hotel (72.8 KB gzip); `get_hotel_info` 723 KB | 8.3x decoded, 4.4x gzip | | **73 hotels** around Los Angeles | 349 KB, 4.8 KB a hotel (75.7 KB gzip) | 2,493 KB, 34.2 KB a hotel (309 KB gzip); `get_hotel_info` 2,784 KB, 38.1 KB a hotel | 7.1x decoded, 4.1x gzip | A mini card is also **predictable**: in the 73 hotels it runs from 0.4 KB to 8.3 KB, 4.6 KB in the middle. A full document runs from 3.8 KB to 131 KB, 23 KB in the middle. Where the bytes go (73 hotels): in a **mini** card, photos are 58% and amenities 34%, so **92% of it is photos and amenities**. In a **full** document the room types are 79%, the photos 8% and the amenities 5%. A mini card is still larger than what a list shows. What a result card needs is the id, name, kind, address, position, stars, filters, the city name and **one photo**: about **0.5 KB**, or 1.0 KB with six photos (computed on the 15 hotels above; 5.1 KB a hotel in mini form). Keep those fields when you store or forward the cards, and do not send the amenity groups to a phone. > [!NOTE] > Mini saves **bytes**, not server time. Both forms read the same cache document and the answer takes 0.2 to 0.7 s for 15 to 73 hotels either way. The saving is on the network, in parsing, and in your own storage: 8.5 MB of search answer for 1,818 hotels around Paris is 93% mini cards. ## Hotels without content A search prices every hotel the supplier returns, but the content comes from FX-Port's own cache of hotel content. A hotel that is priced but not cached yet (a new property) is **not** in `hotels_details`. The search tells you: ~~~json { "status": "partial", "processing_status": { "total_hotels": 15, "cached_hotels": 14, "queued_hotels": 1 }, "queue_info": { "queued_hotel_ids": ["comfort_inn_tegucigalpa"], "estimated_completion": "about 9 minutes", "estimated_completion_seconds": 541 }, "missing_hotel_ids": ["comfort_inn_tegucigalpa"], "hotels_pricing": { "comfort_inn_tegucigalpa": { "final_price": 109590.36, "b2b_price": 109371.61, "...": "..." } } } ~~~ - `missing_hotel_ids` lists the hotels that have a price and no card. The price is in `hotels_pricing` (and `missing_hotels_pricing`) as for any other hotel. - FX-Port queues them for loading in the background (`queue_info`): at most 50 ids a search. They are usually in the cache a few minutes later and then appear in `hotels_details` of the next search. - `status` is `complete` when every hotel has content, `partial` when some do not, `cache_miss` when none does. It is rare: 1 hotel of 15 in the example (and cached 9 minutes later), none of 73 around Los Angeles, none of 1,818 around Paris. **To get the card of a missing hotel now**, call [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) with that one id: ~~~json { "hotel_id": "comfort_inn_tegucigalpa", "detail_level": "minimal" } ~~~ It is the only content call that asks the supplier when FX-Port does not hold the hotel (about 0.3 s), and it keeps the hotel for everybody afterwards. It does this for **one** id per call. The calls that read many ids (`POST /hotels/search`, `POST /hotels/details`, `get_hotel_info` with `hotel_ids`) read the cache only: they leave out the ids they do not hold and do not report them. How to do it well: - Fill **only the hotels the user can see**, not all of them. Render the others with their price and a placeholder, or leave them out until their content is cached. - Make at most **five calls at a time**. The supplier answers about 30 hotel content calls a minute for the whole of FX-Port, so a `429`, `502` or `503` answer means you went too fast or the supplier is busy: back off. A `503 supplier_busy` was refused before anything was sent: wait the seconds of `Retry-After` and ask again. - Send `"language": "en"` when you fetch a hotel that is not cached. The content that is fetched is stored for every customer in the language of that call. - Remember the result for the hotel id, and remember a failure for a few minutes, so that the next search does not repeat the same calls. ## Images `images` holds URL **templates**. Replace `{size}` to get a picture: ~~~text https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg ~~~ | `{size}` | Use | Note | |---|---|---| | `120x120` | Thumbnails, map pins | | | `240x240` | Cards of a result list | 18 KB for the photo above | | `640x400` | A hotel page, the first screen | | | `1024x768` | A gallery, a full-width image | 228 KB for the photo above | | `x500` | 500 px high, the width follows | | These sizes answer `200`. `orig` does not (it redirects to a web page) and unknown sizes answer `400`. Choose the size by what you display: **one 1024x768 photo can weigh as much as 40 mini cards**, so the size you ask the CDN for matters more than the card. **JavaScript** ~~~js const photo = (template, size = "240x240") => template.replace("{size}", size); const card = search.hotels_details[0]; const thumbnail = card.images ? photo(card.images[0]) : "/no-photo.svg"; // images can be null ~~~ **Python** ~~~python def photo(template, size="240x240"): return template.replace("{size}", size) card = search["hotels_details"][0] thumbnail = photo(card["images"][0]) if card["images"] else "/no-photo.svg" # images can be null ~~~ `images_limit` (0 to 30) on the content calls (`POST /hotels/search`, `POST /hotels/details` and `get_hotel_info`) keeps only the first N photos of each mini card: `images_limit=1` removes about half of a mini card. It applies to the mini card only (`detail_level=minimal`, alias `mini`): with the full document it is ignored and the answer carries a `coerced_value` warning. It does not exist on `search_advanced`, which always returns every photo of every hotel. ## Reading hotel content Three calls read hotels by id. They read FX-Port's cache, they take up to 200 ids, and they answer a bare JSON array that does **not** follow the order of your ids; an id that is not cached is left out and queued for loading. An id can also be the supplier's numeric `hid`, as a JSON integer or as digits (`[8473727]`): `POST /hotels/search` and `POST /hotels/details` look an id up as an id and then as a `hid`, a hotel reached by both comes back once, and a digits-only id that matches nothing is not queued; `get_hotel_info` takes a `hid` too. Spaces around an id are trimmed. | Call | Body | Answer | |---|---|---| | `POST /hotels/search` | `{ "hotel_ids": [...] }` (a bare list still works) | Mini cards with `?detail_level=minimal`, full documents otherwise | | `POST /hotels/details` | the same | The stored documents (`?detail_level=minimal` gives mini cards) | | [`POST /hotels/get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) | `{ "hotel_id": "..." }` or `{ "hotel_ids": [...] }` | `{ "data": [...] }`, camelCase (mini cards with `detail_level`) | **Request** ```bash curl 'https://api.fx-port.com/api/v1/hotels/search?detail_level=minimal&images_limit=1' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_ids": [ "test_hotel_do_not_book", "hotel_honduras_maya_6" ] }' ``` | Query parameter | Default | Description | |---|---|---| | `detail_level` | `full` | `minimal` (alias `mini`) or `full`. Anything else answers `400 invalid_detail_level`. | | `fields` | none | A comma separated list of top-level fields (`id,name,star_rating`), at most 40. It replaces `detail_level`; `id` is always added. Nested paths are not supported. | | `images_limit` | none | With `detail_level=minimal` and no `fields`: the most photos of each hotel, 0 to 30 (another number answers `400 invalid_images_limit`). In any other case it is ignored and the answer carries a `coerced_value` warning. `POST /hotels/details` reads it too, with `detail_level=minimal`. | The answer carries `X-Results-Count`, `X-Detail-Level` and `X-Search-Time` headers. A body that is not a list of ids, or that holds more than 200 ids, answers `400 invalid_body`, and a malformed id `400 invalid_hotel_id`. See the reference: [`POST /hotels/search`](https://fx-port.com/docs/api/get-hotel-content.md), [`POST /hotels/details`](https://fx-port.com/docs/api/get-hotel-details.md) and [`POST /hotels/get_hotel_info`](https://fx-port.com/docs/api/get-hotel-info.md). ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `hotelIds`, `ids` (body) | `hotel_ids` | | `detailLevel` (query) | `detail_level` | | `imagesLimit` (query) | `images_limit` | ## Caching - **Cache content by hotel id.** A hotel's content changes rarely: a day is a safe default for a card, and a result list can use content that is a week old. Do not put the language in the key: cached content is **English whatever `language` you send** (`language` only applies to the live fetch of a hotel that is not cached, and to room names in `get_rates`). - **Never cache prices** beyond the search that returned them. `bookHash` values live 30 minutes, and a price can change at any time. - **Cache the card, not the answer.** Keep the fields you show and drop `amenity_groups` and all but the photos you display. - **Do not refetch on every search.** Search again only for prices; read content for the ids you have not seen. ## A results list in code The answer of `search_advanced` has the cards in `hotels_details` and the prices in `hotels_pricing`. Join them by hotel id, and treat the ids of `missing_hotel_ids` apart: **Python** ~~~python search = call("POST", "/hotels/search_advanced", json=request) cards = {card["id"]: card for card in search["hotels_details"]} rows = [] for hotel_id, price in search["hotels_pricing"].items(): card = cards.get(hotel_id) # None for a hotel in missing_hotel_ids rows.append({ "id": hotel_id, "name": card["name"] if card else None, "stars": card["star_rating"] if card else None, "photo": card["images"][0].replace("{size}", "240x240") if card and card["images"] else None, "price": price["final_price"], "currency": price["agency_currency"], }) rows.sort(key=lambda row: row["price"]) to_fill = [row["id"] for row in rows[:20] if row["name"] is None] # only what is visible, five at a time ~~~ **JavaScript** ~~~js const search = await call("POST", "/hotels/search_advanced", request); const cards = new Map(search.hotels_details.map((card) => [card.id, card])); const rows = Object.entries(search.hotels_pricing).map(([id, price]) => { const card = cards.get(id); // undefined for a hotel in missing_hotel_ids return { id, name: card?.name ?? null, stars: card?.star_rating ?? null, photo: card?.images?.[0]?.replace("{size}", "240x240") ?? null, price: price.final_price, currency: price.agency_currency, }; }); rows.sort((a, b) => a.price - b.price); const toFill = rows.slice(0, 20).filter((row) => row.name === null).map((row) => row.id); // only what is visible ~~~ ## Related - [Geo search](https://fx-port.com/docs/hotels/search.md): The search that returns the mini cards and the prices. - [Hotel details](https://fx-port.com/docs/hotels/hotel-details.md): `get_hotel_info`, the call for the hotel page and for hotels without content. - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): Rooms, meals and cancellation terms for the stay. - [Rate limits](https://fx-port.com/docs/rate-limits.md): How many calls a key may make, and how to stay below. --- # Guests and rooms > How to book multiple rooms, and pass residency, the number of travelers and children ages for hotels. Source: https://fx-port.com/docs/hotels/guests-and-rooms How a hotel request says **who travels**: the rooms, the adults, the children and their ages, the residency, and later the names. These rules are specific to hotels. See [Flight passengers](https://fx-port.com/docs/flights/passengers.md) for the flight equivalent. ## The `guests` array: one object per room [`search_advanced`](https://fx-port.com/docs/hotels/search.md) and [`get_rates`](https://fx-port.com/docs/hotels/rates.md) take a `guests` array. **Each object is one room**: the number of rooms is the number of objects. There is no separate room count. ~~~json { "adults": 2, "children": [8] } ~~~ | Field | Type | Required | Description | |---|---|---|---| | `adults` | integer | Yes | Adults in this room, 1 to 6. | | `children` | array of integers | No | One entry per child in this room: the child's **age in years on the check-in day**. `[]` or absent means no children. At most 4. | A number written as text (`"adults": "2"`, a child age `"8"`) is read as the number, with a `coerced_value` warning; an integral float such as `2.0` is the same number. A boolean, `NaN`, a fraction or another text keeps the error of its field (see [Values the API reads for you](https://fx-port.com/docs/hotels/search.md#values-the-api-reads-for-you)). **1 room, 2 adults** ~~~json { "guests": [ { "adults": 2, "children": [] } ] } ~~~ **2 rooms with a child** ~~~json { "guests": [ { "adults": 2, "children": [8] }, { "adults": 1, "children": [] } ] } ~~~ Room 1: 2 adults and a child aged 8. Room 2: one adult travelling alone. **3 rooms, mixed** ~~~json { "guests": [ { "adults": 2, "children": [3, 13, 4] }, { "adults": 2, "children": [] }, { "adults": 1, "children": [] } ] } ~~~ Room 1: 2 adults and 3 children aged 3, 13 and 4. Room 2: a couple. Room 3: a single traveler. In total 5 adults and 3 children in 3 rooms. ## Rates are for exactly this occupancy The rooms you send decide which rates exist and what they cost. **Send the same `guests` and the same `residency` to `search_advanced` and to `get_rates`**, and book with the guest list of the same rooms (see [below](#guest-names-at-booking-time)). If the user changes a room, a child or the residency, ask for the rates again: a `book_hash` is only good for the occupancy it was priced for. ## Children - The age is an **integer from 0 to 17**, the age on the check-in day (a child who turns 9 during the stay is 8). - Hotels set their own child rules: the age up to which a child stays free, whether an extra bed or a cot costs something, the meals for children. [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) returns them in `metapolicyStruct` (`children`, `cot`, `extraBed`, `childrenMeal`): read them before you tell a traveler that a child is free. - A child is a guest of the room like an adult when you **book**: one entry per child, flagged with `is_child` and `age`. ## Constraints | Rule | Limit | Error code | |---|---|---| | `guests` is a non-empty list of objects | | `invalid_guests` | | Rooms in a request | 1 to 9 | `too_many_rooms` | | Adults in a room | 1 to 6 (a room cannot hold children only) | `invalid_adult_count` | | Children in a room | at most 4 | `too_many_children` | | Age of a child | 0 to 17, a whole number | `invalid_child_age` | | Guests in a room, adults and children together | at most 10 | `too_many_guests` | They answer `400`, before the supplier is called. A room can still have **no rate** because of its real capacity: the supplier only offers the rooms that can take the occupancy, so an empty `rates` list means that no room fits or that the hotel has no availability. ## Residency `residency` is the country of **residence of the guests**, a two-letter ISO 3166-1 code (`gb`, `dz`, `fr`; upper or lower case). It is sent **once per request**, not per room or per guest. It decides which rates exist, because some rates are for some residencies only (`anyResidency: false` on the rate), and it can change the price. - [`get_rates`](https://fx-port.com/docs/hotels/rates.md) uses `gb` when you send none. [`search_advanced`](https://fx-port.com/docs/hotels/search.md) has **no default**: always send it, and send the same one to both calls. - A code the supplier does not know answers `400 invalid_residency` (`zz`, `usa`, `gbr`, an empty text). - The **sub-territory** values of the website's nationality list (`GB-SCT`, `USPR`, `ES-XCI`, `PT-MA`, `PT-AZ`, `CY-NC`, `GP-MG`, `BQ-SA`, `BQ-SE`) are read as their country (`gb`, `us`, `es`, `pt`, `cy`, `gp`, `bq`), with a `coerced_value` warning. Send the 2-letter country. - In the sandbox, `test_hotel` can only be searched with `dz`, `ru` or `us`: see [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md). ## Guest names at booking time [`book_hotel`](https://fx-port.com/docs/hotels/book.md) takes a `rooms` array: **one object per room, in the same order as the rooms you searched**. Each room lists the real guests who sleep in it, **one entry per adult and per child**: ~~~json { "rooms": [ { "guests": [ { "first_name": "Amine", "last_name": "Benali" }, { "first_name": "Sara", "last_name": "Benali" }, { "first_name": "Yanis", "last_name": "Benali", "is_child": true, "age": 8 } ] } ] } ~~~ > [!WARNING] > **A child needs two more fields** > > A child is sent like any guest **plus `"is_child": true` and `"age"`**. A child sent as a plain guest, or a room whose guests do not add up to the occupancy you searched, is refused by the supplier with `400` (`incorrect_guests_number`) and nothing is booked. | Field | Type | Required | Description | |---|---|---|---| | `first_name`, `last_name` | string | Yes | Up to 100 characters each. See the rules below. | | `is_child` | boolean | For a child | `true` for a child. | | `age` | integer | For a child | The age in years on the check-in day, 0 to 17. The same age you searched with. A number written as text is read, with a `coerced_value` warning. | ### How names are cleaned The supplier refuses digits and most symbols in a name, so FX-Port cleans each name **before** it sends it: letters of **any script** (Latin with accents, Arabic, Cyrillic, Chinese ...), spaces, hyphens, commas, periods and apostrophes are kept, and anything else (digits, slashes, brackets, underscores ...) is replaced by a space and the spaces are collapsed. `Ben2` becomes `Ben` and `Smith/Jr` becomes `Smith Jr`. Accented and non-Latin names are accepted: bookings with `José Núñez`, `Zoë O'Brien-Müller` and Arabic names were confirmed in the sandbox. Send each name as it is written in the traveler's passport. ASCII letters are not required. See [`book_hotel`](https://fx-port.com/docs/hotels/book.md) for the whole booking request, including the contact details. ## Related - [Geo search](https://fx-port.com/docs/hotels/search.md): Where the rooms are first sent. - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): The rates for the occupancy. - [Book a hotel](https://fx-port.com/docs/hotels/book.md): The guest list of each room. - [Flight passengers](https://fx-port.com/docs/flights/passengers.md): The same idea for flights. --- # Hotel pricing > What each hotel price field means, what your agency is debited (totalPrice minus agencyCommission), and how taxes and the exchange rate are shown. Source: https://fx-port.com/docs/hotels/pricing Every hotel price is a **whole-stay price for all the rooms**, in the **currency of your agency** (the sandbox agency settles in DZD). There is no per-night amount except the supplier's `dailyPrices`. Three numbers matter: | Amount | What it is | |---|---| | **`totalPrice`** (`final_price` in a search) | What the traveler pays. Show this price. | | **`agencyCommission`** | Your commission: the part of `totalPrice` that stays with your agency. | | **What you pay** (`b2b_price` in a search, `netPrice` in a booking) | What is debited from your agency balance when you book: `totalPrice - agencyCommission`. | ~~~text totalPrice = what you pay + agencyCommission what you pay = totalPrice - agencyCommission ~~~ > [!WARNING] > **b2bPrice is not in the rate, pre-book and booking answers** > > Only the search (`hotels_pricing[].b2b_price`) and the booking detail (`netPrice`, `pricing[].b2b_price`) carry the amount you pay. The `pricing` blocks of [`get_rates`](https://fx-port.com/docs/hotels/rates.md), [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md) and [`book_hotel`](https://fx-port.com/docs/hotels/book.md) do not: compute it as `totalPrice - agencyCommission`. ## A real example The cheapest rate of the sandbox test hotel for three nights, from `get_rates` (the supplier prices in EUR, the agency settles in DZD): ~~~json { "exchangeRate": 285, "pricing": { "supplierCurrency": "EUR", "agencyCurrency": "DZD", "originalPrice": 3, "agencyCommission": 2.97, "totalPrice": 1484.97, "taxes": { "cityTax": { "includedInPrice": false, "amount": 23.1, "currency": "HNL" }, "electricityFee": { "includedInPrice": true, "amount": 0.6, "currency": "EUR" }, "resortFee": { "includedInPrice": true, "amount": 0.68, "currency": "EUR" } } } } ~~~ - The traveler pays **1,484.97 DZD** (`totalPrice`). - Your commission is **2.97 DZD** (`agencyCommission`). - You are debited **1,482.00 DZD** (`1484.97 - 2.97`). The sandbox does not debit anything: `book_hotel` answers `balanceDebited: false` there. - The same hotel and the same rate in a `search_advanced` answer, in the search's own names: ~~~json { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 2.97, "final_price": 1484.97, "b2b_price": 1482 } ~~~ > [!NOTE] > `originalPrice` is the supplier's own price in EUR (3.00 here), for information. **Do not compute your prices from it**: what you pay is not `originalPrice × exchangeRate`, because it also holds FX-Port's own fees, which are not itemised. Use `totalPrice` and `agencyCommission`. ## Where each price is | Where | Fields | |---|---| | [`search_advanced`](https://fx-port.com/docs/hotels/search.md): `hotels_pricing[hotel_id]` | `final_price`, `agency_commission`, `b2b_price`, `agency_currency`, `exchange_rate`. The price of the **cheapest rate**. | | [`get_rates`](https://fx-port.com/docs/hotels/rates.md) and [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md): `rates[].pricing` | `totalPrice`, `agencyCommission`, `originalPrice`, `supplierCurrency`, `agencyCurrency`, `taxes`. And next to it, **outside** `pricing`: `rates[].exchangeRate` and `rates[].dailyPrices`. | | [`book_hotel`](https://fx-port.com/docs/hotels/book.md): `pricing` | `totalPrice`, `agencyCommission`, `currency`, `supplierCurrency`, `originalPrice`. | | [Booking detail](https://fx-port.com/docs/account/booking-detail.md) and [history](https://fx-port.com/docs/account/history.md) | `totalPrice`, `netPrice` (what was debited), `agencyCommission`, `currency`, and `pricing[].b2b_price`. | The names differ between the search (snake_case) and the rest (camelCase): `final_price` is `totalPrice`, `b2b_price` is `netPrice`, `agency_currency` is `agencyCurrency`. The camelCase blocks also carry snake_case twins (`total_price`, `agency_commission`, `agency_currency`, `supplier_currency`, `exchange_rate`): see [Legacy names](https://fx-port.com/docs/legacy-names.md). Amounts are rounded to two decimals. ## The fields of a `pricing` block | Field | Meaning | |---|---| | `supplierCurrency` | The currency the supplier prices in: `EUR`. | | `agencyCurrency` | The currency of your agency. Every amount of the block, except `originalPrice` and `taxes`, is in it. | | `originalPrice` | The supplier's price for the stay, in `supplierCurrency`. For information only. | | `agencyCommission` | Your commission, in `agencyCurrency`. | | `totalPrice` | What the traveler pays, in `agencyCurrency`. | | `taxes` | The taxes and fees of the rate, by name. See [Taxes](#taxes). | Internal fields of the cost build-up (FX-Port's own commission and the supplier's cost lines) are never returned: do not build on a field that is not listed here. ## Your commission `agencyCommission` comes from the commission rule of your agency. To set it for one booking, send `commission_override` to [`book_hotel`](https://fx-port.com/docs/hotels/book.md#commission-override): `{ "type": "fixed", "value": 50 }` is 50 in your currency **per room**, `{ "type": "percentage", "value": 5 }` is 5% of what you pay. What you are debited does not change: the traveler price becomes what you pay plus the new commission. ## The exchange rate `exchangeRate` says how many units of your agency currency one unit of the supplier's currency is worth (`285.0`: 1 EUR is 285 DZD). It is on each rate of `get_rates` and `pre_book` (outside `pricing`), and on each hotel of `hotels_pricing`. Use it only to **convert supplier-currency amounts for display**, such as taxes and penalties; never to recompute a price. ## Taxes `taxes` lists the taxes and fees of the rate by name (`cityTax`, `resortFee`, `electricityFee`, `vat` ...). Each one has `includedInPrice`, `amount` and `currency`: - **`includedInPrice: true`**: already inside `totalPrice`. Show it as information. - **`includedInPrice: false`**: **the traveler pays it at the hotel**, on top of `totalPrice`. Show it clearly: it is not charged to your agency. - **`amount` is never converted.** It is in `currency`, which is the supplier's currency (`EUR`) or the **local currency of the hotel** (`HNL` for a hotel in Honduras). Convert it with care, or show it as it is. ## Cancellation penalties The `cancellationPolicy.policies[]` windows carry the **supplier's** penalty: `originalAmount` in `originalCurrency` (EUR). The `currency` field of a window is only the **label** of your agency currency: `originalAmount` is **not** in it. There is no penalty in your currency in the rate. Show the penalty as the supplier states it, or as a share of the stay (`originalAmount ÷ originalPrice`) and say that the exact amount is known when the booking is cancelled: the answer of [`cancel_hotel`](https://fx-port.com/docs/hotels/cancel.md) gives what is refunded to your agency and to the traveler. ## Related - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): Where the prices of the rooms come from. - [Pre-book a rate](https://fx-port.com/docs/hotels/pre-book.md): Lock a price, and what a price change looks like. - [Book a hotel](https://fx-port.com/docs/hotels/book.md): The price as it is debited, and `commission_override`. - [Cancel a booking](https://fx-port.com/docs/hotels/cancel.md): What is refunded, and when. --- # Geo search > Search and price every hotel around a point (latitude, longitude and radius), or one hotel by id: a compact card and the cheapest price per hotel. Endpoint: `POST /api/v1/hotels/search_advanced` Source: https://fx-port.com/docs/hotels/search **Geo search** lists the hotels **around a point** with their prices. Send a `latitude`, a `longitude` and a `radius` with the dates and the guests: the answer has a compact **mini card** for each hotel and the **price of its cheapest rate** for the stay. The same call, `POST /api/v1/hotels/search_advanced`, also searches **one hotel by id**. It returns no rooms, meals or cancellation terms: call [`get_rates`](https://fx-port.com/docs/hotels/rates.md) for the hotel the user picks. **Permission:** Read > [!WARNING] > **Sandbox: any hotel can be searched, only the test hotels can be booked** > > The search runs on real hotels and real prices in the sandbox. Booking is possible only on `test_hotel_do_not_book` and `test_hotel`: see [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md). ## Search hotels around a point `POST /api/v1/hotels/search_advanced` **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/search_advanced \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "latitude": 48.8566, "longitude": 2.3522, "radius": 1000, "checkin": "2026-12-01", "checkout": "2026-12-04", "residency": "gb", "guests": [ { "adults": 2, "children": [] } ] }' ``` These seven fields are all it needs. `radius` is in **meters** (100 to 70,000) and has **no default**. A big city is a big answer: 5 km around the center of Paris measured 1,818 hotels and 8.5 MB. Keep the radius to what your map shows ([Size and speed](#size-and-speed)). The answer is one JSON body with two parts that you **join by hotel id**: ~~~text status complete | partial | cache_miss | no_hotels_found hotels_details[] the mini card of each hotel: id, name, address, latitude, longitude, star_rating, images, amenities (no price) hotels_pricing { "": { final_price, b2b_price, agency_commission, agency_currency, exchange_rate } } the cheapest rate of each hotel for the whole stay missing_hotel_ids the hotels that have a price but no card yet search_id, processing_status, queue_info, timing ~~~ Every field is described in [Response](#response), the cards in [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md). ## How to get the coordinates Ask [`locations/search`](https://fx-port.com/docs/hotels/locations.md) for the place the user typed, then take the `latitude` and `longitude` of the place they pick (for example `locations[0]`): **Request** ```bash curl 'https://api.fx-port.com/api/v1/hotels/locations/search?query=Paris' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` [Search locations](https://fx-port.com/docs/hotels/locations.md) shows the answer and how to let the user choose between several places. To search around a hotel you know, use its own `latitude` and `longitude` (in its [mini card](https://fx-port.com/docs/hotels/hotel-data.md)). ## Search one hotel by id To price **one hotel** whose id you know (from [`name-search`](https://fx-port.com/docs/hotels/name-search.md), an earlier search or a booking), send `hotel_id` instead of the coordinates and the radius: **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/search_advanced \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "checkin": "2026-12-01", "checkout": "2026-12-04", "residency": "gb", "guests": [ { "adults": 2, "children": [] } ] }' ``` The answer has the same shape with one card and one price, in under a second. If you send both a `hotel_id` and coordinates, `hotel_id` wins and the coordinates are ignored. A search of `test_hotel` with a residency other than `dz`, `ru` or `us` answers `no_hotels_found`. ## Request body | Field | Type | Required | Default | Description | |---|---|---|---|---| | `latitude` | number | For a geo search | | -90 to 90, from [`locations/search`](https://fx-port.com/docs/hotels/locations.md). | | `longitude` | number | For a geo search | | -180 to 180. | | `radius` | integer | For a geo search | none | Meters, 100 to 70,000. **There is no default**: a geo search without a radius cannot be priced. A value outside the range is moved to the nearest bound, and the answer carries a `coerced_value` warning. | | `hotel_id` | string | For a search of one hotel | | The FX-Port id of the hotel, from `name-search` or from an earlier search. | | `checkin` | string | Yes | | Check-in date, `YYYY-MM-DD`. From yesterday (for time zones) to 400 days ahead. | | `checkout` | string | Yes | | Check-out date, after `checkin`. A stay is 1 to 30 nights. | | `guests` | array | Yes | | One `{ adults, children }` object per room, 1 to 9 rooms. See [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md). | | `residency` | string | No, but send it | none | The residency of the guests, a two-letter country code (`gb`, `dz`, `fr`). It decides which rates exist: some are for some residencies only. The search does not default it, unlike [`get_rates`](https://fx-port.com/docs/hotels/rates.md), which defaults to `gb`. | | `language` | string | No | `en` | Language of the supplier's answer. The hotel content in the answer is English whatever you send. | > [!CAUTION] > Do not send a `currency` field: it is refused with `400`. Prices come in the currency of your agency, resolved from your key. Several rooms: one `{ adults, children }` object per room, with the age of each child: **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/search_advanced \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "latitude": 48.8566, "longitude": 2.3522, "radius": 1500, "checkin": "2026-12-10", "checkout": "2026-12-12", "residency": "gb", "guests": [ { "adults": 2, "children": [ 8 ] }, { "adults": 1, "children": [] } ] }' ``` ### Legacy names These older spellings still work. The answer carries a `warnings` entry with the code `deprecated_field` that names the spelling to use (and the headers `Deprecation` and `X-FX-Deprecated-Fields`). See [Legacy names](https://fx-port.com/docs/legacy-names.md). | Older name | Use instead | |---|---| | `id`, `hotelId` | `hotel_id` | | `check_in`, `checkin_date`, `checkinDate` | `checkin` | | `check_out`, `checkout_date`, `checkoutDate` | `checkout` | ## Values the API reads for you A value that a real client writes in another well-defined way is **read, not refused**, and the answer carries a `coerced_value` warning that says what was read (see [Conventions](https://fx-port.com/docs/conventions.md#a-previous-spelling-still-works-and-says-so)). Send the documented form to avoid the warning. | You send | The API reads | |---|---| | `"radius": 600000` or `50` | `70000` or `100`: a radius outside 100 to 70,000 meters is moved to the nearest bound | | A number written as text: `"radius": "15000"`, `"latitude": "14.09"`, `"adults": "2"`, a child age `"8"` | The number. An integral float (`2.0`) is the same number and has no warning | | `"checkin": "2026-12-01T00:00:00Z"`, a date with a **midnight** time | `2026-12-01`. Any other time of day is `400 invalid_date` | | `"residency": "GB-SCT"`, `"USPR"`, `"ES-XCI"`, `"PT-MA"`, the sub-territories of the website's nationality list | The country: `gb`, `us`, `es`, `pt`. Send the 2-letter country | | `"language": "zh-Hans-CN"`, `"es-419"`, `"sr-Latn-RS"` | `zh_CN`, `es`, `sr` | | `"hotel_id": 8473727`, a JSON integer, or an id with spaces around it | The text `"8473727"`, or the trimmed id. A number is **not** looked up as the supplier's numeric `hid` here: send the FX-Port id (the [content calls](https://fx-port.com/docs/hotels/hotel-data.md#reading-hotel-content) do look a `hid` up) | | `"hotel_id": null` next to `latitude` and `longitude` | A search around a point (a `null` alone is `400 missing_field`) | What cannot be read keeps its error code: a boolean or `NaN` where a number is wanted, `1e3`, a fraction where a whole number is wanted, `usa`, `gbr`, `en-USA`, another time of day. [`get_rates`](https://fx-port.com/docs/hotels/rates.md) reads the same values. ## Response The answer is **always one JSON body**. It is a real, trimmed answer of the sandbox (a geo search with a 4 km radius around Nuuk: five hotels were priced, the answer is cut to two here). A search of one hotel answers the same keys with one card: ~~~json { "environment": "sandbox", "search_id": "71498fa9-d3a7-4b6c-81bd-76693be726d1", "status": "complete", "hotels_details": [ { "id": "hotel_nordbo_2", "hid": 10048834, "name": "Hotel Nordbo", "kind": "Hotel", "address": "Vandsovej 13, Nuuk, 3900, Greenland, Nuuk", "latitude": 64.17923, "longitude": -51.73019, "postal_code": null, "region": { "id": 1359, "country_code": "GL", "iata": "GOH", "name": "Nuuk", "type": "City", "type_v2": "city" }, "star_rating": 0, "images": [ "https://cdn.worldota.net/t/{size}/content/16/be/16be5aea51c0060686286cf8c886e4e07e1e5dce.jpeg", "https://cdn.worldota.net/t/{size}/content/b1/98/b198dca0393650833ee449c3e460b2af85a04dda.jpeg" ], "serp_filters": [ "has_parking", "has_meal", "has_pets" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Smoke-free property", "Ticket assistance" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Breakfast", "Restaurant" ], "non_free_amenities": [], "group_name": "Meals" } ] }, { "id": "hhe_express", "hid": 9168363, "name": "HHE Express Hotel", "kind": "Hotel", "address": "Aqqusinersuaq 14B, Nuuk", "latitude": 64.17208, "longitude": -51.73649, "postal_code": "3900", "region": { "id": 1359, "country_code": "GL", "iata": "GOH", "name": "Nuuk", "type": "City", "type_v2": "city" }, "star_rating": 3, "images": [ "https://cdn.worldota.net/t/{size}/content/b5/39/b539a20fd8349ef419e73802ced9a04215155205.JPEG", "https://cdn.worldota.net/t/{size}/content/41/97/4197c95a76a3ada2539a0fe169e7e3d7df3e80f9.jpeg" ], "serp_filters": [ "has_internet", "has_parking" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Elevator/lift", "Smoke-free property" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Free Wi-Fi" ], "non_free_amenities": [], "group_name": "Internet" } ] } ], "processing_status": { "total_hotels": 5, "cached_hotels": 5, "queued_hotels": 0 }, "queue_info": { "queued_hotel_ids": [], "estimated_completion": null }, "timing": { "total_duration": 0.84, "search_duration": 0.82, "cache_check_duration": 0.02 }, "hotels_pricing": { "hotel_nordbo_2": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 438.68, "final_price": 219774.68, "b2b_price": 219336 }, "hhe_express": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 228.83, "final_price": 114639.23, "b2b_price": 114410.4 } }, "missing_hotel_ids": [], "missing_hotels_pricing": {} } ~~~ | Field | Description | |---|---| | `search_id` | The id of the search, also in the `X-Search-Id` header. Quote it when you write to support. There is nothing to pass on to the next call. | | `status` | `complete`: every priced hotel has a card. `partial`: some do not (see `missing_hotel_ids`). `cache_miss`: none has. `no_hotels_found`: nothing is available for the request. | | `hotels_details[]` | The **mini card** of each hotel that has cached content: 13 fields (id, name, address, position, stars, photos, filters, amenities). No rate, no room, no price. See [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) and [The Hotel mini object](https://fx-port.com/docs/api/objects/hotel-mini.md). | | `hotels_pricing` | The price of the **cheapest rate** of every priced hotel, by hotel id: `final_price` (what the traveler pays for the whole stay and all rooms), `agency_commission`, `b2b_price` (`final_price - agency_commission`, what your agency is debited), `agency_currency` and `exchange_rate`. See [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md). | | `missing_hotel_ids` | The hotels that are priced but have no card yet. | | `missing_hotels_pricing` | The price entry of each of them, the same fields as `hotels_pricing`. | | `processing_status` | `total_hotels`, `cached_hotels` and `queued_hotels`. | | `queue_info` | The ids that FX-Port is loading in the background (`queued_hotel_ids`) and a rough delay (`estimated_completion`, `estimated_completion_seconds`). | | `timing` | Seconds spent: `total_duration`, `search_duration` (the supplier) and `cache_check_duration`. | | `truncated`, `max_hotels`, `total_hotels_found` | Present only when the supplier found more than 5,000 hotels and the rest was cut off. Narrow the search. | | `note` | A hint about an empty answer, for example that `test_hotel` has no availability for the residency. | The answer carries the headers `X-Search-Id`, `X-Search-Status`, `X-Search-Type` (`hotel_id` or `geo`), `X-Total-Hotels`, `X-Available-Hotels`, `X-Search-Time` and `X-Environment`. ### Join the cards and the prices `hotels_details` and `hotels_pricing` are separate: join them by hotel id. A hotel that has a price and no card is in `missing_hotel_ids`: ~~~json { "environment": "sandbox", "search_id": "4a6877dc-96da-4f32-8c1b-4aaed8b60fe4", "status": "partial", "hotels_details": [ { "id": "hotel_honduras_maya_6", "hid": 6329711, "name": "Hotel Honduras Maya", "kind": "Hotel", "address": "Colonia Palmira 3era calle ave, Tegucigalpa", "latitude": 14.103492, "longitude": -87.19721, "postal_code": "11101", "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" }, "star_rating": 3, "images": [ "https://cdn.worldota.net/t/{size}/content/1d/67/1d67eefb8685fb4b809624bb4f6c437592722183.JPEG", "https://cdn.worldota.net/t/{size}/content/6a/0e/6a0ebfe39089a02e1e6da4a03eb6200047545ba1.JPEG" ], "serp_filters": [ "has_internet", "has_airport_transfer", "has_parking", "has_kids", "has_pool" ], "amenity_groups": [ { "amenities": [ "ATM", "Air conditioning", "24-hour reception" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Bridal suite", "Room service", "Interconnecting rooms available" ], "non_free_amenities": [], "group_name": "Rooms" } ] } ], "processing_status": { "total_hotels": 15, "cached_hotels": 14, "queued_hotels": 1 }, "queue_info": { "queued_hotel_ids": [ "comfort_inn_tegucigalpa" ], "estimated_completion": "about 9 minutes", "estimated_completion_seconds": 541 }, "timing": { "total_duration": 4.38, "search_duration": 4.33, "cache_check_duration": 0.05 }, "hotels_pricing": { "hotel_honduras_maya_6": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 285.14, "final_price": 142853.54, "b2b_price": 142568.4 }, "comfort_inn_tegucigalpa": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 218.75, "final_price": 109590.36, "b2b_price": 109371.61 } }, "missing_hotel_ids": [ "comfort_inn_tegucigalpa" ], "missing_hotels_pricing": { "comfort_inn_tegucigalpa": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 218.75, "final_price": 109590.36, "b2b_price": 109371.61 } } } ~~~ Show such a hotel with its price and a placeholder, or fetch its card with [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) (`detail_level` `minimal`, one id per call, five at a time). The recipe is in [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md#hotels-without-content). ## Size and speed There is **no pagination**, and the price list is part of the same answer. Measured on the sandbox (2026-11-01, the search call only): | Search | Hotels | Answer | Time | |---|---|---|---| | One hotel | 1 | 2.9 KB (1.2 KB gzip) | 0.4 to 0.7 s | | Nuuk, 4 km | 5 | 20 KB (5.7 KB gzip) | 1.5 s | | Tegucigalpa, 15 km | 15 | 82 KB (17 KB gzip) | 4.1 s | | Los Angeles, 5 km | 73 | 362 KB (79 KB gzip) | 9.6 s | | Paris, 5 km | 1,818 | 8.5 MB (1.7 MB gzip) | 19.3 s | About 93% of a large answer is `hotels_details`. So: **keep the radius to what the screen needs**, send `Accept-Encoding: gzip`, show the first hotels and read the rest in your own pages, and do not run the same search again for each page. One search answers at most 5,000 hotels. Identical searches of your agency that arrive at the same moment are merged into one supplier call; each caller still gets its own `request_id` and headers. ## No streaming for API keys `?stream=true` and `Accept: text/event-stream` are **ignored** for API keys: the answer is always one JSON body (`Content-Type: application/json`). Server-sent events exist for FX-Port's own dashboard only. Streaming of flight searches is described in [Streaming](https://fx-port.com/docs/streaming.md). ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `missing_field` | A required field is missing: `checkin`, `checkout` or `guests`; there is neither `hotel_id` nor `latitude` and `longitude`; or a search around a point has no `radius`. `errors[0].field` names the first missing field (always the current name: `hotel_id`, not `id`) and `errors[0].details.missing_fields` lists all of them when several are missing. The message is the older sentence (`Missing required fields for hotel_id search: ['guests']`). | | `400` | `bad_request` | You sent a `currency` field. | | `400` | `invalid_date`, `date_in_past`, `date_too_far`, `checkout_before_checkin`, `stay_too_long` | The dates are not a real stay: a wrong date, in the past, more than 400 days ahead (`errors[0].details.max_days_ahead` says 400), `checkout` not after `checkin`, or more than 30 nights. | | `400` | `invalid_guests`, `invalid_adult_count`, `too_many_children`, `invalid_child_age`, `too_many_guests`, `too_many_rooms` | The rooms do not follow [the rules of guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md#constraints). | | `400` | `invalid_latitude`, `invalid_longitude`, `invalid_radius` | A coordinate is out of range, or the radius is not a whole number. | | `400` | `invalid_residency`, `invalid_language`, `invalid_hotel_id` | The code is not one the supplier knows. | | `400` | `invalid_json`, `invalid_body` | The body is not a JSON object. | | `403`, `401` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | | `422` | `supplier_rejected_request` | The supplier refused well formed parameters (`errors[0].type` is `supplier_error`; `errors[0].details.supplier_code` has its code when it is a plain one). Change the dates, the guests, the residency or the radius. | | `429` | `rate_limit_exceeded` | More than 6,000 searches a minute for the key: [Rate limits](https://fx-port.com/docs/rate-limits.md). With `errors[0].type` `supplier_error` the code says that the hotel supplier is receiving too many searches: retry in a minute. | | `502` | `bad_gateway` | The supplier could not complete the search (`errors[0].type` is `supplier_error`). Retry in a few minutes with backoff. | | `503` | `supplier_busy` | The supplier is at capacity for searches. The call was **refused before anything was sent**: retry after the seconds in `Retry-After`. | A `200` with `status: "no_hotels_found"` is not an error: nothing is available for the request (also when a hotel exists but not for this residency). ## Gotchas - **`hotels_pricing` is the cheapest rate**, not a quote. It has no room, meal or cancellation terms. The rates of the hotel come from [`get_rates`](https://fx-port.com/docs/hotels/rates.md), which returns prepaid rates only, so in rare cases the cheapest rate there is higher. - **The prices are for the whole stay and all rooms**, in the currency of your agency. There is no price per night. - **Send the same `residency` and `guests` to `get_rates`** as to the search, or the rates will differ. - **A search is not a booking step.** `get_rates` makes its own search of the hotel; no id of the search is needed to book. ## Related - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md): The cards of the answer, the full document and the hotels without a card. - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): The rooms and rates of the hotel the user picked. - [Search locations](https://fx-port.com/docs/hotels/locations.md): Coordinates for a typed destination, the input of the geo search. - [API reference: Geo search](https://fx-port.com/docs/api/search-hotels.md): Every field and error. --- # Search locations > Search cities and destinations by name and get back usable geo coordinates for hotel availability search. Endpoint: `GET /api/v1/hotels/locations/search` Source: https://fx-port.com/docs/hotels/locations Turn what a user typed (a city, an area, an airport) into places with coordinates. The `latitude` and `longitude` of the place the user picks are the input of the [geo search](https://fx-port.com/docs/hotels/search.md) (`search_advanced` with a `radius`), which lists the hotels around that point. **Permission:** Read > [!NOTE] > This lookup finds **places**, not FX-Port hotels. To find one hotel by its name, use [Hotel name search](https://fx-port.com/docs/hotels/name-search.md). The `id` of a place is an id of the location provider and is **never** a hotel id you can send to `get_rates`. ## Query parameters `GET /api/v1/hotels/locations/search` | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `query` | string | Yes | | The text to look for: a city, an area, an airport or a hotel name, 1 to 64 characters once spaces and control characters are cleaned. | | `locale` | string | No | `en` | Language of the names: `en`, `fr`, `ar`, `es`, `de`, `it`, `ru`, `tr`, `ja`, `ko` ... Written `ll` or `ll-RR` (`es-AR`); any other value answers in English. | | `limit` | integer | No | `10` | Most places to return, 1 to 50. Places without coordinates are dropped before the limit is applied. | ## Request example **Request** ```bash curl 'https://api.fx-port.com/api/v1/hotels/locations/search?query=Tegucigalpa&limit=3' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response example A real answer of the sandbox, with the `raw` objects shortened: ~~~json { "environment": "sandbox", "success": true, "query": "Tegucigalpa", "resultsCount": 2, "locations": [ { "id": "20119", "name": "Tegucigalpa", "displayName": "Tegucigalpa, Honduras", "city": "Tegucigalpa", "countryCode": "HN", "latitude": 14.094204124743296, "longitude": -87.19410705810549, "type": "location", "raw": { "id": "20119", "displayname": "Tegucigalpa, Honduras", "loctype": "city", "cid": 107, "lat": 14.094204124743296, "lng": -87.19410705810549, "cc": "HN", "country": "Honduras", "timezone": "America/Tegucigalpa", "utc": "-06:00", "ap": "TGU" } }, { "id": "TGU", "name": "Toncontin", "displayName": "Toncontin, Tegucigalpa, Honduras, (TGU)", "city": "Tegucigalpa", "countryCode": "HN", "latitude": 14.06088, "longitude": -87.2172, "type": "location", "raw": { "id": "TGU", "displayname": "Toncontin, Tegucigalpa, Honduras, (TGU)", "loctype": "ap", "cid": 107, "lat": 14.06088, "lng": -87.2172, "cc": "HN", "country": "Honduras", "airportname": "Toncontin", "ap": "TGU" } } ] } ~~~ | Field | Description | |---|---| | `success` | `true` when the lookup worked, also when nothing matches (`resultsCount` is then `0` and `locations` empty). | | `resultsCount` | How many places are in `locations`. | | `locations[].id` | The id of the place at the location provider: a city id, an airport code or a hotel id of the provider. Not a FX-Port hotel id. | | `locations[].name`, `displayName`, `city`, `countryCode` | What to show in the list. `displayName` carries the context (`Tegucigalpa, Honduras`). | | `locations[].latitude`, `longitude` | The only fields the [geo search](https://fx-port.com/docs/hotels/search.md) needs: send them as its `latitude` and `longitude`. | | `locations[].type` | **Always** `location`. The kind of place is `raw.loctype`: `city`, `ap` (airport) or `hotel`. | | `locations[].raw` | The record of the location provider, about 40 keys (`loctype`, `cityname`, `timezone`, the bounding box `box_*` ...). It is passed on as it is: unlisted keys can change, do not build on them. | ## From a place to a geo search 1. Show `displayName` for each place and let the user pick one. Results mix cities, airports and hotels: show an icon from `raw.loctype` if the list is long. 2. Send the `latitude` and `longitude` of the place to the [geo search](https://fx-port.com/docs/hotels/search.md) (`search_advanced`) with a `radius` in meters. The radius is **mandatory** and goes from 100 to 70,000: 5,000 to 15,000 is a good start for a city. A bigger radius means a bigger and slower answer. 3. When the user picked a **hotel** (`raw.loctype` is `hotel`), do not use its `id`. Search the hotel by name with [`name-search`](https://fx-port.com/docs/hotels/name-search.md) to get the FX-Port hotel id. ## Typeahead - Wait about 300 ms after the last keystroke and cancel the request that is in flight. - A first lookup of a text takes 1 to 2.5 seconds, a repeated one about a second. Keep the answers for the session of the user instead of asking again for the same text. - The lookup is limited per IP address as well as per key: see [Rate limits](https://fx-port.com/docs/rate-limits.md). - The answer is the same in the sandbox and on live. ## Names The keys of this answer are camelCase (`resultsCount`, `displayName`, `countryCode`) and have no snake_case twin. Add `?naming=snake` to get every key in snake_case. See [Conventions and naming](https://fx-port.com/docs/conventions.md#naming). ## Errors | HTTP | Code | Cause | |---|---|---| | `422` | `invalid_query` | `query` is blank, or longer than 64 characters. | | `422` | `validation_error` | `query` is missing, or `limit` is outside 1 to 50. | | `401`, `403` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | | `429` | `rate_limit_exceeded` | Too many calls: [Rate limits](https://fx-port.com/docs/rate-limits.md). | When its provider is slow or down, the lookup falls back to FX-Port's own records of places, so the `raw` object can then have other keys: one more reason not to build on it. ## Related - [Geo search](https://fx-port.com/docs/hotels/search.md): The next step: the hotels around the coordinates you picked. - [Hotel name search](https://fx-port.com/docs/hotels/name-search.md): Find one hotel by its name. - [API reference: Search destinations](https://fx-port.com/docs/api/search-hotel-locations.md): Every field and error. --- # Hotel name search > Find a hotel by name. Partial names and accents are tolerated; typos only when the rest of the name is long enough. Source: https://fx-port.com/docs/hotels/name-search Find a hotel by the name a person types, and get its **FX-Port hotel id** to use in [`search_advanced`](https://fx-port.com/docs/hotels/search.md), [`get_rates`](https://fx-port.com/docs/hotels/rates.md) and [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md). The search compares your text with hotel **names** over every hotel FX-Port knows. It does not price anything and does not need dates. **Permission:** Read > [!NOTE] > Names are not unique: `Hilton` is the name of hundreds of hotels in the world. Always show the city and the country of each hit (they are on by default) and let the user choose. To search around a place instead of by name, use [Search locations](https://fx-port.com/docs/hotels/locations.md) and the [geo search](https://fx-port.com/docs/hotels/search.md). ## Two forms, one search Use the form that fits your client. They run the same search and return the same answer. **GET** `GET /api/v1/hotels/name-search` **Request** ```bash curl 'https://api.fx-port.com/api/v1/hotels/name-search?query=Sofitel%20Algiers&limit=3&min_score=60' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` **POST** `POST /api/v1/hotels/search-by-name` **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/search-by-name \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "Sofitel Algiers", "limit": 3, "min_score": 60, "include_location": true, "include_details": false }' ``` | Parameter | Type | Required | Default | Description | |---|---|---|---|---| | `query` | string | Yes | | The hotel name or a part of it, 2 to 100 characters. Control characters are removed. | | `limit` | integer | No | `10` | Most hits to return, 1 to 100. The POST form caps a larger value at 100 and says so in `warnings` (`coerced_value`); the GET form refuses it with `400 invalid_limit`. A whole number written as text (`"10"`) is read; zero, a negative number, a fraction or another text is `400 invalid_limit` on both forms. | | `min_score` | number | No | `60` | Lowest match score to return, 0 to 100. A number written as text is read; a number outside that range, or another text, is `400 invalid_min_score`. See [Reading the score](#reading-the-score). | | `include_location` | boolean | No | `true` | Add the address, city, country code and postal code to each hit. In the POST body the text `true` or `false` (also `1`, `0`, `yes`, `no`, `on`, `off`) is read, with a `coerced_value` warning, and `null` means the default. | | `include_details` | boolean | No | `false` | Return the whole stored hotel document of each hit instead of the summary. See [Details of each hit](#details-of-each-hit): you almost never want it. | ## Response A real answer of the sandbox: ~~~json { "environment": "sandbox", "query": "Sofitel Algiers", "results_count": 2, "search_time": "0.060s", "hotels": [ { "id": "sofitel_algiers_hamma_garden", "name": "Sofitel Algiers Hamma Garden", "match_score": 83.39, "match_type": "partial", "address": "172, rue Hassiba Benbouali, Algiers", "city": "Algiers", "country_code": "DZ", "postal_code": "16014" }, { "id": "algiers_hotel", "name": "Algiers", "match_score": 76.67, "match_type": "partial", "address": "2845 South Las Vegas Boulevard Las Vegas, South Las Vegas Boulevard, Las Vegas", "city": "Las Vegas", "country_code": "US", "postal_code": "89109" } ] } ~~~ | Field | Description | |---|---| | `results_count` | How many hotels are in `hotels`. | | `search_time` | The time the search itself took, **as text** with its unit (`0.060s`). The call takes longer because of the network: 0.2 to 0.8 s in our measurements. | | `hotels[].id` | The FX-Port hotel id. Send it as `hotel_id`. | | `hotels[].name` | The name of the hotel. | | `hotels[].match_score` | How well the name matches, 0 to 100, two decimals. The hits are sorted by it. | | `hotels[].match_type` | `exact` (the whole name), `partial` (your text is a part of the name, or the name a part of your text) or `fuzzy` (the text is close to the name but not the same). | | `hotels[].address`, `city`, `country_code`, `postal_code` | Where the hotel is. `postal_code` is an empty string when unknown. Absent with `include_location=false`. | ## Reading the score The score says how close the **name** is to your text. It does not say that it is the hotel you mean. What the search returned in our probes (sandbox, 2026-11-01): | You type | Best hit | Score | Type | |---|---|---|---| | `Sofitel Algiers Hamma Garden` | Sofitel Algiers Hamma Garden (Algiers, DZ) | 100 | `exact` | | `sofitel algiers hamma garden hotel` | the same hotel | 85.59 | `partial` | | `sofitel algirs hamma garden` (a typo) | the same hotel | 86.43 | `fuzzy` | | `Hamma Garden` (a fragment) | `A garden` (Sevastopol, RU); the hotel you want is second | 81.67 (80.71) | `partial` | | `sofitel algers` (a typo in a short text) | `Sofi` (Perm, RU): the hotel is **not** found | 72.14 | `partial` | | `Hilton` | many hotels called `Hilton`, in St Helens, Thanesar, Windsor ... | 100 | `exact` | | `pereme` and `peremé` | the same hits with the same scores (78.33 at best): accents are ignored | 78.33 | `partial` | What to do with it: - **Treat a score of 80 or more as a likely match and anything below as a suggestion.** In our probes, every hit from 80 up was the hotel we typed, and nearly every hit below was another hotel (`Sofitel Algiers` also finds a hotel called `Algiers` in Las Vegas at 76.67). Raise `min_score` to 80 to hide the weak ones, or lower it to 50 when you would rather show too much. - **Typos are tolerated only when the rest of the text is long enough to carry the match.** A typo in a short text finds another hotel. - **Do not book the first hit without showing it.** Show the name, the city and the country. ## Details of each hit With `include_details=true` each hit is the whole stored document of the hotel (snake_case, with `images_ext`, `policy_struct` and every room type) plus `match_score` and `match_type`, and **no** `city` or `country_code` at the top (they are in `region`). It is large: 48 KB for one hotel in our measurement (26 KB for the smallest we measured). It is not the shape of any other call, so a client that wants the hotel page should take the `id` of the hit and call [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) for that one hotel. For a result list use the summary, or the compact card of [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md). ## Good practice - **Debounce a typeahead** at about 300 ms and cancel the request in flight. The search engine runs a few searches at a time, and when it is busy the call answers `503` with `service_busy` and a `Retry-After` of 2 seconds: wait that long and ask again. - **The ids are the same in the sandbox and on live**, but a hit is not necessarily bookable in the sandbox: see [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md). The test hotel `test_hotel` (`Test Hotel (Do Not Book)`) is found by name; `test_hotel_do_not_book` is not, so use its id as it is written there. - **The keys are snake_case** and have no camelCase twin. ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `missing_field` | `query` is missing from the POST body (`Missing required field: 'query'`; `errors[0].field` is `query`). | | `400` | `invalid_query` | `query` is not a text of 2 to 100 characters (POST and GET). | | `400` | `invalid_limit`, `invalid_min_score` | `limit` is not a whole number from 1 to 100 (a larger number is capped on the POST form and refused on the GET form), or `min_score` is not a number from 0 to 100. `errors[0].field` names it and `errors[0].details` carries `min` and `max` when the number is out of range. | | `400` | `bad_request` | The query is blank, or `include_details` or `include_location` of the POST body is not a boolean or one of the texts above (the message names it). | | `422` | `validation_error` | The GET form has no `query`, or `include_details` or `include_location` is not a boolean. | | `503` | `service_busy` | The search engine is busy. Retry after `Retry-After` seconds. | | `401`, `403` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | | `429` | `rate_limit_exceeded` | Too many calls: [Rate limits](https://fx-port.com/docs/rate-limits.md). | A search that finds nothing is not an error: the answer is `200` with `results_count: 0` and an empty `hotels`. ## Related - [Geo search](https://fx-port.com/docs/hotels/search.md): Price the hotel you found, for a stay (search it by its id). - [Hotel details](https://fx-port.com/docs/hotels/hotel-details.md): The page of the hotel, by its id. - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md): The compact card for a list and the full document for one hotel. - [API reference: Find a hotel by name](https://fx-port.com/docs/api/get-hotel-name-search.md): Every field and error. --- # Hotel details > The full page of one hotel, or its compact card with detail_level. Reads the cache and fetches an unknown hotel live. Endpoint: `POST /api/v1/hotels/get_hotel_info` Source: https://fx-port.com/docs/hotels/hotel-details Read **one hotel** for the page the user opens before checkout: the description, every room type, the photos, the policies, the facts and how to reach the hotel. It is the only content call that asks the supplier when FX-Port does not hold the hotel yet. **Permission:** Read > [!TIP] > **Use the right size** > > This answer is the **full** document: 5 to 143 KB for one hotel (26 KB in the middle, 38 KB on average in our measurement). Ask for it for the **one** hotel the user opened. For a list of hotels use the compact card: `search_advanced` already returns it, and this call returns it too with `detail_level: "minimal"`. See [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md). ## Request body `POST /api/v1/hotels/get_hotel_info` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `hotel_id` | string or integer | One of the two | | One hotel: its FX-Port id (`hotels_details[].id`, a hit of the name search) or its numeric supplier id (`hid`). The only form that asks the supplier when the hotel is not cached. | | `hotel_ids` | array | One of the two | | Several hotels, at most 200. **Cached hotels only**: the others are left out. Wins over `hotel_id` when both are sent. | | `detail_level` | string | No | `full` | `full`: the whole document, in camelCase. `minimal` (alias `mini`): the compact card, in snake_case. | | `images_limit` | integer | No | | With `detail_level: "minimal"`: keep only the first N photos of each card, 0 to 30. With `full` it is ignored and the answer carries a `coerced_value` warning. | | `language` | string | No | `en` | Language of the live fetch of a hotel that is not cached. Cached content is English whatever you send. | ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `hotelId`, `id`, `hid` | `hotel_id` | | `hotelIds`, `ids` | `hotel_ids` | | `detailLevel` | `detail_level` | | `imagesLimit` | `images_limit` | ## Request examples **One hotel** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/get_hotel_info \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "language": "en" }' ``` **Compact card** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/get_hotel_info \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "detail_level": "minimal", "images_limit": 1 }' ``` **Several hotels** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/get_hotel_info \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_ids": [ "test_hotel_do_not_book", "hotel_honduras_maya_6" ], "detail_level": "minimal" }' ``` ## How the hotel is found 1. With `hotel_id`, FX-Port looks for the id in its cache of hotel content. A **number** (or a numeric string) is taken as the supplier's `hid` and looked up the same way. 2. A single `hotel_id` that is not cached is **fetched from the supplier** (about 0.3 s), stored for every customer, and answered. This is how you get the card of a hotel listed in `missing_hotel_ids` of a search: see [Hotels without content](https://fx-port.com/docs/hotels/hotel-data.md#hotels-without-content). 3. With `hotel_ids` nothing is fetched: the hotels that are cached come back, the others are **left out without an error**, and the answer is `{"data": []}` when none is cached. A single `hotel_id` that no source knows is `404 hotel_not_found`. ## Response `data` is always a list, with one entry for a single `hotel_id`. The entry is the full document (camelCase), here shortened to one element per list: ~~~json { "environment": "sandbox", "data": [ { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "hotelChain": "No chain", "isClosed": false, "isGenderSpecificationRequired": false, "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "postalCode": null, "latitude": 14.079872, "longitude": -87.21677, "region": { "id": 3421, "name": "Tegucigalpa", "countryCode": "HN", "iata": "TGU", "type": "City" }, "starRating": 0, "starCertificate": null, "contact": { "email": "test@example.com", "phone": "+15555550123" }, "checkIn": "14:00:00", "checkOut": "12:00:00", "frontDeskHours": { "start": "00:00:00", "end": "00:00:00" }, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ], "amenityGroups": [ { "groupName": "General", "amenities": [ "24-hour reception", "Early check-in", "Late check-out" ], "paidAmenities": [] } ], "serpFilters": [ "has_internet", "has_parking", "kitchen" ], "descriptionStruct": [ { "title": "Location", "paragraphs": [ "A perfect fit for a big group of travellers: apartment «Test Hotel (Do Not Book)» ..." ], "html": "

Location

A perfect fit for a big group of travellers ...

" } ], "metapolicyExtraInfo": "Front desk is open 24/7.", "metapolicyStruct": { "internet": [ { "inclusion": "included", "price": "0.00", "currency": null, "price_unit": "unspecified", "internet_type": "unspecified", "work_area": "hotel" } ], "pets": [ { "inclusion": "not_included", "price": "560.00", "currency": "USD", "price_unit": "per_room_per_stay", "pets_type": "unspecified" } ], "children": [ { "age_start": 0, "age_end": 3, "price": "500.00", "currency": "CHF", "extra_bed": "available" } ] }, "roomGroups": [ { "roomGroupId": 95, "name": "Standard Double room full double bed", "nameStruct": { "mainName": "Standard Double room", "beddingType": "full double bed", "bathroom": "shared bathroom" }, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ], "roomAmenities": [ "bath", "heating" ], "roomClass": "room", "quality": "standard", "bathroom": "private-bathroom", "hasPrivateBathroom": true, "capacity": 2, "balcony": false, "view": null, "floor": null, "bedrooms": null, "beddingType": "double-bed", "isFamily": false, "isClub": false, "genderRestriction": null } ], "facts": { "floorsNumber": null, "roomsNumber": null, "yearBuilt": null, "yearRenovated": null, "electricity": { "voltage": [], "frequency": [ 60 ], "sockets": [ "a", "b" ] } }, "paymentMethods": [ "visa", "maestro", "cash" ], "keysPickup": { "type": "lockbox", "isContactless": true, "email": "test@example.com", "phone": "+15555550123", "officeAddress": null, "extraInfo": "The keys are in the box by the front door." } } ] } ~~~ The reference describes every field: [`get_hotel_info`](https://fx-port.com/docs/api/get-hotel-info.md) for this camelCase form and [The Hotel full object](https://fx-port.com/docs/api/objects/hotel-full.md) for the snake_case form of the same data. Things to know when you read it: - **`checkIn` and `checkOut` are times of the day** (`14:00:00`, local time), not dates. - **`images` are URL templates** with `{size}`: build the picture URL as described in [Images](https://fx-port.com/docs/hotels/hotel-data.md#images). `images` can be `null`. - **`roomGroups` is about 80% of the document.** Each room type carries its own photos and amenities, with its properties decoded into words (`roomClass`, `quality`, `beddingType`, `hasPrivateBathroom`, `balcony`, `view`). - **`metapolicyStruct` tells what is paid at the property** (parking, extra beds, pets, deposits). Read `children` before you say a child stays free. Its entries keep snake_case keys (`price_unit`, `age_start`). - **`isClosed: true`** means the property is closed at the supplier: [`get_rates`](https://fx-port.com/docs/hotels/rates.md) will find no rate. - **The key style follows `detail_level`.** `full` answers camelCase; `minimal` answers the snake_case card of [The Hotel mini object](https://fx-port.com/docs/api/objects/hotel-mini.md). Do not mix them in one parser. Eight keys of the full document come **twice**, as camelCase and as a snake_case twin, so that you can move to snake_case at your pace: `hotelChain`/`hotel_chain`, `isClosed`/`is_closed`, `postalCode`/`postal_code`, `starRating`/`star_rating`, `starCertificate`/`star_certificate`, `checkIn`/`check_in_time`, `checkOut`/`check_out_time` and `region.countryCode`/`region.country_code`. Add `?naming=snake` to get **every** key in snake_case. See [Conventions and naming](https://fx-port.com/docs/conventions.md#naming). ## Good practice - **Cache the document by hotel id** (not by language: the content is English). A day is a safe duration. - **Call it once, when the user opens the hotel**, not for every hotel of a list. - **Fetch a missing hotel with one id per call**, five calls at a time at most, and remember the failures for a few minutes. The supplier answers about 30 hotel content calls a minute for all of FX-Port. On a `503 supplier_busy` wait the seconds of `Retry-After`: the call was refused before anything was sent. - **Send `"language": "en"`** when the hotel may not be cached: the fetched content is stored for every customer in the language of that call. ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `missing_field` | Neither `hotel_id` nor `hotel_ids` is sent (`Hotel IDs are required`; `errors[0].field` is `hotel_ids`). | | `400` | `invalid_hotel_id`, `invalid_hotel_ids` | An id is not 1 to 128 characters (letters, digits and `_ . : -`; or a positive integer for a `hid`), or `hotel_ids` is not a list. | | `400` | `too_many_ids` | More than 200 ids in `hotel_ids`. | | `400` | `invalid_detail_level`, `invalid_images_limit`, `invalid_language` | The value is not one of the documented ones. | | `404` | `hotel_not_found` | A single `hotel_id` that neither FX-Port nor the supplier knows. | | `429` | `supplier_rate_limited` | The supplier is receiving too many content calls (only when FX-Port asks it for a single hotel). Retry after a minute. | | `502` | `supplier_error`, `supplier_invalid_response` | The supplier could not process the request, or answered in a form that could not be used. Retry with backoff. | | `503` | `supplier_unavailable`, `supplier_timeout`, `supplier_busy` | The supplier could not be reached, did not answer in time, or is at capacity. With `supplier_busy` the call was **refused before anything was sent**: retry after the seconds in `Retry-After`. Otherwise retry with backoff. | | `401`, `403` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | ## Related - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md): When to use the compact card and when this document. - [Geo search](https://fx-port.com/docs/hotels/search.md): The cards of the hotels around a point, with their prices. - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): The rooms, meals and cancellation terms of the hotel the user picked. - [API reference: Read one hotel for the hotel page](https://fx-port.com/docs/api/get-hotel-info.md): Every field and error. --- # Get hotel rates > Every room and rate combination for a specific hotel, with cancellation policies. Required before pre_book. Endpoint: `POST /api/v1/hotels/get_rates` Source: https://fx-port.com/docs/hotels/rates Get every **room and rate** of one hotel for a stay: each with its meal plan, its cancellation schedule, its taxes and its price. The `bookHash` of the rate the user chooses is what you send to [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md). **Permission:** Read > [!WARNING] > **Sandbox: any hotel is priced, only the test hotels can be booked** > > `get_rates` returns the real rates of any hotel in the sandbox. A rate of another hotel than the supplier test hotels cannot be booked there: see [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md). `test_hotel` has rates for the residencies `dz`, `ru` and `us` only. ## Request body `POST /api/v1/hotels/get_rates` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `hotel_id` | string | Yes | | The FX-Port id of the hotel: `hotels_details[].id` of a [search](https://fx-port.com/docs/hotels/search.md), or a hit of the [name search](https://fx-port.com/docs/hotels/name-search.md). | | `checkin` | string | Yes | | Check-in date, `YYYY-MM-DD`. From yesterday to 400 days ahead. | | `checkout` | string | Yes | | Check-out date, after `checkin`. A stay is 1 to 30 nights. | | `guests` | array | Yes | | One `{ adults, children }` object per room. See [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md). | | `residency` | string | No | `gb` | Residency of the guests, a two-letter country code. Some rates exist for some residencies only. | | `language` | string | No | `en` | Language of the room names (`fr` gives `Standard chambre`). | > [!CAUTION] > Do not send a `currency` field: it is refused with `400`. Prices come in the currency of your agency. Send the **same** `guests` and `residency` as in the search, or the rates and prices will differ. A value written in another well-defined way (a number as text, a date with a midnight time, `GB-SCT` as residency ...) is read like in [`search_advanced`](https://fx-port.com/docs/hotels/search.md#values-the-api-reads-for-you), and a number sent as `hotel_id` is not looked up as a supplier `hid` here. Identical calls of your agency that arrive at the same moment are merged into one supplier call; each caller still gets its own `request_id` and headers. ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `id`, `hotelId` | `hotel_id` | | `check_in`, `checkinDate`, `checkin_date` | `checkin` | | `check_out`, `checkoutDate`, `checkout_date` | `checkout` | ## Request examples **One room** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/get_rates \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "checkin": "2026-12-01", "checkout": "2026-12-04", "residency": "gb", "language": "en", "guests": [ { "adults": 2, "children": [] } ] }' ``` **Two rooms, with a child** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/get_rates \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "checkin": "2026-12-10", "checkout": "2026-12-12", "residency": "gb", "language": "en", "guests": [ { "adults": 2, "children": [ 8 ] }, { "adults": 1, "children": [] } ] }' ``` **The other test hotel** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/get_rates \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel", "checkin": "2026-12-01", "checkout": "2026-12-04", "residency": "dz", "language": "en", "guests": [ { "adults": 2, "children": [] } ] }' ``` ## Response A real answer of the sandbox, cut to the cheapest of the 14 rates of the test hotel (0.5 to 1.4 s, 23 KB for the 14 rates): ~~~json { "environment": "sandbox", "success": true, "data": { "hotelId": "test_hotel_do_not_book", "hid": 8473727, "rates": [ { "bookHash": "h-14574656-7ce9-5a21-b673-8654c5ebd416", "matchHash": "m-a1f40b22-c086-57c9-b2b5-70c8e59cb27c", "room": { "name": "Standard room", "beddingType": "single bed", "miscRoomType": null, "capacity": null, "bathroom": "private-bathroom", "hasPrivateBathroom": true, "balcony": false, "roomClass": "room", "quality": "standard", "view": null, "floor": null, "bedrooms": null, "isFamily": false, "isClub": false, "genderRestriction": null, "nonSmoking": true }, "meal": { "type": "nomeal", "included": false, "childMealIncluded": false }, "dailyPrices": [ 1, 1, 1 ], "allotment": 77, "isPackage": false, "anyResidency": true, "deposit": null, "noShow": null, "cancellationPolicy": { "freeCancellationBefore": "2026-12-01T21:00:00", "policies": [ { "from": null, "until": "2026-12-01T21:00:00", "originalAmount": 0, "originalCurrency": "EUR", "currency": "DZD" }, { "from": "2026-12-01T21:00:00", "until": null, "originalAmount": 3, "originalCurrency": "EUR", "currency": "DZD" } ] }, "pricing": { "supplierCurrency": "EUR", "agencyCurrency": "DZD", "originalPrice": 3, "agencyCommission": 2.97, "totalPrice": 1484.97, "taxes": { "cityTax": { "includedInPrice": false, "amount": 23.1, "currency": "HNL" }, "electricityFee": { "includedInPrice": true, "amount": 0.6, "currency": "EUR" }, "resortFee": { "includedInPrice": true, "amount": 0.68, "currency": "EUR" } } }, "exchangeRate": 285 } ] }, "processingTime": 0.335 } ~~~ | Field | Description | |---|---| | `data.hotelId`, `data.hid` | The hotel, by FX-Port id and by the supplier's numeric id. | | `data.rates[]` | The rates, **in no particular order**: sort them by `pricing.totalPrice` yourself. | | `bookHash` | The token of the rate. Send it as `book_hash` to [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md). Valid for **30 minutes**: after that, ask for the rates again. | | `matchHash` | A second identifier of the rate. **It is not a booking token**: `pre_book` does not read it. Send `bookHash`. | | `room` | The room: `name` (in your `language`), bedding, bathroom, class, quality, view, size flags. The full room types, with photos, are in the content of the hotel ([`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md), `roomGroups`): match them by room name. | | `meal` | `type`, `included` and `childMealIncluded`. The types are `nomeal`, `breakfast`, `lunch`, `dinner`, `half-board`, `half-board-lunch`, `half-board-dinner`, `full-board`, `all-inclusive` and `ultra-all-inclusive`. | | `dailyPrices` | The supplier's price of each night, in the supplier's currency (EUR). For information. | | `allotment` | How many rooms of this type are left at this rate. | | `anyResidency` | `false` when the rate is reserved for some residencies: it exists for the `residency` of your request only. | | `isPackage` | `true` when the rate is part of a package. | | `cancellationPolicy` | When the booking can be cancelled for free, and the penalty windows after that. See below. | | `pricing`, `exchangeRate` | The price of the rate, for the whole stay and all rooms, and the exchange rate used. See [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md). | | `processingTime` | Seconds the call took. | The keys you feed back or compute with also come with a snake_case twin: `book_hash`, `match_hash`, `daily_prices`, `exchange_rate`, `pricing.total_price`, `pricing.agency_commission`, `pricing.agency_currency`, `pricing.supplier_currency`, `cancellationPolicy.free_cancellation_before`, `data.hotel_id` and `processing_time`. See [Legacy names](https://fx-port.com/docs/legacy-names.md). ## Choosing a rate - **A room type appears many times**: one rate per meal plan and per cancellation condition. Group the rates by `room.name` and show the cheapest of each, with what differs (meal, cancellation). - **Show what is paid at the hotel.** `pricing.taxes` lists taxes and fees; those with `includedInPrice: false` are paid by the traveler at the property, on top of `totalPrice`, in the currency the supplier states (it can be the local currency, such as `HNL`). - **Only prepaid rates are returned.** Rates that are paid at the hotel are not offered, because FX-Port bookings are paid from your agency balance. The price of the search is the cheapest rate of the supplier, so in rare cases the cheapest rate here is a little higher. - **A rate is for one occupancy and one residency.** If the user changes a guest or the country, call again. ## Cancellation policy `cancellationPolicy` says what a cancellation costs, window after window: - `freeCancellationBefore` is the moment until which the booking can be cancelled **for free**, in UTC, without a zone suffix (`2026-12-01T21:00:00`). `null` means the rate is **non-refundable**. - `policies[]` is the schedule from now to the check-in and after: the free window first (`originalAmount: 0`), then the windows with a penalty. - The amounts are the **supplier's**: `originalAmount` is in `originalCurrency` (EUR). The `currency` field only labels your agency currency, and **no** amount in your currency is given: see [Cancellation penalties](https://fx-port.com/docs/hotels/pricing.md#cancellation-penalties). What is refunded is in the answer of [`cancel_hotel`](https://fx-port.com/docs/hotels/cancel.md). ## No rate is not an error A hotel that is unknown, closed, not available for the dates or not available for the residency answers `200` with `hotelId: null` and an empty list. **Check `rates.length`**; do not wait for a `404`: ~~~json { "environment": "sandbox", "success": true, "data": { "hotelId": null, "hid": null, "rates": [] }, "processingTime": 0.16 } ~~~ If you expected rates, check the dates, the occupancy, the residency (`test_hotel` has none for `gb` or `fr`) and `isClosed` in the content of the hotel. ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `missing_field` | `hotel_id`, `checkin`, `checkout` or `guests` is missing. `errors[0].field` names the first missing field and `errors[0].details.missing_fields` lists all of them when several are missing. The message is the older sentence (`Missing required fields: ['guests']`). | | `400` | `bad_request` | You sent a `currency` field. | | `400` | `invalid_date`, `date_in_past`, `date_too_far`, `checkout_before_checkin`, `stay_too_long` | The dates are not a real stay. | | `400` | `invalid_guests`, `invalid_adult_count`, `too_many_children`, `invalid_child_age`, `too_many_guests`, `too_many_rooms` | The rooms break [the rules](https://fx-port.com/docs/hotels/guests-and-rooms.md#constraints). | | `400` | `invalid_residency`, `invalid_language`, `invalid_hotel_id` | The code is not valid. | | `502` | `supplier_invalid_response` | The supplier answered in a form that could not be used. Retry in a few minutes. | | `503` | `supplier_unavailable`, `supplier_timeout`, `supplier_busy` | The supplier could not be reached, did not answer in time, or is at capacity. With `supplier_busy` the call was **refused before anything was sent**: retry after the seconds in `Retry-After`. Otherwise retry with backoff. | | `401`, `403` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | | `429` | `rate_limit_exceeded` | Too many calls: [Rate limits](https://fx-port.com/docs/rate-limits.md). | A search that the supplier itself refuses comes as `400` too, with the supplier's own code in `errors[0].code` and `errors[0].type` `supplier_error`. ## Next step Take the `bookHash` of the chosen rate and call [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md) to lock its price. ## Related - [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md): What `totalPrice`, `agencyCommission` and the taxes mean, and what you are debited. - [Pre-book a rate](https://fx-port.com/docs/hotels/pre-book.md): Lock the price of the chosen rate. - [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md): The occupancy of a request. - [API reference: Get the rooms and rates of one hotel](https://fx-port.com/docs/api/get-hotel-rates.md): Every field and error. --- # Pre-book a rate > Lock in the price of a chosen rate and get a partner_order_id and the booking form ahead of book_hotel. Requires Read+Write permission. Endpoint: `POST /api/v1/hotels/pre_book` Source: https://fx-port.com/docs/hotels/pre-book Lock the price of the rate the user chose. `pre_book` asks the supplier to confirm the rate and the price, opens an order at the supplier, and keeps the priced offer on FX-Port's side. It answers a `partnerOrderId`: the **booking id** you send to [`book_hotel`](https://fx-port.com/docs/hotels/book.md) next. **Permission:** Read+Write (a read key answers `403 read_write_permission_required`) > [!CAUTION] > **In the sandbox only the supplier test hotels can be booked** > > Pre-book `test_hotel_do_not_book` (every residency) or `test_hotel` (residency `dz`, `ru` or `us`). Any other hotel **answers `409 hotel_not_bookable_in_sandbox`** and lists the test hotels in `errors[0].details.test_hotels`: the supplier opens no order for it, `formData.orderId` is `null`, and `book_hotel` would fail with `order_not_found`. Searching and pricing work for every hotel. See [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md). > [!NOTE] > `pre_book` stores the **priced offer** (with your commission) on FX-Port's side, under the `partnerOrderId`. `book_hotel` reads the price, the hotel, the dates and the residency from that offer, not from your request, so the amount debited is the one you were shown here. Call `pre_book` right before `book_hotel`. ## Request body `POST /api/v1/hotels/pre_book` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `book_hash` | string | Yes | | The `bookHash` of the rate, from [`get_rates`](https://fx-port.com/docs/hotels/rates.md). It starts with `h-` and is valid for **30 minutes**. | | `price_increase_percent` | number | No | `0` | The price rise, in percent, you accept if the supplier raised the price since `get_rates`: 0 to 100. With `0` the call fails if the price moved. `null` means not set (0); a number written as text (`"5"`) is read, with a `coerced_value` warning. | | `language` | string | No | `en` | Language of the hotel name in the answer. | The `matchHash` of a rate is **not** a booking token: `pre_book` does not read it, and a call that sends only a `match_hash` fails with `400 missing_field`. Send the `bookHash` as `book_hash`. **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/pre_book \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "book_hash": "h-14574656-7ce9-5a21-b673-8654c5ebd416", "price_increase_percent": 0 }' ``` ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `hash`, `bookHash` | `book_hash` | ## Response A real answer of the sandbox, in 4 to 5 seconds (the supplier does the work): ~~~json { "environment": "sandbox", "success": true, "status": "ok", "partnerOrderId": "HMVXX4ZQ3", "priceChanged": false, "data": { "hotelId": "test_hotel_do_not_book", "hid": 8473727, "rates": [ { "bookHash": "p-58bfbfa0-fd1c-4be9-8a2a-54942cae6627", "matchHash": "m-a1f40b22-c086-57c9-b2b5-70c8e59cb27c", "room": { "name": "Standard room", "beddingType": "single bed", "miscRoomType": null, "capacity": null, "bathroom": "private-bathroom", "hasPrivateBathroom": true, "balcony": false, "roomClass": "room", "quality": "standard", "view": null, "floor": null, "bedrooms": null, "isFamily": false, "isClub": false, "genderRestriction": null, "nonSmoking": true }, "meal": { "type": "nomeal", "included": false, "childMealIncluded": false }, "dailyPrices": [ 1, 1, 1 ], "allotment": 77, "isPackage": false, "anyResidency": true, "deposit": null, "noShow": null, "cancellationPolicy": { "freeCancellationBefore": "2026-12-01T21:00:00", "policies": [ { "from": null, "until": "2026-12-01T21:00:00", "originalAmount": 0, "originalCurrency": "EUR", "currency": "DZD" }, { "from": "2026-12-01T21:00:00", "until": null, "originalAmount": 3, "originalCurrency": "EUR", "currency": "DZD" } ] }, "pricing": { "supplierCurrency": "EUR", "agencyCurrency": "DZD", "originalPrice": 3, "agencyCommission": 2.97, "totalPrice": 1484.97, "taxes": { "cityTax": { "includedInPrice": false, "amount": 23.1, "currency": "HNL" }, "electricityFee": { "includedInPrice": true, "amount": 0.6, "currency": "EUR" }, "resortFee": { "includedInPrice": true, "amount": 0.68, "currency": "EUR" } } }, "exchangeRate": 285 } ] }, "formData": { "partnerOrderId": "HMVXX4ZQ3", "orderId": 757581298, "itemId": 232430616, "paymentTypes": [ { "amount": "3.00", "currencyCode": "EUR", "isNeedCreditCard": false, "isNeedCvc": false, "type": "deposit" }, { "amount": "3.00", "currencyCode": "USD", "isNeedCreditCard": true, "isNeedCvc": true, "type": "now" } ] } } ~~~ | Field | Description | |---|---| | `partnerOrderId` | **The booking id.** Send it as `booking_id` to [`book_hotel`](https://fx-port.com/docs/hotels/book.md), and later to the booking detail, [`get_voucher`](https://fx-port.com/docs/hotels/voucher.md) and [`cancel_hotel`](https://fx-port.com/docs/hotels/cancel.md). It comes twice more as `booking_id` and `partner_order_id`. | | `priceChanged` | `true` when the supplier's price moved since `get_rates`, within `price_increase_percent`. | | `data.rates[0]` | The rate you locked, with the **price as it stands now** (`pricing.totalPrice`). Its `bookHash` is a new one that starts with `p-`: do not reuse it, `book_hotel` needs only the booking id. | | `formData.orderId`, `itemId` | The order opened at the supplier. **`orderId: null` means the supplier opened no order**: the hotel cannot be booked, do not call `book_hotel`. | | `formData.paymentTypes` | The ways the supplier offers to pay: one `deposit` and about 40 card options, one per currency. Informational: FX-Port always pays with `deposit`, from your agency balance. | ## Check the price When `priceChanged` is `true`, or whenever you want to be sure, compare `data.rates[0].pricing.totalPrice` with the price the traveler accepted. If it is higher, show the new price and let the traveler decide before you call `book_hotel`. With `price_increase_percent` at `0` the call fails instead of locking a higher price (see below). ## What lives how long | What | How long | |---|---| | The `bookHash` from `get_rates` | 30 minutes | | The priced offer behind `partnerOrderId` | 1 hour: after that `book_hotel` answers `409 cached_offer_required` on live and you must `pre_book` again | | The order at the supplier | 60 minutes; an order you never book costs nothing and expires | Calling `pre_book` again **with the same rate and the same `price_increase_percent` within a minute** returns the same answer (the same `partnerOrderId`), so a retry after a timeout does not open a second order. The key of the replay is your agency, the rate and the percentage as read: `"5"`, `5` and `5.0` are one request, another `price_increase_percent` is a new request. The supplier limits all `pre_book` calls of FX-Port to about 30 a minute: do not pre-book for many rates "just in case". ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `missing_field`, `invalid_rate_hash`, `invalid_price_increase` | `book_hash` is missing (`Missing required field: hash`, `errors[0].field` is `book_hash`) or malformed, or `price_increase_percent` is not a number from 0 to 100. | | `400` | `rate_not_found` | The supplier does not know the rate any more: it expired (30 minutes), or the price rose by more than `price_increase_percent`. Call [`get_rates`](https://fx-port.com/docs/hotels/rates.md) again and choose again. `errors[0].type` is `supplier_error`: the refusal comes from the supplier, and any other refusal of the supplier comes the same way, with its own code. | | `400` | `supplier_invalid_response` | The supplier priced the rate but returned no usable offer (the message says `No rates in pre-book response`). Call [`get_rates`](https://fx-port.com/docs/hotels/rates.md) again. | | `403` | `read_write_permission_required` | The key has read permission only. | | `409` | `hotel_not_bookable_in_sandbox` | Sandbox only: the hotel is not a supplier test hotel. | | `500` | `internal_error` | An unexpected failure, for example the supplier did not answer. Nothing is booked and you can repeat the call: within a minute it replays the answer of the first call if that one succeeded, otherwise it opens a new order (an order you never book costs nothing). | | `502` | `supplier_invalid_response` | The supplier answered in a form that could not be used. Nothing is locked. Retry in a few minutes. | | `503` | `supplier_busy` | The supplier is at capacity. The call was refused **before anything was sent**: nothing is locked, nothing is debited. Retry after the seconds in `Retry-After`. | The `409` carries the test hotels: ~~~json { "test_hotels": [ { "id": "test_hotel_do_not_book", "hid": 8473727, "residency": "any" }, { "id": "test_hotel", "hid": 8526976, "residency": [ "dz", "ru", "us" ] } ] } ~~~ ## Next step Call [`book_hotel`](https://fx-port.com/docs/hotels/book.md) with the `partnerOrderId` and the guests of each room. ## Related - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): Where the `bookHash` comes from. - [Book a hotel](https://fx-port.com/docs/hotels/book.md): Finish the booking. - [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md): What `totalPrice` and what you are debited mean. - [API reference: Lock a rate before booking](https://fx-port.com/docs/api/pre-book-hotel.md): Every field and error. --- # Book a hotel > Book a pre-booked rate with the guest details: the booking confirms at once, with no hold or pay-later. Requires Read+Write permission. Endpoint: `POST /api/v1/hotels/book_hotel` Source: https://fx-port.com/docs/hotels/book Finish the booking of a rate you locked with [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md). **Booking a hotel confirms it: there is no hold or pay-later option for API keys, and your agency is debited as part of the booking, when it is confirmed** (the sandbox debits nothing). The balance is checked before anything is booked (live only: `402 insufficient_balance`). You send the **booking id** (the `partnerOrderId` of `pre_book`), the **guests of each room** and the **contact of the booker**. The price, the hotel, the dates and the residency come from the offer that `pre_book` stored, not from this request. **Permission:** Read+Write (a read key answers `403 read_write_permission_required`) > [!CAUTION] > **In the sandbox only the supplier test hotels can be booked** > > Book `test_hotel_do_not_book` (every residency) or `test_hotel` (residency `dz`, `ru` or `us`). For any other hotel `pre_book` answers `409 hotel_not_bookable_in_sandbox`, and a `book_hotel` that reaches the supplier anyway fails with `order_not_found`. **Every sandbox booking is a real order on the supplier's test contract: cancel it** (free until 21:00 UTC on the check-in day). [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md) has a script that books and cancels. > [!WARNING] > **Do not retry book_hotel blindly** > > The answer is `pending`: the supplier confirms in the background. If a call times out or fails with a `5xx` (except `503 supplier_busy`, which is refused before anything is sent), **read the booking before you do anything else** ([how](#when-something-goes-wrong)). A second `book_hotel` for the same booking id answers `409 duplicate_booking` and does not reach the supplier. ## Request body `POST /api/v1/hotels/book_hotel` | Field | Type | Required | Default | Description | |---|---|---|---|---| | `booking_id` | string | Yes | | The `partnerOrderId` of [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md). A booking id is used once. | | `rooms` | array | Yes | | One object per room, **in the order you searched**. See [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md#guest-names-at-booking-time). | | `rooms[].guests` | array | Yes | | One `{ first_name, last_name }` per guest of the room, adults and children. A child also carries `is_child: true` and `age`. `is_child` is a boolean: `1` and `0`, and texts such as `"true"`, `"false"`, `"yes"` or `"no"`, are read as one; anything else is `400 invalid_is_child`. | | `user` | object | Yes | | The booker: `email` (required), `phone` (with country code) and an optional `comment` for the hotel. FX-Port sends its confirmation e-mail to `email`. | | `language` | string | No | `en` | Language of the confirmation e-mail and of the voucher. | | `send_email` | boolean | No | `true` | `false` to stop FX-Port from sending its confirmation e-mail. Use it when you test. Read like `is_child` (`"false"`, `"no"` and `0` work); anything that is not a boolean is `400 invalid_send_email`. | | `commission_override` | object | No | | Your commission for this booking: see [Commission override](#commission-override). | The body does not need the hotel, the rate or the dates: FX-Port keeps them under the booking id and **always books the pre-booked rate and hotel**. Do not send `book_hash` or `hotel_id`. If you do, a hash the offer was made from (the `bookHash` of `pre_book` or of `get_rates`, a match hash) and the offer's own hotel (in any case, its numeric `hid`, the id `get_rates` was called with) are accepted silently; any other value is **ignored**, never refused: the answer carries an `ignored_field` warning (`the pre-booked rate was booked`, see [Conventions](https://fx-port.com/docs/conventions.md#a-previous-spelling-still-works-and-says-so)). **Two adults** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/book_hotel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "HMVXX4ZQ3", "rooms": [ { "guests": [ { "first_name": "Jane", "last_name": "Smith" }, { "first_name": "John", "last_name": "Smith" } ] } ], "user": { "email": "traveler@example.com", "phone": "+15555550123" }, "send_email": false }' ``` **Two rooms, with a child** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/book_hotel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "HMVXX4ZQ3", "rooms": [ { "guests": [ { "first_name": "Anna", "last_name": "Smith" }, { "first_name": "Ben", "last_name": "Smith" }, { "first_name": "Cleo", "last_name": "Smith", "is_child": true, "age": 8 } ] }, { "guests": [ { "first_name": "Dan", "last_name": "Jones" } ] } ], "user": { "email": "traveler@example.com", "phone": "+15555550123", "comment": "Late arrival" }, "send_email": false }' ``` **With my own commission** **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/book_hotel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "HMVXX4ZQ3", "rooms": [ { "guests": [ { "first_name": "Jane", "last_name": "Smith" }, { "first_name": "John", "last_name": "Smith" } ] } ], "user": { "email": "traveler@example.com", "phone": "+15555550123" }, "commission_override": { "type": "fixed", "value": 50 }, "send_email": false }' ``` ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `partner_order_id`, `partnerOrderId`, `bookingId` | `booking_id` | | `bookHash` | `book_hash` (do not send it: FX-Port books the pre-booked rate) | | `hotelId` | `hotel_id` (do not send it: FX-Port books the pre-booked hotel) | ## Response: the booking is `pending` A real answer of the sandbox, in 3 to 5 seconds: ~~~json { "environment": "sandbox", "success": true, "status": "pending", "bookingId": "HMVXX4ZQ3", "partnerOrderId": "HMVXX4ZQ3", "hotelId": "test_hotel_do_not_book", "checkinDate": "2026-12-01", "checkoutDate": "2026-12-04", "pricing": { "totalPrice": 1484.97, "agencyCommission": 2.97, "currency": "DZD", "supplierCurrency": "EUR", "originalPrice": 3 }, "postProcessing": { "bookingDbId": "24fec1e5-a09f-48ec-8050-1adaab0a8de1", "webhookSent": false, "bucketLink": null }, "processingTime": 5.449, "balanceDebited": false, "transactionCreated": false, "payoutCreated": false } ~~~ | Field | Description | |---|---| | `status` | **Always `pending`.** The supplier confirms in the background. | | `bookingId` | The booking id: the same as `partnerOrderId` and as the `booking_id` you sent. Use it for the booking detail, the voucher and the cancellation. | | `hotelId`, `checkinDate`, `checkoutDate` | The hotel and the dates, from the locked offer. | | `pricing` | The price as locked: `totalPrice` is what the traveler pays, `agencyCommission` is yours, and you are debited `totalPrice - agencyCommission`. There is no `b2bPrice` here. See [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md). | | `postProcessing.bucketLink` | `null` here: the voucher is made after the call. Read it with [`get_voucher`](https://fx-port.com/docs/hotels/voucher.md). The other keys of `postProcessing` are internal. | | `balanceDebited`, `transactionCreated`, `payoutCreated` | **Sandbox only**, always `false`: nothing is debited. They are absent on live. | | `processingTime` | Seconds the call took. | The keys also come as snake_case twins (`booking_id`, `partner_order_id`, `hotel_id`, `checkin`, `checkout`, `pricing.total_price`, `pricing.agency_commission`, `pricing.agency_currency`, `pricing.supplier_currency`, `voucher_url`, `processing_time`, `balance_debited` ...). See [Legacy names](https://fx-port.com/docs/legacy-names.md). ## From pending to confirmed `pending` becomes `confirmed` or `failed` a few seconds later: **4 to 14 seconds** in our measurements (7 to 11 seconds in the last runs). There are **no webhooks**: you poll. ~~~text book_hotel → pending → (4 to 14 s) → confirmed → voucher links (at the same moment, or up to 10 s later) → failed ~~~ Call [`GET /api/v1/bookings/{booking_id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md) **every 2 seconds** (the booking changes within seconds: polling faster only uses your [rate limits](https://fx-port.com/docs/rate-limits.md)) until `status` is no longer `pending`, and stop after about 90 seconds. **JavaScript** ~~~js async function waitForBooking(bookingId, headers) { const started = Date.now(); while (Date.now() - started < 90_000) { const url = `https://api.fx-port.com/api/v1/bookings/${bookingId}?product=hotels`; const { data } = await (await fetch(url, { headers })).json(); if (data.status !== "pending") return data; // "confirmed" or "failed" await new Promise((resolve) => setTimeout(resolve, 2000)); } throw new Error(`Booking ${bookingId} is still pending: keep polling it, do not book again`); } ~~~ **Python** ~~~python import time import requests def wait_for_booking(booking_id, headers, timeout=90): started = time.time() while time.time() - started < timeout: response = requests.get( f"https://api.fx-port.com/api/v1/bookings/{booking_id}", params={"product": "hotels"}, headers=headers, timeout=30, ) record = response.json()["data"] if record["status"] != "pending": return record # "confirmed" or "failed" time.sleep(2) raise TimeoutError(f"Booking {booking_id} is still pending: keep polling it, do not book again") ~~~ | Status | Meaning | |---|---| | `pending` | Accepted. The supplier has not confirmed yet. | | `confirmed` | Booked. The voucher links follow within seconds. The booking can be cancelled. | | `failed` | The booking did not go through. Lock a rate again with `pre_book` (new booking id) and book again. | | `refunded`, `partial_refund`, `cancelled` | After a cancellation: [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md). | ## When something goes wrong The supplier documents that a timeout or a `5xx` during a booking **does not mean that the booking failed**: it may exist. So the rule is: **never call `book_hotel` again with the same booking id to see what happens. Read it.** | What you got | What to do | |---|---| | `200` `pending` | Poll the booking. | | A timeout, a network error, `500` (`post_booking_failed`, `internal_error`) or `502` `supplier_invalid_response` | Read `GET /bookings/{booking_id}?product=hotels`. `pending` or `confirmed`: carry on, the booking exists. `404 booking_not_found`: the booking never started, you may call `book_hotel` once more. `failed`: `pre_book` again. | | `503` `supplier_busy` or `balance_lock_timeout` | **Refused before anything was sent**: nothing was booked at the supplier, no booking exists, nothing is debited. Call `book_hotel` again with the same booking id after the seconds in `Retry-After` (a few seconds for `balance_lock_timeout`, which means that another booking of your agency in the same currency is being processed). | | `409` `duplicate_booking` | A booking with this id exists already (`errors[0].details` has its `booking_id` and, for your own booking, its `status`). **No second call reached the supplier.** Poll it. | | `409` `cached_offer_required` | The offer of `pre_book` was not found: it is older than one hour (live), or another agency locked it. `pre_book` again with your own key, then book the new booking id. | | `400` `invalid_request` | The body names two booking ids: `partner.partner_order_id` differs from `booking_id`. Send the booking id once, as `booking_id`. | | `400` `incorrect_guests_number` | A child without `is_child` and `age`, or guests that do not match the searched rooms. Fix the list and book again. | | `400` `order_not_found` | The supplier has no order for this booking id: the hotel was not bookable (in the sandbox, not a test hotel) or the order expired. `pre_book` again. | | `402` `insufficient_balance` | Live: top up the balance of your agency, then book again. | ## Commission override `commission_override` sets **your commission** for this booking instead of the default one of your agency: | Field | Description | |---|---| | `type` | `fixed`: `value` is your commission **per room**, in your agency currency. `percentage`: `value` is a percentage of what you pay (`totalPrice - agencyCommission` before the override). | | `value` | `0` or more. A negative value, or a `type` that is not one of the two, is ignored. | The traveler price becomes **what you pay plus the new commission**. What you are debited does not change. The answer shows the new `pricing.totalPrice` and `pricing.agencyCommission`. Sandbox example: `{ "type": "fixed", "value": 50 }` on a one-room booking costing 1,185.60 DZD gives `totalPrice` 1,235.60. ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `missing_field` | A required field is missing: `rooms` or `user.email`. `errors[0].field` names it (`Missing required field: rooms`), and `errors[0].details.missing_fields` lists all of them when several are missing. | | `400` | `invalid_rooms`, `too_many_rooms`, `invalid_child_age`, `invalid_is_child`, `invalid_send_email`, `invalid_booking_id`, `invalid_language` | The body breaks a rule: `rooms` must be a non-empty list of at most 9 rooms with 1 to 10 guests each, names at most 100 characters, `booking_id` 1 to 256 characters, `is_child` and `send_email` booleans. | | `400` | `invalid_request` | The body names two booking ids: `partner.partner_order_id` differs from `booking_id`. A `book_hash` or `hotel_id` that is not the offer's is not an error: it is ignored. | | `400` | `booking_mode_not_available` | The body has a `booking_mode` other than `standard`. A booking made with an API key always books and confirms the rate: do not send the field. | | `400` | `incorrect_guests_number`, `order_not_found` | See [above](#when-something-goes-wrong). Both are refusals of the supplier: `errors[0].type` is `supplier_error`. | | `402` | `insufficient_balance` | Live only. | | `403` | `read_write_permission_required`, `agency_not_active` | A read key, or an agency that is not approved for this environment. | | `409` | `duplicate_booking`, `cached_offer_required`, `trusted_offer_pricing_missing` | See [above](#when-something-goes-wrong). | | `500` | `post_booking_failed`, `internal_error` | **Read the booking before you repeat the call.** | | `502` | `supplier_invalid_response` | The supplier's answer to the booking could not be read. **Read the booking before you repeat the call.** | | `503` | `supplier_busy`, `balance_lock_timeout` | Refused before anything was sent: retry after the seconds in `Retry-After`. | The `409 hotel_not_bookable_in_sandbox` belongs to `pre_book`; this is what it carries: ~~~json { "test_hotels": [ { "id": "test_hotel_do_not_book", "hid": 8473727, "residency": "any" }, { "id": "test_hotel", "hid": 8526976, "residency": [ "dz", "ru", "us" ] } ] } ~~~ ## Next step Poll [the booking](https://fx-port.com/docs/hotels/get-booking.md) until it is `confirmed`, then fetch the [voucher](https://fx-port.com/docs/hotels/voucher.md). To cancel, see [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md). ## Related - [Pre-book a rate](https://fx-port.com/docs/hotels/pre-book.md): Where the booking id comes from. - [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md): Poll the status, with the price and the voucher links. - [Get the hotel voucher](https://fx-port.com/docs/hotels/voucher.md): The PDF to hand to the traveler. - [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md): Free until 21:00 UTC on the check-in day in the sandbox. - [API reference: Book a locked rate](https://fx-port.com/docs/api/book-hotel.md): Every field and error. --- # Get a hotel booking > Retrieve the full hotel booking, including the pricing breakdown and the voucher PDF link, through the shared account booking endpoint. Endpoint: `GET /api/v1/bookings/{booking_id}?product=hotels` Source: https://fx-port.com/docs/hotels/get-booking Read a hotel booking: its **status** (the one to poll after [`book_hotel`](https://fx-port.com/docs/hotels/book.md)), what the traveler pays and what you were debited, the hotel and the dates, and the **voucher links**. Hotel bookings are read through the shared booking endpoint of the account area, documented in [Booking detail](https://fx-port.com/docs/account/booking-detail.md): pass `product=hotels`. This page says what to read for a hotel. **Permission:** Read ## Request `GET /api/v1/bookings/{booking_id}?product=hotels` | Part | Description | |---|---| | `booking_id` (path) | The `bookingId` of [`book_hotel`](https://fx-port.com/docs/hotels/book.md) (the `partnerOrderId` of `pre_book`). The internal `id` (a UUID) works too. | | `product` (query) | `hotels`. Without it the products are tried one after the other: it is slower and can match a booking of another product. | **Request** ```bash curl 'https://api.fx-port.com/api/v1/bookings/HMVXX4ZQ3?product=hotels' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` A booking is visible to **your agency only**, in the environment of your key: a sandbox booking is not found with a live key, and the other way round. An id that matches nothing answers `404 booking_not_found`. ## Response A real answer of the sandbox for a `confirmed` booking (trimmed; the id and the links are replaced): ~~~json { "environment": "sandbox", "success": true, "data": { "product": "hotels", "id": "24fec1e5-a09f-48ec-8050-1adaab0a8de1", "bookingId": "HMVXX4ZQ3", "status": "confirmed", "environment": "sandbox", "currency": "DZD", "totalPrice": 1484.97, "netPrice": 1482, "agencyCommission": 2.97, "providerName": "ETG - RateHawk", "supplierId": "ratehawk", "createdAt": "2026-11-01T15:32:10.51714+00:00", "updatedAt": "2026-11-01T15:32:26.802414+00:00", "hotelId": "test_hotel_do_not_book", "hotelName": "Test Hotel (Do Not Book) test", "checkinDate": "2026-12-01", "checkoutDate": "2026-12-04", "voucherUrl": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlWithPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlNoPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith-no-price.pdf", "record": { "id": "24fec1e5-a09f-48ec-8050-1adaab0a8de1", "booking_id": "HMVXX4ZQ3", "status": "confirmed", "currency": "DZD", "total_price": 1484.97, "agency_commission": 2.97, "hotel_id": "test_hotel_do_not_book", "hotel_name": "Test Hotel (Do Not Book) test", "checkin_date": "2026-12-01", "checkout_date": "2026-12-04", "bucket_link": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "bucket_link_no_price": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith-no-price.pdf", "supplier_id": "ratehawk", "created_at": "2026-11-01T15:32:10.51714+00:00", "updated_at": "2026-11-01T15:32:26.802414+00:00", "environment": "sandbox", "refund_amount": null, "refund_currency": null }, "pricing": [ { "id": "2cf80a07-f588-4a2a-afc6-c732ee205f8c", "created_at": "2026-11-01T15:32:16.39859+00:00", "agency_currency": "DZD", "total_price": 1484.97, "agency_commission": 2.97, "environment": "sandbox", "b2b_price": 1482 } ], "balanceOperations": [] }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox" } } ~~~ ## What to read | Field | Description | |---|---| | `data.status` | `pending`, `confirmed`, `failed`, `refunded`, `partial_refund` or `cancelled`. **Poll while it is `pending`**, every 2 seconds: it changes in 4 to 14 seconds. See [From pending to confirmed](https://fx-port.com/docs/hotels/book.md#from-pending-to-confirmed). | | `data.totalPrice` | What the traveler pays, in the currency of your agency. | | `data.netPrice` | **What was debited from your agency**: `totalPrice - agencyCommission`. It is `pricing[0].b2b_price`. Nothing is debited in the sandbox. | | `data.agencyCommission` | Your commission. | | `data.hotelId`, `hotelName`, `checkinDate`, `checkoutDate` | The hotel and the stay. The dates have no time. Send `hotelId` to [`get_hotel_info`](https://fx-port.com/docs/hotels/hotel-details.md) to show the hotel again. | | `data.voucherUrl`, `voucherUrlWithPrice`, `voucherUrlNoPrice` | The voucher PDFs: `voucherUrl` and `voucherUrlWithPrice` are the same link (the voucher with the price), `voucherUrlNoPrice` is the voucher without it. `null` until they are made, a few seconds after `confirmed`. The same links as [`get_voucher`](https://fx-port.com/docs/hotels/voucher.md). | | `data.supplierId`, `providerName` | The supplier of the booking: `ratehawk`, `ETG - RateHawk`. | | `record.refund_amount`, `refund_currency` | What was refunded to your agency, after a cancellation. `null` before. | | `pricing[]` | The price lines of the booking: `total_price`, `agency_commission` and `b2b_price` (what you pay). | | `balanceOperations[]` | The debits and credits of the booking on your balance. Empty in the sandbox. | The hotel keys of the summary also come as snake_case twins: `hotel_id`, `hotel_name`, `checkin`, `checkout`, `voucher_url`, `voucher_url_with_price` and `voucher_url_no_price`. See [Legacy names](https://fx-port.com/docs/legacy-names.md). The full field reference of the shared endpoint is in [Booking detail](https://fx-port.com/docs/account/booking-detail.md), and the list of your hotel bookings is [Booking history](https://fx-port.com/docs/account/history.md) with `product=hotels`. ## Good practice - **Poll every 2 seconds, not faster.** A booking changes within 4 to 14 seconds, and faster polling only uses your [rate limits](https://fx-port.com/docs/rate-limits.md). Stop at `confirmed` or `failed`. - **Read before you retry.** After a timeout or a `5xx` on `book_hotel` or `cancel_hotel`, this is the call that tells what happened. - **Keep `bookingId`.** It is the key of the booking everywhere: the detail, the voucher, the cancellation. ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `invalid_booking_id` | The reference is not a booking id. | | `404` | `booking_not_found` | No booking of your agency in this environment has this id, or it belongs to another product. | | `401`, `403` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | | `429` | `rate_limit_exceeded` | Too many calls: [Rate limits](https://fx-port.com/docs/rate-limits.md). | ## Related - [Book a hotel](https://fx-port.com/docs/hotels/book.md): Where the booking id comes from and how long `pending` lasts. - [Get the hotel voucher](https://fx-port.com/docs/hotels/voucher.md): The voucher links alone. - [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md): Free until 21:00 UTC on the check-in day in the sandbox. - [Booking detail](https://fx-port.com/docs/account/booking-detail.md): The shared endpoint, every field. --- # Get the hotel voucher > Retrieve the confirmation voucher PDF URL of a confirmed hotel booking. Endpoint: `GET /api/v1/hotels/get_voucher` Source: https://fx-port.com/docs/hotels/voucher Get the **voucher PDFs** of a hotel booking: the confirmation to hand to the traveler, with the price for you and without it for the traveler. Only the agency that owns the booking can read its voucher. **Permission:** Read ## Request `GET /api/v1/hotels/get_voucher` | Parameter | Type | Required | Description | |---|---|---|---| | `booking_id` | string | Yes | The `bookingId` of [`book_hotel`](https://fx-port.com/docs/hotels/book.md). | **Request** ```bash curl 'https://api.fx-port.com/api/v1/hotels/get_voucher?booking_id=HMVXX4ZQ3' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `partner_order_id`, `partnerOrderId`, `bookingId` | `booking_id` | ## Response A real answer of the sandbox (the links are replaced): ~~~json { "environment": "sandbox", "success": true, "data": { "bookingId": "HMVXX4ZQ3", "voucherUrl": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlWithPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlNoPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith-no-price.pdf", "status": "confirmed" } } ~~~ | Field | Description | |---|---| | `data.bookingId` | The booking. Also as `booking_id`. | | `data.voucherUrl`, `data.voucherUrlWithPrice` | **The same link**: the voucher PDF with the price. For your own records. Also as `voucher_url` and `voucher_url_with_price`. | | `data.voucherUrlNoPrice` | The voucher PDF **without the price**: hand this one to the traveler, so that they do not see what was paid. Also as `voucher_url_no_price`. | | `data.status` | The status of the booking. | Both variants are made for every confirmed booking. The same links are in the answer of the [booking detail](https://fx-port.com/docs/hotels/get-booking.md) (`voucherUrl`, `voucherUrlNoPrice`): use whichever call fits your flow, `get_voucher` is the lighter one when you only need the link. ## When the voucher exists The voucher is made **after the booking is `confirmed`**: at the same moment, or up to about 10 seconds later. Before that the call answers `404` with the code `pdf_not_found`. So: 1. Poll [the booking](https://fx-port.com/docs/hotels/get-booking.md) until it is `confirmed`. 2. Ask for the voucher; on `404 pdf_not_found`, wait 2 seconds and ask again, up to about 10 times. - A booking that is still `pending` has no voucher. - **The links stay after a cancellation**: the voucher of a cancelled booking is still returned, so do not hand it out again. ## The links are public The links are **permanent public addresses with no signature and no expiry**: anyone who has one can open the voucher, without a key. The file name is `--.pdf`, so it **contains the name of the first guest**. - Do not log them, post them in a ticket or send them to anybody who is not the traveler or your own staff. - Give the traveler `voucherUrlNoPrice`; keep `voucherUrl` for your agency. - Fetch the link again from this call when you need it rather than storing it in many places. ## Errors | HTTP | Code | Cause | |---|---|---| | `404` | `booking_not_found` | No booking has this id. | | `404` | `pdf_not_found` | The booking exists and its voucher is not made yet (`The voucher is not available yet ...`). Try again in 2 seconds. | | `403` | `forbidden` | The booking belongs to another agency. | | `400` | `invalid_booking_id` | `booking_id` is not 1 to 256 characters: letters, digits and `_ . : -`. | | `422` | `validation_error` | `booking_id` is missing. | | `503` | `service_overloaded` | The service is overloaded for a moment (the database pool is full). Nothing was read: retry after the seconds in `Retry-After`. | | `401`, `403` | | The key is missing, wrong or not allowed: [Authentication](https://fx-port.com/docs/authentication.md). | | `429` | `rate_limit_exceeded` | Too many calls: [Rate limits](https://fx-port.com/docs/rate-limits.md). | ## Related - [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md): The status, the price and the same links. - [Book a hotel](https://fx-port.com/docs/hotels/book.md): How a booking becomes `confirmed`. - [API reference: Get the voucher PDF links of a booking](https://fx-port.com/docs/api/get-hotel-voucher.md): Every field and error. --- # Cancel a hotel booking > Cancel a confirmed hotel booking owned by your agency. Requires Read+Write permission. Endpoint: `POST /api/v1/hotels/cancel_hotel` Source: https://fx-port.com/docs/hotels/cancel Cancel a **confirmed** hotel booking of your agency. FX-Port cancels it at the supplier and gives back what the cancellation schedule of the rate allows: everything before the free cancellation deadline, less (or nothing) after it. **Permission:** Read+Write (a read key answers `403 read_write_permission_required`) > [!WARNING] > **Only a confirmed booking can be cancelled** > > Right after [`book_hotel`](https://fx-port.com/docs/hotels/book.md) the booking is `pending`: wait until [the booking](https://fx-port.com/docs/hotels/get-booking.md) is `confirmed`. A booking that is `pending`, `failed`, or already `cancelled` or `refunded` answers `400 invalid_status`. > [!TIP] > **In the sandbox, cancel every booking you make** > > Every sandbox booking is a real order on the supplier's test contract. The test hotels cancel **for free until 21:00 UTC on the check-in day** (`test_hotel` until 00:00 UTC on that day). See [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md). ## Request body `POST /api/v1/hotels/cancel_hotel` | Field | Type | Required | Description | |---|---|---|---| | `booking_id` | string | Yes | The `bookingId` of [`book_hotel`](https://fx-port.com/docs/hotels/book.md). | **Request** ```bash curl https://api.fx-port.com/api/v1/hotels/cancel_hotel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "HMVXX4ZQ3" }' ``` ### Legacy names These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)). | Older name | Use instead | |---|---| | `partner_order_id`, `partnerOrderId`, `bookingId` | `booking_id` | ## Response A real answer of the sandbox, in 3.5 to 4 seconds (1 to 4 seconds is usual): ~~~json { "environment": "sandbox", "success": true, "status": "ok", "bookingId": "HMVXX4ZQ3", "bookingStatus": "refunded", "adminCancel": false, "refund": { "cancellationType": "free", "type": "Free cancellation", "agencyAmount": 1482, "travelerAmount": 1484.97, "currency": "DZD", "providerAmount": 3, "providerCurrency": "EUR" }, "refundProcessed": true, "webhookSent": true, "processingTime": 3.488 } ~~~ | Field | Description | |---|---| | `bookingStatus` | The new status of the booking: `refunded` (free cancellation), `partial_refund` (a penalty applied) or `cancelled` (nothing refunded). Also as `booking_status`. | | `refund.cancellationType` | `free` (the supplier refunded everything), `partial` (a penalty applied) or `none` (no refund). Also as `cancellation_type`. | | `refund.type` | The same as a label: `Free cancellation`, `Partial cancellation` or `No refund`. | | `refund.agencyAmount` | **What is credited back to your agency**, in your currency. For a free cancellation it is everything you were debited (`totalPrice - agencyCommission`). Also as `agency_refund_amount`. | | `refund.travelerAmount` | What the traveler gets back, in your currency. For a free cancellation it is the whole `totalPrice`. Also as `traveler_refund_amount`. | | `refund.currency` | Your agency currency. Also as `agency_currency`. | | `refund.providerAmount`, `providerCurrency` | What the supplier refunded, in its own currency (EUR). For information. | | `refundProcessed` | `true` when the refund was handled. In the sandbox nothing moves and it is still `true`. Also as `refund_processed`. | | `adminCancel` | `true` when FX-Port staff cancelled the booking. Always `false` for your call. | | `webhookSent` | An internal step of FX-Port, not a message to you: **FX-Port sends no webhooks to customers.** | | `processingTime` | Seconds the call took. | The keys are camelCase. Twins in snake_case sit next to them: `booking_id`, `booking_status`, `refund_processed` and `processing_time` at the top, and `cancellation_type`, `agency_refund_amount`, `traveler_refund_amount`, `supplier_refund_amount`, `agency_currency` and `supplier_currency` inside `refund`. See [Legacy names](https://fx-port.com/docs/legacy-names.md). ## What you get back | When you cancel | `bookingStatus` | `cancellationType` | Your refund | |---|---|---|---| | Before the free cancellation deadline | `refunded` | `free` | Everything you were debited. | | After it, with a penalty | `partial_refund` | `partial` | What was debited minus the penalty of the rate. | | After it, on a non-refundable rate | `cancelled` | `none` | Nothing. | When the deadline is, and what each window costs, is in the `cancellationPolicy` of the rate (`freeCancellationBefore` and `policies[]`): see [Get hotel rates](https://fx-port.com/docs/hotels/rates.md#cancellation-policy). Cancelling after the free window still succeeds; it is the refund that is smaller. We could not exercise the penalty windows in the sandbox, because the test hotels refund in full until the check-in day: the lines above for `partial` and `none` come from the refund rules of the code, not from a sandbox run. ## If the call times out The supplier sometimes takes about 30 seconds to answer a cancellation. The call then fails with **`503 supplier_timeout`**, and the cancellation **may or may not have happened**. So: 1. Read the booking: [`GET /bookings/{booking_id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md). 2. `refunded`, `partial_refund` or `cancelled`: it went through. 3. Still `confirmed`: call `cancel_hotel` again. In our tests the second call answered in under a second. ~~~python for attempt in range(3): try: response = requests.post(f"{API}/hotels/cancel_hotel", headers=HEADERS, json={"booking_id": booking_id}, timeout=60) except requests.Timeout: continue # read the booking if you can; it stays confirmed until the supplier answers if response.ok: break time.sleep(3) # a 5xx once in a while: the booking is still confirmed, ask again ~~~ ## Errors | HTTP | Code | Cause | |---|---|---| | `400` | `invalid_status` | The booking is not `confirmed`. | | `400` | `missing_field`, `invalid_booking_id` | `booking_id` is missing (`errors[0].field` is `booking_id`), or is not 1 to 256 characters of letters, digits and `_ . : -`. | | `400` | the supplier's code | The supplier refused the cancellation (`errors[0].type` is `supplier_error`). The booking is unchanged. | | `403` | `read_write_permission_required`, `forbidden` | A read key, or a booking of another agency. | | `404` | `booking_not_found` | No booking of your agency has this id in this environment. | | `502` | `supplier_invalid_response` | The supplier answered in a form that could not be used. The cancellation may or may not have happened: read the booking, and call again only if it is still `confirmed`. | | `503` | `supplier_timeout` | The supplier did not answer in time. Read the booking, then retry. | ## Related - [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md): The status after a cancellation, and what was refunded. - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md): The cancellation schedule of a rate. - [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md): What you were debited, and what the traveler paid. - [API reference: Cancel a confirmed hotel booking](https://fx-port.com/docs/api/cancel-hotel.md): Every field and error. --- # eSIMs API overview > Browse the package catalog, place orders, top up existing eSIMs and track usage across 190+ countries. Source: https://fx-port.com/docs/esims Sell travel data eSIMs across 190+ countries from a single API surface. FX-Port sources packages from its eSIM supplier network and prices them for your agency in your own currency: you never call a supplier directly. A package is a data plan for one destination (or one region); an order creates one eSIM from it; a topup adds a package to an eSIM that already exists. For a summary of what the API covers, who it is for and how to get access, see the [eSIM API for travel companies](https://fx-port.com/b2b-esims-api). > [!NOTE] > **Coverage and pricing grow automatically.** FX-Port continuously onboards more eSIM suppliers, > and each supplier independently expands its own network of mobile operators over time — both > add new countries, package sizes, and better rates/connectivity to the catalog. Your integration > picks up new packages with no code changes — always read `package_id` from the catalog response > rather than hard-coding it. ## How an integration works **Step 1: Read the catalog** [`GET /esims/packages?country=france`](https://fx-port.com/docs/esims/packages.md) returns the packages for a destination with prices in your currency. Keep the `package_id` of the package the traveler chooses. **Step 2: Create the order** [`POST /esims/orders`](https://fx-port.com/docs/esims/orders.md) with that `package_id` and the traveler's email and phone creates one eSIM. Keep `booking_id` and the ICCID (`data.iccid`) from the response. **Step 3: Give the traveler the eSIM** Show `sim.qrcode_url` or the `qrcode` text, or let the traveler open the `sharing.link`. [Setup instructions](https://fx-port.com/docs/esims/instructions.md) returns the same data again later, with installation steps. **Step 4: Follow the usage** [`GET /esims/usage/{iccid}`](https://fx-port.com/docs/esims/usage.md) returns the data left and the expiry. **Step 5: Top up when the traveler runs out** [`GET /esims/topup-packages/{iccid}`](https://fx-port.com/docs/esims/topups.md) lists what can be added; [`POST /esims/topups`](https://fx-port.com/docs/esims/topups.md) adds it. ## eSIM concepts Read these before integrating. - [eSIM pricing](https://fx-port.com/docs/esims/pricing.md): What each price field means and how to compute what is debited from your balance. - [Supplier continuity](https://fx-port.com/docs/esims/supplier-continuity.md): Why a topup can occasionally be unavailable, and how FX-Port protects your balance. ## Endpoints | Method | Path | Permission | Rate limit group | Description | |---|---|---|---|---| | `GET` | `/api/v1/esims/packages` | Read | Default | List the package catalog, filtered by country and type | | `GET` | `/api/v1/esims/compatible-devices` | Read | Default | List devices known to support eSIM | | `POST` | `/api/v1/esims/orders` | **Read+Write** | Booking | Create a new eSIM order | | `GET` | `/api/v1/esims/topup-packages/{iccid}` | Read | Default | List the packages that can top up one eSIM | | `POST` | `/api/v1/esims/topups` | **Read+Write** | Booking | Top up an existing eSIM | | `GET` | `/api/v1/esims/usage/{iccid}` | Read | Default | Usage of one eSIM | | `GET` | `/api/v1/esims/usage/bulk` | Read | Default | Usage of several eSIMs | | `GET` | `/api/v1/esims/bookings` | Read | Default | List the agency's eSIM orders (topups are not listed here) | | `GET` | `/api/v1/esims/bookings/{iccid}/topup-history` | Read | Default | Topups of one eSIM | | `GET` | `/api/v1/esims/bookings/{booking_id}/verify` | Read | Default | Check that a booking is yours | | `GET` | `/api/v1/esims/bookings/{booking_id}/setup-instructions` | Read | Default | Setup data (QR code, APN, steps) of an order | | `GET` | `/api/v1/esims/instructions/{iccid}` | Read | Default | Installation instructions for an ICCID | Only **order creation** and **topup creation** change anything: on live they debit your agency balance and provision the eSIM. Every other endpoint only reads. The full parameters, schemas and examples of every endpoint are in the [API reference](https://fx-port.com/docs/api.md). > [!WARNING] > `GET /esims/bookings/{booking_id}/setup-url` is **deprecated** and must not be used: it is replaced by > [setup-instructions](https://fx-port.com/docs/esims/instructions.md#setup-instructions-by-booking), which returns the > same data as structured JSON. It no longer returns any supplier data: it answers with the install links of the > order only (see [the setup link](https://fx-port.com/docs/esims/instructions.md#deprecated-the-setup-link)). ## Rate limits Every API key may send **1,200 requests per minute to the booking group** (creating an order and creating a topup) and **6,000 requests per minute to the default group** (every other eSIM endpoint: the catalog, devices, topup packages, usage, bookings, instructions). The counters are per key and per minute. A request over the limit is answered `429` with the code `rate_limit_exceeded`: see [Rate limits and fair use](https://fx-port.com/docs/rate-limits.md). Too many orders or topups of one key or agency running at the same time can be answered `429 concurrency_limit_exceeded`: retry after the seconds in `Retry-After`. Separately, the supplier limits how often usage can be read: see [eSIM usage](https://fx-port.com/docs/esims/usage.md). ## Sandbox behaviour A sandbox key (`fxp_test_...`) works against the supplier's test system, so every endpoint above can be tried end to end: | Behaviour | Detail | |---|---| | **An order creates a test eSIM** | The supplier's sandbox returns a real test eSIM with a dummy activation code (`LPA:1$lpa.airalo.com$DUMMY-...`). It cannot be installed on a phone. | | **Nothing is debited** | Orders and topups carry `balanceDebited`, `transactionCreated` and `payoutCreated` (twins: `balance_debited`, `transaction_created`, `payout_created`), all `false`. They are also on the error bodies of both endpoints. | | **Topup balance** | A topup answers `balance` `previous: 0.0` and `current: 0.0`: the sandbox has no balance. | | **Usage** | A new eSIM shows its full allowance and `ACTIVE`; usage does not change after a topup. | | **Bookings** | Sandbox orders and topups appear only to sandbox keys: orders in [bookings](https://fx-port.com/docs/esims/bookings.md), topups in the topup history, and both in the [account history](https://fx-port.com/docs/account/history.md) and the [booking detail](https://fx-port.com/docs/account/booking-detail.md). | | **Live-only checks** | The balance check (`402 insufficient_balance`), the 60-second duplicate check and the balance lock (`503 balance_lock_timeout`) only run on live. | ## Traveler contact details `email` and `phone` are **required** on [order](https://fx-port.com/docs/esims/orders.md) and [topup](https://fx-port.com/docs/esims/topups.md) creation — not optional. What happens to them: - `email`: on an **order**, FX-Port asks the supplier to email the QR code and a sharing link to this address as soon as the eSIM is created, so the traveler can install it without you relaying the QR code. On a **topup** the email is kept on the booking only; it is not sent to the supplier. - `phone`, `first_name`, `last_name`: kept on the booking and returned by the booking endpoints. They are not sent to the supplier. Only pass the traveler's own contact details — never billing, pricing, or account information. `first_name` and `last_name` are optional and are **not restricted to ASCII**: accents and non-Latin scripts are accepted as they are (only the length is checked). ## Conventions of the eSIM endpoints - **Bodies have `success`.** Every answer carries `success` (a call that fails also carries the usual error body, see below), except the answer of [verify](https://fx-port.com/docs/esims/bookings.md#check-that-a-booking-is-yours). - **Creations answer `200`**, not `201`, and have no `Location` header. - **JSON in, JSON out.** Send `Content-Type: application/json` on orders and topups (the media type is read in any case). A form-encoded body is also read, but a JSON body without the JSON content type is read as an empty form and refused with `missing_field`. - **Wrapped payloads.** Topup packages, usage and instructions wrap their payload one level deeper (`data.data`); each entry of bulk usage does it again (`data.data.data`). The reference shows each shape. - **Units and times.** Data is in megabytes. Times that come from the supplier (`created_at` of an order or eSIM, `expired_at` of usage) are `YYYY-MM-DD HH:MM:SS` in UTC without a zone; times stored by FX-Port (bookings) are ISO 8601 with a zone. - **No trailing slash.** `/api/v1/esims/packages/` is redirected with an empty body: call the path without the slash. - **Amounts** are in the currency of your agency (`currency`), never in the supplier's. - **Spellings that are read.** An ICCID may be written in groups of spaces or hyphens (`8944 4654 0000 0123 456`); `locale` takes any spelling of a locale (`zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, an `Accept-Language` value) and `country` a country name (`United States`); an empty `page`, `limit`, `force` or `all` (`?page=`) means not sent. A value that is really wrong keeps its stable code (`invalid_iccid`, `invalid_locale`, `invalid_country` ...). - **Names.** Several answers keep a legacy camelCase or short key next to its snake_case twin (`code` and `order_id`, `id` and `package_id`, `balanceDebited` and `balance_debited`, `pagination.pages` and `total_pages` ...). Both carry the same value; use the snake_case one in new code. `?naming=snake` returns every key in snake_case. See [Conventions and naming](https://fx-port.com/docs/conventions.md). ## Error responses Every error has a stable `code` in `errors[0].code`: branch on it, not on the message. See [Errors](https://fx-port.com/docs/errors.md) for the shape and every shared code. | HTTP | Typical `code` | Cause | |---|---|---| | `400` | `missing_field`, `invalid_package_id`, `invalid_iccid`, `invalid_email`, `invalid_phone`, `invalid_type`, `invalid_json` | A required field is missing or has the wrong format | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `402` | `insufficient_balance` | Agency balance lower than the amount to debit (live) | | `403` | `read_write_permission_required`, `agency_not_active` | The key has read permission only, or the agency is not active | | `404` | `not_found`, `iccid_not_found`, `booking_not_found` | The package, the eSIM or the booking does not exist for your agency | | `409` | `duplicate_request` | The same order or topup is already being processed or was just confirmed | | `422` | `validation_error` | A query parameter has the wrong type or range (for example `limit=500`) | | `429` | `rate_limit_exceeded`, `concurrency_limit_exceeded` | Too many requests per minute, or too many orders or topups of your key or agency being processed at the same time. Retry after the seconds in `Retry-After` | | `500` | `internal_error`, `booking_record_failed`, `balance_debit_failed`, `supplier_response_invalid` | An error on our side; the last three come after the supplier created the eSIM | | `503` | `service_unavailable`, `balance_lock_timeout`, `duplicate_check_unavailable`, `service_busy` | The supplier connection, a lock or the service is temporarily unavailable; retry in a few seconds | When the **supplier** refuses an order or a topup, the response is a `supplier_error` with the supplier's own HTTP status (for example `422` or `429`), nothing is debited, and a `failed` booking is saved for support. Usage, topup packages, topups and instructions answer `404 iccid_not_found` for an ICCID that is not an eSIM of your agency; setup instructions answer `404 booking_not_found` for a booking that is not yours. **Loading answers.** Right after a restart the catalog and the device list may not be loaded yet: `GET /esims/packages` and `GET /esims/compatible-devices` then answer **`202`** with `"success": false` and an empty `data`, and the device list can answer `503` while its fetcher starts. This is not an error of your request: wait a few seconds and send it again. ## Questions? Email the FX-Port developer team at [dev@fx-port.com](mailto:dev@fx-port.com) for integration help, feature requests, or to report an API error. --- # eSIM pricing > What each eSIM price field means, what is debited from your balance and how the commission is read. Source: https://fx-port.com/docs/esims/pricing Every eSIM package and topup package carries a set of money fields, computed on the server in your agency's currency. Any pricing sent in a request body is ignored: every order creates one eSIM at the price the server computes. The same word means different amounts in different endpoints. Read this page once, then use the table [at the end](#what-price-means-in-each-response) when you are unsure. ## Field meanings These are the fields of a package in `GET /api/v1/esims/packages`: | Field | Meaning | |---|---| | `base_price` | The **base price** of the package in your agency currency, before your commission. It is **not** the amount debited from your balance. `price` carries the same value (the legacy name: in the catalog `price` is the base price, while in orders and topup packages it is the retail price, which is why `base_price` is the name to use) | | `retail_price` / `total_price` | The price to charge the traveler, in your agency currency. Both fields have the same value; it includes your commission | | `balance_debit_amount` | **The amount debited from your agency balance** when you order the package (live). Read this field: do not compute it from `base_price`. `b2b_price` carries the same value (the name flights and hotels use) | | `agency_commission` | Your commission **rate**, as text: `"12 %"` in the examples, `"0 %"` for contracted corporate agencies. In bookings it is an **amount** (see below) | | `commission_rate` | The same rate as a **number**: `12` in the examples, `0` for contracted corporate agencies. Use it when you compute | | `currency` | Your agency's settlement currency: the currency of every amount | | `supplier_currency` | The currency the supplier prices the package in (for example `"USD"`) | | `exchange_rate` | The rate `supplier_currency` → `currency` used for this package | ## What is debited from your balance The amount debited when you order a package is **`balance_debit_amount`**. Your commission is what is left of the retail price: ~~~ commission = total_price − balance_debit_amount ~~~ `balance_debit_amount` can be **higher than `base_price`**: amounts that are specific to your agency are part of what is debited, so the relation between `base_price` and the other amounts differs from agency to agency. Your commission is the rate of `agency_commission` applied to `balance_debit_amount`, rounded up to the cent (`12 %` of `418.60` is `50.232`, shown as `50.24`). Never rebuild `balance_debit_amount` or `total_price` from `base_price`: read them. **Example** (a sandbox agency that settles in `DZD`, supplier currency `USD`, package `france-in-7days-1gb`): ~~~json { "price": 402.5, "base_price": 402.5, "retail_price": 468.84, "total_price": 468.84, "balance_debit_amount": 418.6, "b2b_price": 418.6, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250.0 } ~~~ Check: `468.84 − 418.60 = 50.24`, the commission. After the order, the booking of this eSIM has `total_price: 468.84` and `agency_commission: 50.24` (an amount), and the [account history](https://fx-port.com/docs/account/history.md) shows `netPrice: 418.6` (twin `b2b_price`), the amount that was debited. > [!NOTE] > A contracted corporate agency has `agency_commission` `"0 %"`: `total_price` and `balance_debit_amount` > are then equal. ## What `price` means in each response | Response | `price` | Where the other amounts are | |---|---|---| | `GET /esims/packages` | Base price, **before** your commission (`base_price` has the same value) | `total_price` / `retail_price`, `balance_debit_amount` / `b2b_price` | | `GET /esims/topup-packages/{iccid}` | The **retail** price: same as `total_price` | `base_price` is the base price (what `price` is in the catalog), `commission` is your commission as an **amount**, `balance_debit_amount` / `b2b_price` is the debit | | `POST /esims/orders`, `POST /esims/topups`, setup instructions | The **retail** price the traveler pays: same as `total_price` | `currency`, `agency_currency`, `supplier_currency`, `exchange_rate` | | `GET /esims/bookings`, topup history | No `price` | `total_price`, and `agency_commission` as an **amount** | | [Account history and booking detail](https://fx-port.com/docs/account/history.md) | No `price` | `totalPrice`, `agencyCommission` (amount) and `netPrice`, the amount debited. Their twins are `total_price`, `agency_commission` and `b2b_price` | Two consequences: - `agency_commission` is a **rate string** (`"12 %"`) in the catalogs, with the number `commission_rate` next to it, and an **amount** (`50.24`) in bookings and in the account endpoints. - A topup package's `price` is **not** comparable with the `price` of a catalog package: compare `base_price` with `base_price`, or `total_price` with `total_price`. Use `base_price` and `total_price` everywhere and the ambiguity disappears. ## Supplier currency FX-Port sources eSIM packages from its supplier network, and each supplier settles in its own currency (configured per supplier on the FX-Port side — not hard-coded). `exchange_rate` is always `supplier_currency → currency`, never assumed to be USD. If a package's `supplier_currency` already matches your agency `currency`, `exchange_rate` is `1.0`. Every amount you read is already converted: you never convert yourself. ## Order and topup responses `POST /api/v1/esims/orders` and `POST /api/v1/esims/topups` return the pricing block nested under `data.order` / `data.topup` as `price`, `total_price`, `retail_price`, `currency`, `agency_currency`, `supplier_currency` and `exchange_rate`. The supplier's own cost is not part of a response. A topup adds a `balance` block with `previous` and `current`, the agency balance in the order's currency before and after the debit; in the sandbox both are `0.0` because nothing is debited. --- # Supplier continuity > Why a topup can occasionally be unavailable for an existing eSIM, and how FX-Port protects your agency balance when that happens. Source: https://fx-port.com/docs/esims/supplier-continuity An eSIM plan is provisioned on a specific mobile network operator in the destination country. FX-Port's suppliers choose which operators serve a country, and in rare cases a supplier **changes which operator serves a country or a package**. Operator and package availability can therefore change over time, which is why you should read the current catalog and topup packages instead of storing them for long. ## What this means for topups If the operator of a country changes after a traveler's eSIM was issued, the package of that eSIM can be discontinued for this ICCID even though the eSIM itself is still active. The eSIM can keep working until it expires, but **a topup of the old operator's package is no longer possible**: a package of the current operator is needed, which usually means a new eSIM order rather than a topup of the old ICCID. This is uncommon, but design for it: it is a supplier-side change, not an error of your integration. ## How FX-Port handles it FX-Port never assumes that a topup package is still valid. The flow makes sure this situation cannot fail silently or take your money: 1. **`GET /api/v1/esims/topup-packages/{iccid}`** asks the supplier for the exact ICCID, not for the package of the original order. If the supplier no longer offers a topup for this eSIM, the list is empty. The answer is kept for **5 minutes** per eSIM and language (`cached: true`); `force=true` asks the supplier again. 2. **`POST /api/v1/esims/topups`** debits your agency balance and records a confirmed booking **only after** the supplier accepts the topup. If the supplier rejects it, including because the package or operator of this ICCID is no longer topup-eligible, FX-Port: - does **not** touch your agency balance, - records the attempt as a `failed` booking for support, and - answers with a `supplier_error` (the supplier's HTTP status) so your integration can react at once. 3. Both endpoints serve only the eSIMs **your agency ordered**. An ICCID that is unknown or belongs to another agency is answered `404 iccid_not_found`, and the supplier is not asked. ## Recommended integration pattern ~~~bash # 1. Check topup availability for the exact ICCID first curl --request GET \ --url https://api.fx-port.com/api/v1/esims/topup-packages/8900000540463077957 \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ For an eSIM that cannot be topped up now, the list is empty: ~~~json { "success": true, "data": { "data": [] } } ~~~ An empty list means this eSIM cannot currently be topped up. Place a new [eSIM order](https://fx-port.com/docs/esims/orders.md) for the traveler instead of retrying. If packages are returned, continue with [Create eSIM topup](https://fx-port.com/docs/esims/topups.md#2-create-the-topup) using one of the returned `package_id` values. Never reuse the `package_id` of the original order, and do not keep a topup package for later: ask again when the traveler wants to top up. --- # Package catalog > List eSIM data packages by country and type, and check device compatibility. Source: https://fx-port.com/docs/esims/packages ## List packages `GET /api/v1/esims/packages` **Permission:** Read Returns the package catalog with prices computed for your agency, in your agency's currency. Results are cached server-side and refreshed periodically, so repeated calls are cheap. > [!WARNING] > **Always send `country`.** The unfiltered catalog is about **4 MB** (more than 2,000 packages), has > **no pagination** and takes several seconds. Ask for one destination at a time, and accept > compressed responses (`Accept-Encoding: gzip`). ### Query parameters | Parameter | Type | Required | Description | |---|---|---|---| | `country` | string | No | Country or region slug, for example `france`, `turkey` or `europe`. Take it from `country_slug` of a package. A country name as people write it is read as its slug (`United States` is `united-states`, `Bosnia & Herzegovina` is `bosnia-and-herzegovina`); case and accents do not matter. An unknown slug, or a name that matches no slug, returns an empty list. | | `type` | string | No | Scope of the package: `local` (one country) or `global` (every regional and worldwide bundle). Any other value that is a plain word (for example `regional`) returns an empty list: there is no `regional` value. A value with spaces or symbols is refused with `invalid_type`. Upper case is accepted. | | `locale` | string | No | Language of the text fields. Falls back to the `Accept-Language` header, then `en`. Any spelling of a locale is read and only its language counts: `fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9` (the first language is used). Supported: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi`, `zh`. Another well-formed language is answered in English with a `coerced_value` warning, and `meta.locale` says `en`. A value that is not a locale is refused with `invalid_locale`. | **Legacy names** (still work; the response carries a `deprecated_field` warning): | Legacy | Use instead | |---|---| | `filter[type]` | `type` | `all_pages` is an old parameter that does nothing: every call returns the whole filtered catalog, and the response lists `all_pages` in its `warnings` as `ignored_field`. **cURL** ~~~bash curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/packages?country=france&type=local&locale=en' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const params = new URLSearchParams({ country: "france", type: "local", locale: "en" }); const response = await fetch(`https://api.fx-port.com/api/v1/esims/packages?${params}`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); if (response.status === 202) { // the catalog is still loading after a restart: wait a few seconds and retry } const { data: packages, meta } = await response.json(); console.log(meta.total_packages, packages[0].package_id, packages[0].balance_debit_amount); ~~~ **Python** ~~~python import requests response = requests.get( "https://api.fx-port.com/api/v1/esims/packages", params={"country": "france", "type": "local", "locale": "en"}, headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) body = response.json() for package in body["data"]: print(package["package_id"], package["title"], package["total_price"], package["currency"]) ~~~ ### Response example Example response for `GET /api/v1/esims/packages?country=france&type=local` (one of the twelve packages shown, lists trimmed): ~~~json { "environment": "sandbox", "success": true, "endpoint": "/packages", "data": [ { "id": "france-in-7days-1gb", "package_id": "france-in-7days-1gb", "type": "sim", "price": 402.5, "base_price": 402.5, "amount": 1024, "day": 7, "is_unlimited": false, "title": "1 GB - 7 days", "is_fair_usage_policy": false, "fair_usage_policy": null, "data": "1 GB", "voice": null, "text": null, "country_name": "France", "country_code": "FR", "country_slug": "france", "country_image_url": "https://cdn-revamp.airalo.com/images/9753dedb-d495-47cf-b6e4-82e555564743.png", "operator_name": "KPN France Data only", "operator_id": 1704, "operator_style": "dark", "operator_gradient_start": "#F1D4FB", "operator_gradient_end": "#EEC0FF", "operator_type": "local", "operator_is_prepaid": false, "operator_esim_type": "Prepaid", "operator_warning": null, "operator_apn_type": "automatic", "operator_is_roaming": true, "operator_info": ["Data-only eSIM.", "Rechargeable online with no expiry."], "operator_image_url": "https://cdn-revamp.airalo.com/images/01b47d41-fbb4-4032-8f5a-d1ca6a0f3688.png", "operator_plan_type": "data", "operator_activation_policy": "first-usage", "operator_is_kyc_verify": false, "operator_rechargeability": true, "operator_other_info": "Covers metropolitan (mainland) France and Corsica. Does not include overseas territories (Guadeloupe, Martinique, Reunion, ...", "operator_coverages": [ { "name": "FR", "code": "FR", "networks": [{ "name": "Orange", "types": ["5G"] }] } ], "operator_apn": { "ios": { "apn_type": "automatic", "apn_value": null }, "android": { "apn_type": "automatic", "apn_value": null } }, "is_regional": false, "is_global": false, "retail_price": 468.84, "total_price": 468.84, "balance_debit_amount": 418.6, "b2b_price": 418.6, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250.0 } ], "meta": { "total_packages": 12, "filters_applied": { "country": "france", "type": "local" }, "locale": "en", "agency_currency": "DZD", "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250.0, "commission_percentage": "12 %", "commission_rate": 12 } } ~~~ Save `package_id` from the response — it's what you pass as `package_id` when [creating an order](https://fx-port.com/docs/esims/orders.md). `id` carries the same value (it is the legacy name; keep reading it if your code already does). The package object is described in full in the [eSIM package object](https://fx-port.com/docs/api/objects/esim-package.md). ### Response field reference **Package identity and data allowance** | Field | Type | Meaning | |---|---|---| | `package_id` | string | Package identifier — pass it as `package_id` when [creating an order](https://fx-port.com/docs/esims/orders.md). `id` carries the same value (legacy name) | | `type` | string | Always `sim`: the package is sold as a new eSIM (topup packages have `type` `topup`) | | `title` | string | Human-readable package label (data amount + validity) in the language of the request | | `data` | string | Data allowance for the package, e.g. `"1 GB"` or `"Unlimited"` | | `amount` | integer | Data allowance in MB. **`0` means unlimited** — check `is_unlimited` rather than treating `0` as "no data" | | `is_unlimited` | boolean | `true` for unlimited-data packages | | `is_fair_usage_policy` | boolean or integer | Whether a fair usage policy throttles the speed after a threshold. It is a boolean on limited packages and `0` or `1` on unlimited ones: treat `0` as false and `1` as true. Read `fair_usage_policy` for the exact terms | | `fair_usage_policy` | string \| null | Human-readable fair-usage terms (e.g. throttle speed and daily threshold); `null` when no policy applies | | `day` | integer | Validity in days (see `operator_activation_policy` for when it starts) | | `voice` | integer \| null | Voice minutes included, when the package bundles voice (`null` = data-only) | | `text` | integer \| null | SMS included, when the package bundles SMS (`null` = data-only) | | `is_regional` | boolean | **Always `false`.** Regional bundles (for example `europe`) are `global` packages: check `operator_type` | | `is_global` | boolean | `true` when `operator_type` is `global` | **Country** | Field | Type | Meaning | |---|---|---| | `country_name` | string | Destination country or region, human-readable, in the language of the request | | `country_code` | string | Destination country, ISO 3166-1 alpha-2. An empty string for regional and worldwide bundles | | `country_slug` | string | Destination slug — pass as the `country` query parameter. Regions have their own slug (`europe`) | | `country_image_url` | string | Flag/country image URL for display | **Operator (underlying mobile network)** | Field | Type | Meaning | |---|---|---| | `operator_name` | string | Underlying mobile network operator/brand for this package | | `operator_id` | integer | Identifier of the operator | | `operator_type` | string | Package scope: `local` (one country) or `global` (regional and worldwide bundles). There is no `regional` value | | `operator_style` / `operator_gradient_start` / `operator_gradient_end` | string | Display styling hints (light/dark theme, gradient colors) for this operator's branding | | `operator_is_prepaid` | boolean | Whether the underlying plan is prepaid | | `operator_esim_type` | string | e.g. `Prepaid` | | `operator_warning` | string \| null | Operator-specific caution to show travelers (e.g. topup window), when applicable | | `operator_apn_type` | string | `automatic` or `manual` — whether APN settings are auto-configured | | `operator_is_roaming` | boolean | Whether this package operates via roaming rather than a local SIM profile | | `operator_info` | string[] | Short bullet facts about the operator/network (technology, rechargeability, coverage) | | `operator_image_url` | string | Operator logo URL | | `operator_plan_type` | string | e.g. `data` (data-only plan) | | `operator_activation_policy` | string | When validity starts, e.g. `first-usage` (starts on first connection, not purchase) | | `operator_is_kyc_verify` | boolean | Whether the destination country requires identity verification for this operator (rare; relevant for a few countries) | | `operator_rechargeability` | boolean | Whether this eSIM can be topped up — see [Top up an eSIM](https://fx-port.com/docs/esims/topups.md) | | `operator_other_info` | string \| null | Longer free-text coverage notes/exclusions (e.g. territories not covered) | | `operator_coverages` | array | Per-country network list: `{ name, code, networks: [{ name, types }] }` | | `operator_apn` | object | APN settings per platform: `{ ios: { apn_type, apn_value }, android: { apn_type, apn_value } }`; `apn_value` is `null` when the setup is automatic | **Pricing** — four different amounts, see [eSIM pricing](https://fx-port.com/docs/esims/pricing.md) for how to use them | Field | Type | Meaning | |---|---|---| | `base_price` | number | **Base price** in your agency currency, before your commission. It is **not** the amount debited from your balance. `price` carries the same value (legacy name) | | `retail_price` / `total_price` | number | The price to charge the traveler, in your agency currency. Both fields have the same value and include your commission | | `balance_debit_amount` | number | The amount **debited from your agency balance** when you order this package. `total_price` minus this amount is your commission. `b2b_price` carries the same value (the name flights and hotels use) | | `agency_commission` | string | Your commission **rate** as text: `"12 %"` for standard agencies, `"0 %"` for contracted corporate agencies. Bookings carry the commission as an **amount** instead. `commission_rate` carries the same rate as a number (`12` or `0`) | | `currency` | string | Your agency's settlement currency, the currency of every amount above | | `supplier_currency` | string | Currency the supplier prices the package in | | `exchange_rate` | number | Rate used to convert `supplier_currency` to `currency` for this package | ### `meta` field reference | Field | Meaning | |---|---| | `total_packages` | Number of packages in `data` after filtering | | `filters_applied` | Echo of the `country` / `type` filters actually applied | | `locale` | Language used for text fields in this response | | `agency_currency` | Same as `currency` on each package — your agency's settlement currency. `currency` carries the same value (the canonical name) | | `supplier_currency` | Same as `supplier_currency` on each package | | `exchange_rate` | Same as `exchange_rate` on each package | | `commission_percentage` | The commission rate applied across this response, as text. `commission_rate` carries it as a number | ### Caching, languages and speed - **English** is served from a cache that is refreshed about every 50 minutes. - **Another language** is fetched from the supplier on its first call (about 10 seconds) and then cached for an hour: the first request in a new language is slow, the next ones are fast. Package titles are translated; the operator facts are only partly. - A regional bundle appears under its region slug (`country=europe`), with `operator_type` `global`. ### Errors | HTTP | `code` | Cause | |---|---|---| | `202` | — | The catalog is still loading after a restart. `success` is `false` and `data` is empty: wait a few seconds and retry | | `400` | `invalid_country`, `invalid_type`, `invalid_locale` | A parameter is not valid | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | ## Compatible devices `GET /api/v1/esims/compatible-devices` **Permission:** Read Returns the cached list of devices known to support eSIM (about 630 entries), useful for pre-sale device checks before a traveler purchases a package. The list is refreshed about once a week. **cURL** ~~~bash curl --request GET \ --url https://api.fx-port.com/api/v1/esims/compatible-devices \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const response = await fetch("https://api.fx-port.com/api/v1/esims/compatible-devices", { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: devices } = await response.json(); const supported = devices.some((device) => device.model === "iPhone15,2"); ~~~ ### Response example ~~~json { "environment": "sandbox", "success": true, "data": [ { "model": "T803D", "os": "android", "brand": "Alcatel", "name": "V3 Ultra" }, { "model": "iPhone15,2", "os": "ios", "brand": "Apple", "name": "iPhone 14 Pro" } ], "meta": { "total_devices": 631, "cached_at": "2026-11-01T15:07:43.957236", "cache_age_days": 0.01 } } ~~~ ### Response field reference | Field | Meaning | |---|---| | `brand` | Device manufacturer, spelled as the supplier does (the case differs between entries) | | `model` | Manufacturer's internal model identifier | | `name` | Human-readable marketing name for the device | | `os` | Operating system family: `ios` or `android` | | `meta.total_devices` | Number of devices in `data` | | `meta.cached_at` | When the list was fetched from the supplier: the server's clock **without a zone** (`2026-11-01T15:07:43.957236`), unlike the UTC timestamps of the bookings | | `meta.cache_age_days` | Age of the list in days | > [!NOTE] > Right after a restart the list can still be loading in the background: the call answers **`202`** with > `"success": false` and an empty `data` array, or **`503`** while the device fetcher itself is not > running yet. Retry after a few seconds. --- # Create an eSIM order > Create a new eSIM order. Requires a read+write API key. Source: https://fx-port.com/docs/esims/orders `POST /api/v1/esims/orders` **Permission:** Read+Write Creates one eSIM, debits your agency balance (live only) and provisions the eSIM with the supplier. The response carries the eSIM with its activation data. Pricing is always computed server-side — see [eSIM pricing](https://fx-port.com/docs/esims/pricing.md). ## Request Send `Content-Type: application/json` (the media type is read in any case). **cURL** ~~~bash curl --request POST \ --url https://api.fx-port.com/api/v1/esims/orders \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --header 'Content-Type: application/json' \ --data '{ "package_id": "france-in-7days-1gb", "first_name": "John", "last_name": "Smith", "email": "traveler@example.com", "phone": "+15555550123", "description": "Order for John Smith" }' ~~~ **Node.js** ~~~js const response = await fetch("https://api.fx-port.com/api/v1/esims/orders", { method: "POST", headers: { Authorization: "Bearer fxp_test_YOUR_KEY", "Content-Type": "application/json" }, body: JSON.stringify({ package_id: "france-in-7days-1gb", first_name: "John", last_name: "Smith", email: "traveler@example.com", phone: "+15555550123", description: "Order for John Smith", }), }); const body = await response.json(); if (!response.ok) throw new Error(body.errors?.[0]?.code ?? `HTTP ${response.status}`); const bookingId = body.booking_id; // keep it const iccid = body.data.iccid; // keep it too: the same value as data.sim.iccid ~~~ **Python** ~~~python import requests response = requests.post( "https://api.fx-port.com/api/v1/esims/orders", headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, json={ "package_id": "france-in-7days-1gb", "first_name": "John", "last_name": "Smith", "email": "traveler@example.com", "phone": "+15555550123", "description": "Order for John Smith", }, timeout=100, ) body = response.json() response.raise_for_status() booking_id = body["booking_id"] iccid = body["data"]["iccid"] # the same value as data.sim.iccid ~~~ ### Body fields | Field | Type | Required | Description | |---|---|---|---| | `package_id` | string | **Yes** | `id` from [the package catalog](https://fx-port.com/docs/esims/packages.md). 1 to 128 characters: letters, digits, `-`, `_`, `.` | | `email` | string | **Yes** | The traveler's email address (at most 254 characters). FX-Port asks the supplier to email the QR code and a sharing link to this address | | `phone` | string | **Yes** | The traveler's phone number: at least 3 digits, at most 32 characters. No further format is checked. Kept on the booking; it is not sent to the supplier | | `first_name` / `last_name` | string | No | Traveler name, at most 100 characters. **Any characters are accepted** (`José`, `Çelik-Ünal`, non-Latin scripts). Kept on the booking only | | `description` | string | No | A note for your own records, at most 500 characters. It is sent to the supplier with the order and returned as `order.description` | | `agent_id` | string (UUID) | No | The id of the user of your agency who places the order. It is stored on the booking (`agentId` in the account history). It must be a user of your agency: another id is refused with `invalid_agent_id`. Most integrations omit it | **Legacy names** (still work; when both spellings are sent, the new one wins): | Legacy | Use instead | Notes | |---|---|---| | `user_id` | `agent_id` | The response carries a `deprecated_field` warning | | `customer_email` | `email` | The name the booking rows use. Accepted without a warning | | `customer_phone` | `phone` | Same | | `customer_first_name` | `first_name` | Same | | `customer_last_name` | `last_name` | Same | > [!WARNING] > There is no `quantity` field. Every order creates **exactly one** eSIM — pricing and the agency > balance debit only ever account for a single eSIM. `quantity` and every price field > (`price`, `total_price`, `net_price`, `retail_price`, `base_price`, `currency`) are **ignored**: they are > listed in the `warnings` of the response as `ignored_field`. To get more eSIMs, call this endpoint once per eSIM (on live, > two orders of the same package must be 60 seconds apart: see [Duplicates, timeouts and retries](#duplicates-timeouts-and-retries)). > [!WARNING] > Only pass the traveler's own email and phone number — never billing, pricing, or account information. > The email is sent to the supplier so that the traveler receives the QR code; the other contact fields stay in FX-Port. ## Response example A sandbox order (long values shortened with `...`). The response has status **200**, not 201. ~~~json { "environment": "sandbox", "success": true, "data": { "order": { "code": "20261101-106852", "order_id": "20261101-106852", "package_id": "france-in-7days-1gb", "type": "sim", "description": "Order for John Smith", "esim_type": "local", "created_at": "2026-11-01 15:23:22", "package": "KPN France Data only-1 GB - 7 days", "data": "1 GB", "validity": 7, "price": 468.84, "currency": "DZD", "total_price": 468.84, "retail_price": 468.84, "agency_currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250.0, "voice": null, "text": null, "coverage": "" }, "sim": { "iccid": "8900000540463077957", "created_at": "2026-11-01 15:23:22", "lpa": "lpa.airalo.com", "imsis": null, "qrcode": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qrcode_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=...", "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "apn_type": "automatic", "apn_value": "N/A", "apn": { "ios": { "apn_type": "automatic", "apn_value": "N/A" }, "android": { "apn_type": "automatic", "apn_value": "N/A" } }, "is_roaming": true, "confirmation_code": null, "msisdn": null, "sharing": { "access_code": 8803, "link": "https://esims.cloud/v8hy7-usgkq6wg" } }, "iccid": "8900000540463077957" }, "booking_id": "FX-1793546602916", "message": "eSIM order placed successfully", "balanceDebited": false, "transactionCreated": false, "payoutCreated": false, "balance_debited": false, "transaction_created": false, "payout_created": false } ~~~ Save `data.iccid` (the same value as `data.sim.iccid`) — it's required for [topups](https://fx-port.com/docs/esims/topups.md), [usage](https://fx-port.com/docs/esims/usage.md), and [installation instructions](https://fx-port.com/docs/esims/instructions.md). Save `booking_id` for [booking lookups](https://fx-port.com/docs/esims/bookings.md). If `sim` is `null`, the supplier has not returned the eSIM yet: the booking exists, `data.iccid` is the placeholder `PENDING-` followed by the order code, and the data of the eSIM is available through [setup instructions](https://fx-port.com/docs/esims/instructions.md) once the supplier has it. ### Response field reference **`data.order`** | Field | Type | Meaning | |---|---|---| | `code` | string | The supplier's order code. `order_id` carries the same value: it is the name the booking rows use, and the one to use in new code | | `package_id` | string | The package that was ordered | | `type` | string | Always `sim` | | `description` | string \| null | The `description` you sent | | `esim_type` | string | Kind of eSIM plan, for example `local` | | `created_at` | string | When the supplier created the order: `YYYY-MM-DD HH:MM:SS`, **UTC, without a zone** | | `package` | string | Name of the package: operator, data allowance and validity | | `data` | string | Data allowance as text | | `validity` | integer | Validity in days | | `price` / `total_price` / `retail_price` | number | What the traveler pays, in your agency currency. **All three are the retail price** (`retail_price` is the explicit name of `total_price`) — in the catalog `price` is a different amount, see [eSIM pricing](https://fx-port.com/docs/esims/pricing.md) | | `currency` / `agency_currency` | string | Your agency currency (both fields have the same value) | | `supplier_currency` / `exchange_rate` | string / number | The supplier's currency and the rate to your currency | | `voice` / `text` | integer \| null | Voice minutes / SMS included, or `null` | | `coverage` | string | Coverage text taken from the installation instructions; can be an empty string | **`data.sim`** — the activation data is sensitive: show it only to the traveler who owns the eSIM | Field | Type | Meaning | |---|---|---| | `iccid` | string | The ICCID of the eSIM | | `lpa` | string \| null | Host of the SM-DP+ server | | `qrcode` | string \| null | The activation string the QR code encodes: `LPA:1$$` | | `qrcode_url` | string \| null | Link to the QR code image | | `direct_apple_installation_url` | string \| null | Link that starts the installation on an iPhone | | `apn_type` / `apn_value` / `apn` | string / string / object | The access point name, per platform in `apn` | | `is_roaming` | boolean \| null | Whether the eSIM works through roaming | | `imsis`, `confirmation_code`, `msisdn` | string \| null | Extra identifiers, `null` for most plans | | `sharing` | object \| null | `access_code` and `link`: a page where the traveler can open the eSIM | | `created_at` | string \| null | When the supplier created the eSIM, same format as the order | **Top level** | Field | Meaning | |---|---| | `booking_id` | The FX-Port booking reference, `FX-` followed by a number. It is top-level, not inside `data` | | `data.iccid` | The ICCID of the eSIM, the same value as `data.sim.iccid`; `PENDING-` followed by the order code while `sim` is `null` | | `message` | A confirmation sentence | | `balanceDebited`, `transactionCreated`, `payoutCreated` | **Sandbox only**, always `false`: nothing was debited, no balance transaction and no supplier payout were created. `balance_debited`, `transaction_created` and `payout_created` carry the same values and are the names to use in new code. Also present on the error bodies of this endpoint | ## What happens in the sandbox A sandbox key creates a test eSIM at the supplier's sandbox: the activation code is a dummy (`DUMMY-...`), the `qrcode_url` is on the supplier's sandbox host, **no balance is checked or debited**, and the three flags above are `false`. The booking is stored with `environment: "sandbox"` and appears in [bookings](https://fx-port.com/docs/esims/bookings.md) and in the [account history](https://fx-port.com/docs/account/history.md) for sandbox keys only. The balance check, the 60-second duplicate check and the balance lock run on live only. ## Duplicates, timeouts and retries There is no idempotency key. FX-Port protects you from a double order in two ways: - while an order for the same package is being processed, a second order for that package is refused with `409 duplicate_request` (the check is on the package, whoever the traveler is); - on **live**, an order for a package is refused with the same code when the **most recent confirmed order of your agency** is for that same package and is less than **60 seconds** old, and the response names it in `existing_booking_id`. The traveler does not matter: two eSIMs of the same package for two travelers must be ordered at least 60 seconds apart on live (the sandbox has no such wait). Orders usually answer in a few seconds, but a slow supplier can take much longer, and a connection closes after about 100 seconds. If a call times out or the connection drops, **do not send the order again at once**: the eSIM may exist. Look for it first with [`GET /esims/bookings?search=`](https://fx-port.com/docs/esims/bookings.md) (the list is newest first), and only order again when no booking was created. If the **supplier refuses** the order, the response is a `supplier_error` with the supplier's HTTP status, nothing is debited and a booking with the status `failed` is saved for support. ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `missing_field` | `package_id`, `email` or `phone` is missing | | `400` | `invalid_package_id`, `invalid_email`, `invalid_phone`, `invalid_name`, `invalid_description`, `invalid_quantity`, `invalid_agent_id` | A value has the wrong format or length | | `400` | `invalid_json`, `invalid_body` | The body is not valid JSON, or is not a JSON object | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `402` | `insufficient_balance` | Agency balance lower than the amount to debit (live). `details` has `available`, `required` and `currency` | | `403` | `read_write_permission_required` | The key has read permission only | | `403` | `agency_not_active` | The agency is not active for this environment (live) | | `404` | `not_found` | No package has this id, or it has no price. The message names the `package_id` | | `409` | `duplicate_request` | An order for this package is being processed, or (live) the latest confirmed order of your agency is for this package and is less than 60 seconds old | | `429` | `rate_limit_exceeded` | Over 1,200 requests per minute for this key | | `429` | `concurrency_limit_exceeded` | Too many orders of your key or agency are being processed at the same time. Nothing was ordered: retry after the seconds in `Retry-After` | | *supplier status* | `supplier_error` | The supplier refused the order. The status is the supplier's (for example `422` or `429`), nothing was debited, and the body has the `booking_id` of the `failed` booking | | `500` | `booking_record_failed`, `balance_debit_failed`, `supplier_response_invalid` | The supplier created the eSIM but FX-Port could not save the booking or take the debit, or the supplier's answer was unusable. **Do not retry**: contact support with the `order_code` or `booking_id` in the body | | `503` | `service_unavailable`, `balance_lock_timeout`, `duplicate_check_unavailable`, `service_busy` | The supplier connection, the balance lock, the duplicate check or the service is busy: nothing was ordered. Retry in a few seconds (`Retry-After` tells how long when it is set) | The `403` for a read key is returned before the body is read, so it appears even for an empty body. When a booking is saved for a failed order, its `booking_id` is in the error body: use it when you contact support. --- # Top up an eSIM > Check topup availability and top up an existing eSIM by ICCID. Creation requires a read+write API key. Source: https://fx-port.com/docs/esims/topups Topping up is a two-step flow: first ask which packages can top up this exact eSIM, then create the topup with one of the returned `package_id` values. Always follow this order: see [Supplier continuity](https://fx-port.com/docs/esims/supplier-continuity.md) for why. ## 1. Check available topup packages `GET /api/v1/esims/topup-packages/{iccid}` **Permission:** Read Lists the packages that can be added to one eSIM, with prices in your agency currency. The answer is scoped to the ICCID, so it reflects what the supplier offers for this eSIM today, including the rare case where the network operator of the country changed since the eSIM was issued. The ICCID must belong to an eSIM that **your agency ordered** in the same environment: 18 to 22 digits, which may be written in groups of spaces or hyphens (`8944 4654 0000 0123 456`; the digits are read). An ICCID that is unknown, or that belongs to another agency, is answered `404 iccid_not_found` before anything is asked from the supplier. A sandbox key only sees sandbox eSIMs. **cURL** ~~~bash curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/topup-packages/8900000540463077957?locale=en' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const iccid = "8900000540463077957"; const response = await fetch(`https://api.fx-port.com/api/v1/esims/topup-packages/${iccid}?locale=en`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const body = await response.json(); const packages = body.data.data; // the list is one level deeper than the other catalogs ~~~ **Python** ~~~python import requests iccid = "8900000540463077957" response = requests.get( f"https://api.fx-port.com/api/v1/esims/topup-packages/{iccid}", params={"locale": "en"}, headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) packages = response.json()["data"]["data"] ~~~ | Query parameter | Description | |---|---| | `locale` | Language of the text fields: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi` or `zh`. Any spelling of a locale is read and only its language counts (`fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9`: the first language is used). Another well-formed language is answered in English with a `coerced_value` warning; a value that is not a locale is refused with `invalid_locale`. Falls back to `Accept-Language`, then `en` | | `force` | `true` skips the cache and asks the supplier again. An empty value means not sent | **Caching.** The list is kept for **5 minutes** per eSIM and language. A cached answer carries `cached: true` and `cache_age_seconds`; a fresh one has neither. Use `force=true` only when you need the very latest list. ### Response example A sandbox answer (the `prices` block shortened): ~~~json { "environment": "sandbox", "success": true, "data": { "data": [ { "id": "france-in-7days-1gb-topup", "package_id": "france-in-7days-1gb-topup", "type": "topup", "price": 468.84, "amount": 1024, "day": 7, "is_unlimited": false, "title": "1 GB - 7 days", "data": "1 GB", "short_info": null, "voice": null, "text": null, "prices": { "recommended_retail_price": { "EUR": 3.56, "GBP": 3, "USD": 4 } }, "base_price": 402.5, "commission": 50.24, "total_price": 468.84, "retail_price": 468.84, "balance_debit_amount": 418.6, "b2b_price": 418.6, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD" } ] } } ~~~ An empty `data.data` array means this eSIM cannot be topped up now: place a [new eSIM order](https://fx-port.com/docs/esims/orders.md) for the traveler instead. ### Package fields | Field | Type | Meaning | |---|---|---| | `package_id` | string | The id to send as `package_id` when you create the topup. `id` carries the same value (legacy name) | | `type` | string | Always `topup` | | `title`, `data`, `amount`, `day` | | Label, data allowance as text, allowance in MB (`0` is unlimited) and validity in days | | `is_unlimited`, `voice`, `text`, `short_info` | | Unlimited flag, voice minutes and SMS (`null` for data only) and a short note (usually `null`) | | `price` | number | The **retail** price: the same amount as `total_price`. It is **not** the catalog's base price | | `base_price` | number | The base price before your commission: the amount the catalog calls `price` | | `commission` | number | Your commission as an **amount** | | `total_price` / `retail_price` | number | The price to charge the traveler. Both fields have the same value | | `balance_debit_amount` | number | The amount debited from your agency balance by the topup. `b2b_price` carries the same value | | `agency_commission` / `commission_rate` | string / number | Your commission rate as text (`"12 %"`) and as a number (`12`; `0` for contracted corporate agencies) | | `currency`, `supplier_currency` | string | Your agency currency (the currency of every amount) and the supplier's currency | | `prices.recommended_retail_price` | object | The supplier's recommended retail price by currency, for reference only | How the amounts relate is explained in [eSIM pricing](https://fx-port.com/docs/esims/pricing.md#what-price-means-in-each-response). ## 2. Create the topup `POST /api/v1/esims/topups` **Permission:** Read+Write Send one `package_id` from step 1 and the ICCID you asked about. Prices are computed on the server; a price in the body is ignored. **cURL** ~~~bash curl --request POST \ --url https://api.fx-port.com/api/v1/esims/topups \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --header 'Content-Type: application/json' \ --data '{ "package_id": "france-in-7days-1gb-topup", "iccid": "8900000540463077957", "first_name": "John", "last_name": "Smith", "email": "traveler@example.com", "phone": "+15555550123" }' ~~~ **Node.js** ~~~js const response = await fetch("https://api.fx-port.com/api/v1/esims/topups", { method: "POST", headers: { Authorization: "Bearer fxp_test_YOUR_KEY", "Content-Type": "application/json" }, body: JSON.stringify({ package_id: "france-in-7days-1gb-topup", iccid: "8900000540463077957", email: "traveler@example.com", phone: "+15555550123", }), }); const body = await response.json(); if (!response.ok) throw new Error(body.errors?.[0]?.code ?? `HTTP ${response.status}`); console.log(body.booking_id, body.data.balance); ~~~ **Python** ~~~python import requests response = requests.post( "https://api.fx-port.com/api/v1/esims/topups", headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, json={ "package_id": "france-in-7days-1gb-topup", "iccid": "8900000540463077957", "email": "traveler@example.com", "phone": "+15555550123", }, timeout=170, ) body = response.json() response.raise_for_status() ~~~ ### Body fields | Field | Type | Required | Description | |---|---|---|---| | `package_id` | string | **Yes** | A `package_id` returned by [topup-packages](#1-check-available-topup-packages) for **this ICCID**. 1 to 128 characters: letters, digits, `-`, `_`, `.` | | `iccid` | string | **Yes** | The eSIM to top up: 18 to 22 digits (groups of spaces or hyphens are read), an eSIM your agency ordered | | `email` | string | **Yes** | The traveler's email address, at most 254 characters. It is **kept on the booking only**: on a topup it is not sent to the supplier, so the supplier does not email anything to the traveler | | `phone` | string | **Yes** | The traveler's phone number: at least 3 digits, at most 32 characters. Kept on the booking | | `first_name` / `last_name` | string | No | Traveler name, at most 100 characters. **Any characters are accepted**, accents and non-Latin scripts included | | `agent_id` | string (UUID) | No | The user of your agency who places the topup. It must be a user of your agency (`invalid_agent_id` otherwise). Most integrations omit it | **Legacy names** (still work; when both spellings are sent, the new one wins): | Legacy | Use instead | Notes | |---|---|---| | `user_id` | `agent_id` | The response carries a `deprecated_field` warning | | `customer_email`, `customer_phone`, `customer_first_name`, `customer_last_name` | `email`, `phone`, `first_name`, `last_name` | The names the booking rows use. Accepted without a warning | Send `Content-Type: application/json` (the media type is read in any case). Keys the endpoint does not read, `quantity` and every price field included, are ignored and listed in the `warnings` of the response as `ignored_field`. ### Which package may I send? The topup is priced from the list that step 1 returned for this ICCID, so `package_id` must come from that list. A package that is not in it is refused with `404 not_found` ("Package ... not found or has no valid price"), and nothing is debited. Should you get that answer for a package you have just received (for example right after FX-Port was updated), ask for the list again with `force=true` and send the topup again. ## Response example A sandbox topup. The response has status **200**, not 201. ~~~json { "environment": "sandbox", "success": true, "data": { "topup": { "code": "20261101-106853", "order_id": "20261101-106853", "package_id": "france-in-7days-1gb-topup", "package": "KPN France Data only-1 GB - 7 days", "data": "1 GB", "validity": 7, "price": 468.84, "currency": "DZD", "total_price": 468.84, "retail_price": 468.84, "agency_currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250.0 }, "iccid": "8900000540463077957", "balance": { "previous": 0.0, "current": 0.0, "currency": "DZD" } }, "booking_id": "FX-1793546625391", "message": "eSIM topup completed successfully", "balanceDebited": false, "transactionCreated": false, "payoutCreated": false, "balance_debited": false, "transaction_created": false, "payout_created": false } ~~~ | Field | Meaning | |---|---| | `data.topup.code` | The supplier's order code of the topup. `order_id` carries the same value | | `data.topup.price` / `total_price` / `retail_price` | What the traveler pays: the retail price, in your agency currency | | `data.topup.package_id`, `package`, `data`, `validity` | The package that was bought: id, name, allowance and validity in days | | `data.iccid` | The eSIM that was topped up | | `data.balance` | Your agency balance in this currency before (`previous`) and after (`current`) the debit | | `booking_id` | The booking of the topup (`FX-` followed by a number). It is listed by [topup history](https://fx-port.com/docs/esims/bookings.md#topup-history-for-an-iccid) and by the [account history](https://fx-port.com/docs/account/history.md), not by the eSIM bookings list | | `balanceDebited`, `transactionCreated`, `payoutCreated` | **Sandbox only**, always `false`. Twins: `balance_debited`, `transaction_created`, `payout_created` | ### In the sandbox A sandbox topup is accepted by the supplier's test system and **debits nothing**: `balance` is `0.0` before and after, and the three flags are `false`. The usage of a sandbox eSIM does not change after a topup. The balance check and the duplicate check run on live only. ## Does a topup extend or replace the current plan? A topup **adds** the package's data allowance to the eSIM: it does not replace or shorten what the traveler already has. How the validity changes depends on the package, so FX-Port does not predict it. Read the result from [eSIM usage](https://fx-port.com/docs/esims/usage.md) after the topup (`remaining`, `total` and `expired_at`), with `force=true`, because usage is cached for 15 minutes. ## Duplicates and retries There is no idempotency key. A second topup is refused with `409 duplicate_request` while an identical one (same eSIM and package) is being processed, and on **live** also when a topup for the same eSIM was created in the last 60 seconds, whatever the package (`existing_booking_id` names it). If a call times out, look at [topup history](https://fx-port.com/docs/esims/bookings.md#topup-history-for-an-iccid) before you send it again. ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `missing_field` | `package_id`, `iccid`, `email` or `phone` is missing | | `400` | `invalid_package_id`, `invalid_iccid`, `invalid_email`, `invalid_phone`, `invalid_name`, `invalid_agent_id` | A value has the wrong format or length | | `400` | `invalid_json`, `invalid_body` | The body is not valid JSON, or is not a JSON object | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `402` | `insufficient_balance` | Agency balance lower than the amount to debit (live). `details` has `available`, `required` and `currency` | | `403` | `read_write_permission_required` | The key has read permission only | | `403` | `agency_not_active` | The agency is not active for this environment (live) | | `404` | `iccid_not_found` | The ICCID is not an eSIM of your agency in this environment | | `404` | `not_found` | The package is not a topup package of this eSIM (see [Which package may I send?](#which-package-may-i-send)) | | `409` | `duplicate_request` | An identical topup is being processed, or a topup of this eSIM was just confirmed (live) | | `429` | `rate_limit_exceeded` | Over 1,200 requests per minute for this key | | `429` | `concurrency_limit_exceeded` | Too many topups of your key or agency are being processed at the same time. Nothing was topped up: retry after the seconds in `Retry-After` | | *supplier status* | `supplier_error` | The supplier refused the topup, for example because the operator no longer offers the package. The status is the supplier's, nothing was debited, and the body has the `booking_id` of the `failed` booking | | `500` | `booking_record_failed`, `balance_debit_failed`, `supplier_response_invalid` | The supplier accepted the topup but FX-Port could not save it or take the debit, or the supplier's answer was unusable. **Do not retry**: contact support with the `booking_id` in the body | | `503` | `service_unavailable`, `balance_lock_timeout`, `duplicate_check_unavailable`, `service_busy` | The supplier connection, the balance lock, the duplicate check or the service is busy: nothing was topped up. Retry in a few seconds | Step 1 answers `400 invalid_iccid` or `invalid_locale`, `404 iccid_not_found`, and `503 service_unavailable` when the supplier cannot be reached. When the supplier refuses the lookup, the answer has the supplier's status (for example `429`). > [!WARNING] > If the supplier rejects the topup for any reason, including an operator change in the destination country, > **your agency balance is never debited**. The attempt is recorded as a `failed` booking for support and the > response is a `supplier_error` so you can react immediately. --- # eSIM usage > Retrieve the remaining data, voice and SMS balance of one eSIM or of up to 50 eSIMs. Source: https://fx-port.com/docs/esims/usage > [!WARNING] > **Usage is cached and is not real time.** FX-Port keeps the last usage of an eSIM for up to **15 minutes** > to protect the supplier's rate limits. Pass `force=true` to ask the supplier again when you need the freshest number > (for example right after a topup), but do not do it on every request: the supplier limits how often usage can be > read and rejects clients that poll too often. ## Single eSIM usage `GET /api/v1/esims/usage/{iccid}` **Permission:** Read The ICCID is 18 to 22 digits (it may be written in groups of spaces or hyphens, `8944 4654 0000 0123 456`: the digits are read) and must belong to an eSIM **your agency ordered** in this environment. An ICCID that is unknown, or that belongs to another agency, is answered `404 iccid_not_found`. **cURL** ~~~bash curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/usage/8900000540463077957' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const iccid = "8900000540463077957"; const response = await fetch(`https://api.fx-port.com/api/v1/esims/usage/${iccid}`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const body = await response.json(); const { remaining, total, expired_at, status } = body.data.data; // one level deeper than most answers ~~~ **Python** ~~~python import requests iccid = "8900000540463077957" response = requests.get( f"https://api.fx-port.com/api/v1/esims/usage/{iccid}", headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=60, ) usage = response.json()["data"]["data"] ~~~ | Query parameter | Description | |---|---| | `force` | `true` skips the 15-minute cache and asks the supplier again. An empty value means not sent | ### Response example ~~~json { "environment": "sandbox", "success": true, "data": { "data": { "remaining": 1024, "total": 1024, "expired_at": "2026-11-08 15:23:29", "is_unlimited": false, "status": "ACTIVE", "remaining_voice": 0, "remaining_text": 0, "total_voice": 0, "total_text": 0 }, "meta": { "message": "success" } } } ~~~ An answer that comes from the cache adds two keys at the top level: `"cached": true` and `"cache_age_seconds": 412` (the age of the answer in seconds). A fresh answer has neither. ### Fields of `data.data` | Field | Meaning | |---|---| | `remaining`, `total` | Data left and data allowance, in **megabytes** | | `expired_at` | When the eSIM expires: `YYYY-MM-DD HH:MM:SS` in UTC, **without a zone** | | `is_unlimited` | `true` for an unlimited eSIM | | `status` | The state, as the supplier reports it: `ACTIVE` (in use), `NOT_ACTIVE` (not installed or not used yet), `FINISHED` (the allowance is used up) or `EXPIRED` (the validity ended) | | `remaining_voice`, `remaining_text`, `total_voice`, `total_text` | Voice minutes and SMS left and included. `0` for data-only packages | ### When the supplier cannot answer If the supplier is rate limiting or unreachable and FX-Port has a previous answer for this eSIM, you get **that answer, marked as stale**, with status `200`: ~~~json { "success": true, "data": { "data": { "remaining": 1024, "total": 1024, "...": "..." }, "meta": { "message": "success" } }, "cached": true, "stale": true, "stale_reason": "Rate limited", "retry_after": 60 } ~~~ `stale_reason` says why in a few words and `retry_after` (seconds, present when the supplier was rate limiting) says when to ask again. Without a previous answer the call fails instead: `429 rate_limit_exceeded` with `retry_after`, `503 service_unavailable` when the supplier connection is down, or the supplier's own status with the message "Failed to fetch usage". In the **sandbox**, a new eSIM shows its full allowance and the status `ACTIVE`, and the usage does not change after a [topup](https://fx-port.com/docs/esims/topups.md). ## Bulk usage `GET /api/v1/esims/usage/bulk` **Permission:** Read Returns the usage of several eSIMs in one call, for a list or a dashboard. **Always send `iccids`** with the ICCIDs you show: the supplier is asked one eSIM after another, so a long list that is not cached takes seconds. **cURL** ~~~bash curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/usage/bulk?iccids=8900000540463077957,8900000540463077965' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const iccids = ["8900000540463077957", "8900000540463077965"]; const response = await fetch(`https://api.fx-port.com/api/v1/esims/usage/bulk?iccids=${iccids.join(",")}`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: entries } = await response.json(); for (const entry of entries) console.log(entry.iccid, entry.status, entry.data?.data.remaining); ~~~ | Query parameter | Description | |---|---| | `iccids` | ICCIDs separated by commas (semicolons and line breaks work too): **at most 50** for an API key (`400 too_many_iccids` above that), each 18 to 22 digits (`400 invalid_iccid` otherwise). The parameter may also be repeated (`iccids=a&iccids=b`) or written `iccids[]=a&iccids[]=b`, and an ICCID may be written in groups of spaces or hyphens. Without it, the **50 newest eSIM orders** of your agency in this environment are read | | `force` | `true` skips the cache for every eSIM. An empty value means not sent | - **One entry per ICCID**, in the order you asked. An eSIM that was topped up still appears once. - An ICCID that is not an eSIM of your agency is **left out** of the answer, without an error. - The same 15-minute cache and stale answers as the single call apply per eSIM. ### Response example ~~~json { "environment": "sandbox", "success": true, "data": [ { "iccid": "8900000540463077957", "data": { "data": { "remaining": 1024, "total": 1024, "expired_at": "2026-11-08 15:23:29", "is_unlimited": false, "status": "ACTIVE", "remaining_voice": 0, "remaining_text": 0, "total_voice": 0, "total_text": 0 }, "meta": { "message": "success" } }, "status": "success" } ] } ~~~ Each entry has its own `status`. Read it per entry: the call answers `200` even when some entries failed. | Entry `status` | Meaning | `data` | |---|---|---| | `success` | Fresh or cached usage | The usage, one level deeper than the single call: `data.data` | | `stale` | The supplier could not answer: the last known usage | The last known usage | | `rate_limited` | The supplier is rate limiting and there is no previous answer. `retry_after` says when to ask again | `null` | | `error` | The usage could not be read. `error` is `usage_unavailable` after an unexpected failure | `null` | | `PENDING` | The order has no eSIM yet: its ICCID starts with `PENDING-` | `null` | Retry `rate_limited` entries later instead of calling bulk usage again at once. ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `invalid_iccid` | An ICCID is not 18 to 22 digits | | `400` | `too_many_iccids` | More than 50 ICCIDs in `iccids` (bulk) | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `404` | `iccid_not_found` | The ICCID is not an eSIM of your agency in this environment (single usage) | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key, or the supplier is rate limiting and there is no previous answer (`retry_after`) | | `503` | `service_unavailable`, `service_busy` | The supplier connection or the service is busy and there is no previous answer. Retry in a few seconds | --- # eSIM bookings > List the eSIM orders of your agency, read the topups of an eSIM and check that a booking is yours. Source: https://fx-port.com/docs/esims/bookings Three read-only endpoints cover the eSIM orders of your agency: the list of orders, the topups of one eSIM, and a check that a booking reference is yours. Orders and topups are stored as bookings of the same shape; the list shows **orders only**, and the topups of an eSIM are read with the topup history. ## List bookings `GET /api/v1/esims/bookings` **Permission:** Read A paginated list of the **eSIM orders** of your agency in this environment, newest first. A sandbox key lists sandbox orders and a live key lists live orders. **Topups are not in this list**: read them with the [topup history](#topup-history-for-an-iccid) of the eSIM, or in the [account history](https://fx-port.com/docs/account/history.md), which lists orders and topups together. A failed attempt is listed too, with the status `failed`. **cURL** ~~~bash curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/bookings?page=1&limit=50&search=traveler@example.com' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const params = new URLSearchParams({ page: "1", limit: "50", search: "traveler@example.com" }); const response = await fetch(`https://api.fx-port.com/api/v1/esims/bookings?${params}`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: bookings, pagination } = await response.json(); ~~~ **Python** ~~~python import requests response = requests.get( "https://api.fx-port.com/api/v1/esims/bookings", params={"page": 1, "limit": 50, "search": "traveler@example.com"}, headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) body = response.json() ~~~ | Query parameter | Description | |---|---| | `search` | Part of a booking id, the traveler's first or last name, the email, the package name or the ICCID. It does not match the supplier order code or the phone number. At most 100 characters | | `page` | The page, starting at 1 (at most 10,000) | | `limit` | Bookings per page, 1 to **200** (default 50). A larger value is refused with `422 validation_error` | | `all` | `true` returns the largest pages: 200 bookings, see below | An empty `page`, `limit` or `all` (`?page=`) means not sent and takes the default. **`search` is safe with any text.** Characters other than letters, digits and `@ . - _ + ' : / & •` are treated as spaces, so brackets, commas, quotes or wildcards cannot break the search. A term longer than 100 characters, or one with control characters, is refused with `400 invalid_search`; a term with nothing searchable in it answers an empty page. `pagination.total` counts the orders that match, `search` included. **`all=true`** does not return every booking in one answer: an API key gets pages of at most 200 bookings (`limit` defaults to 200). The response carries a `coerced_value` warning. Follow `pagination.has_more` and `page` to read the rest. ### Response example ~~~json { "environment": "sandbox", "success": true, "data": [ { "id": "639417b4-b103-4ece-bd5d-850315322278", "booking_id": "FX-1793546602916", "order_id": "20261101-106852", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "esim", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "environment": "sandbox" } ], "pagination": { "total": 61, "page": 1, "limit": 1, "pages": 61, "total_pages": 61, "has_more": true } } ~~~ ### Booking fields | Field | Meaning | |---|---| | `id` | Internal id of the booking row (UUID): the `id` the account history returns | | `booking_id` | The FX-Port reference, `FX-` followed by a number. Use it in [setup instructions](https://fx-port.com/docs/esims/instructions.md#setup-instructions-by-booking) and in the [booking detail](https://fx-port.com/docs/account/booking-detail.md) | | `order_id` | The supplier's order code (`code` in the order response) | | `status` | `confirmed`, or `failed` when the supplier refused the order (the ICCID of such a row starts with `PENDING-`) | | `type` | `esim` for an order, `topup` for a topup | | `currency`, `total_price` | Your agency currency and the price charged to the traveler | | `agency_commission` | Your commission as an **amount**. The amount debited from your balance is `total_price` minus this amount (see [eSIM pricing](https://fx-port.com/docs/esims/pricing.md)) | | `package_name` | Package name, with the destination for an order | | `iccid` | The ICCID of the eSIM. A topup row carries the ICCID that was topped up | | `customer_email`, `customer_first_name`, `customer_last_name`, `customer_phone` | The traveler details you sent when ordering | | `provider_name`, `supplier_id` | The supplier that fulfilled the booking. Here `airalo`: the [supplier list](https://fx-port.com/docs/suppliers.md) calls the same supplier `airalo_1` | | `created_at`, `updated_at` | ISO 8601 with a zone (UTC) | | `environment` | `sandbox` or `live` | Only agency-facing fields are returned: the supplier's cost and FX-Port's internal margins are never part of a booking. ### Pagination | Field | Meaning | |---|---| | `total` | Number of orders that match (`search` included) | | `page`, `limit` | The page returned and the page size used | | `pages` | Number of pages, `0` when there is no order. `total_pages` carries the same value | | `has_more` | `true` when another page follows | ## Topup history for an ICCID `GET /api/v1/esims/bookings/{iccid}/topup-history` **Permission:** Read The topups of one eSIM, newest first, with the same booking shape as above and `type: "topup"`. There is no pagination. The list is **empty** (status `200`) when the eSIM has no topup or is not an eSIM of your agency. ~~~bash curl --request GET \ --url https://api.fx-port.com/api/v1/esims/bookings/8900000540463077957/topup-history \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ ~~~json { "environment": "sandbox", "success": true, "data": [ { "id": "bd6ff7d7-392f-4d56-8dea-4f0f3dff0af9", "booking_id": "FX-1793546625391", "order_id": "20261101-106853", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "topup", "package_name": "1 GB - 7 days", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:45.424526+00:00", "updated_at": "2026-11-01T15:23:45.424526+00:00", "environment": "sandbox" } ] } ~~~ The ICCID is 18 to 22 digits (groups of spaces or hyphens are read as the digits), or the `PENDING-...` placeholder of an order whose eSIM the supplier has not returned yet (letters, digits and `-`, at most 64 characters); anything else is refused with `400 invalid_iccid`. ## Check that a booking is yours `GET /api/v1/esims/bookings/{booking_id}/verify` **Permission:** Read Tells whether an eSIM booking belongs to your agency, before you build a link or a screen around a `booking_id`. The reference is the `booking_id` of an eSIM order or topup (`FX-` followed by a number, not the internal UUID), at most 100 characters of letters, digits and `._:=+~-` (`400 invalid_booking_id` otherwise). ~~~bash curl --request GET \ --url https://api.fx-port.com/api/v1/esims/bookings/FX-1793546602916/verify \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ ~~~json { "environment": "sandbox", "isOwner": true, "is_owner": true, "userAgencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "bookingAgencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d" } ~~~ | Field | Meaning | |---|---| | `isOwner` | `true` when the booking is an eSIM booking of your agency in this environment. `is_owner` carries the same value | | `userAgencyId`, `bookingAgencyId` | Present only when `isOwner` is `true`; both are your own agency id | A booking of **another agency** and a booking that **does not exist** are answered the same way, `{"isOwner": false}` with status `200`, so the call cannot be used to discover which references exist. This answer has no `success` key. ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `invalid_search` | `search` is longer than 100 characters or has control characters | | `400` | `invalid_iccid` | The ICCID of the topup history is not valid | | `400` | `invalid_booking_id` | The booking reference of `verify` is not valid | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `422` | `validation_error` | `page` or `limit` is not an integer in its range (for example `limit=201`) | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | --- # Setup and installation instructions > Retrieve the QR code, the APN and the installation instructions for a booking or an ICCID. Source: https://fx-port.com/docs/esims/instructions Two endpoints return what a traveler needs to install an eSIM: the **setup data of an order** (the QR code, the activation data and the installation steps, with the prices of the booking) and the **installation steps of an ICCID** in the language of your choice. Both contain the activation data of the eSIM: show it only to the traveler who owns the eSIM. ## Setup instructions by booking `GET /api/v1/esims/bookings/{booking_id}/setup-instructions` **Permission:** Read Returns the original order of an eSIM booking as JSON: the QR code and the activation data, the APN settings, the steps to install by QR code or by hand (as HTML), and the agency-facing prices. Use it to build your own confirmation or setup page. `booking_id` is the reference of an **order**: the `booking_id` of the order response or of the [bookings list](https://fx-port.com/docs/esims/bookings.md) (`FX-` followed by a number). - A **topup** booking has no setup data: `404` with the message "Setup data not available for this booking". - A booking that does not exist and a booking of **another agency** are answered the same way: `404 booking_not_found`. - A reference that is not valid (more than 100 characters, or characters other than letters, digits and `._:=+~-`) is refused with `400 invalid_booking_id`. **cURL** ~~~bash curl --request GET \ --url https://api.fx-port.com/api/v1/esims/bookings/FX-1793546602916/setup-instructions \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const response = await fetch("https://api.fx-port.com/api/v1/esims/bookings/FX-1793546602916/setup-instructions", { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: order } = await response.json(); const sim = order.sims[0]; // sim.qrcode_url, sim.sharing.link, sim.direct_apple_installation_url ... ~~~ ### Response example A sandbox order (the HTML and the QR link shortened with `...`): ~~~json { "environment": "sandbox", "success": true, "data": { "code": "20261101-106852", "order_id": "20261101-106852", "currency": "DZD", "package_id": "france-in-7days-1gb", "quantity": 1, "type": "sim", "description": "Order for John Smith", "esim_type": "local", "validity": 7, "package": "KPN France Data only-1 GB - 7 days", "data": "1 GB", "price": 468.84, "created_at": "2026-11-01 15:23:22", "manual_installation": "

eSIM name: ...

  1. Settings > Cellular/Mobile > Add Cellular/Mobile Plan.
  2. ...
", "qrcode_installation": "

eSIM name: ...

...", "installation_guides": { "en": "https://www.airalo.com/help/getting-started-with-airalo" }, "text": null, "voice": null, "sims": [ { "created_at": "2026-11-01 15:23:22", "iccid": "8900000540463077957", "lpa": "lpa.airalo.com", "imsis": null, "qrcode": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qrcode_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=...", "apn_type": "automatic", "apn_value": "N/A", "is_roaming": true, "confirmation_code": null, "apn": { "ios": { "apn_type": "automatic", "apn_value": "N/A" }, "android": { "apn_type": "automatic", "apn_value": "N/A" } }, "msisdn": null, "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "direct_android_installation_url": "https://esimsetup.android.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "sharing": { "access_code": 8803, "link": "https://esims.cloud/v8hy7-usgkq6wg" } } ], "total_price": 468.84, "retail_price": 468.84, "agency_currency": "DZD" } } ~~~ | Field | Meaning | |---|---| | `code` | The supplier's order code. `order_id` carries the same value | | `price`, `total_price`, `retail_price` | What the traveler pays, in your agency currency (`currency` and `agency_currency`). The three fields have the same value. `price` here is the retail price, see [eSIM pricing](https://fx-port.com/docs/esims/pricing.md) | | `package_id`, `package`, `data`, `validity`, `esim_type` | The package that was ordered: id, name, allowance, validity in days and kind | | `quantity` | Always `1` | | `manual_installation`, `qrcode_installation` | HTML with the steps to install by entering the activation data, and by scanning the QR code. Show it as HTML, or strip the tags | | `installation_guides` | Links to the supplier's general guide, by language | | `sims[]` | The eSIM of the order: `iccid`, the activation data (`lpa`, `qrcode`, `qrcode_url`), the install links (`direct_apple_installation_url`, `direct_android_installation_url`), the APN settings (`apn_type`, `apn_value`, `apn` per platform) and `sharing`, a page where the traveler can open the eSIM (`link` and `access_code`). Keys without a value are `null` | | `created_at` | When the supplier created the order: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone | ## Installation instructions by ICCID `GET /api/v1/esims/instructions/{iccid}` **Permission:** Read Structured, step-by-step installation instructions for one ICCID, per operating system, in the language of the `locale` parameter. The ICCID is 18 to 22 digits (it may be written in groups of spaces or hyphens, `8944 4654 0000 0123 456`) and must belong to an eSIM **your agency ordered** in this environment; any other ICCID is answered `404 iccid_not_found`. ~~~bash curl --request GET \ --url 'https://api.fx-port.com/api/v1/esims/instructions/8900000540463077957?locale=en' \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ | Query parameter | Description | |---|---| | `locale` | Language of the steps: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi` or `zh`. Any spelling of a locale is read and only its language counts (`fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9`: the first language is used). Another well-formed language is answered in English with a `coerced_value` warning; a value that is not a locale is refused with `400 invalid_locale`. Falls back to `Accept-Language`, then `en` | ### Response example Steps shortened to the first ones: ~~~json { "environment": "sandbox", "success": true, "data": { "data": { "instructions": { "language": "EN", "ios": [ { "model": null, "version": "15.0,14.0,13.0,12.0", "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "installation_via_qr_code": { "steps": { "1": "Go to “Settings”, tap “Cellular/Mobile Data”, then tap “Add Cellular/Data Plan” on your device.", "2": "Scan the QR code available on the app, then tap “Add Cellular/Data Plan”.", "3": "Choose a label for your new eSIM plan." }, "qr_code_data": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qr_code_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=..." }, "installation_manual": { "steps": { "1": "Go to “Settings”, tap “Cellular/Mobile Data”, ...", "2": "Tap “Enter Details Manually” ..." }, "smdp_address_and_activation_code": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "smdp_address": "lpa.airalo.com", "activation_code": "DUMMY-2610301358-iKFxr-44741" }, "network_setup": { "steps": { "1": "Go to “Cellular/Mobile Data”, then select the recently downloaded eSIM ...", "2": "..." }, "apn_type": "automatic", "apn_value": "N/A", "is_roaming": true } } ], "android": [ { "...": "same shape, with direct_android_installation_url and the steps of Android phones" } ] } }, "meta": { "message": "success" } } } ~~~ The steps are in `data.data.instructions`, one entry per family of devices in `ios` and `android`. `model` and `version` say which devices an entry is written for (`null` means all). `steps` is an object whose keys are the step numbers `"1"`, `"2"`, ... > [!NOTE] > The steps are generic text of the supplier: some say that the activation details are "available on the app". > Show the QR code and the activation data of the order (`sims[]` above, or the order response) next to the steps in > your own product. ## Sending instructions directly to the traveler You do not have to relay the QR code yourself: the `email` of an [eSIM order](https://fx-port.com/docs/esims/orders.md) is passed to the supplier, who emails the QR code and a sharing link to that address as soon as the eSIM is created. This is the recommended flow whenever you have the traveler's own email. Use the endpoints on this page when you need to render the QR code or the instructions inside your own product. On a [topup](https://fx-port.com/docs/esims/topups.md) the email is only kept on the booking: nothing is emailed. ## Deprecated: the setup link `GET /api/v1/esims/bookings/{booking_id}/setup-url` is **deprecated**: use [setup instructions](#setup-instructions-by-booking) instead. It still answers, with the install links of the order only (no supplier data), and the response carries a `deprecated_endpoint` warning: ~~~json { "success": true, "fullUrl": "https://esims.cloud/v8hy7-usgkq6wg", "setup_url": "https://esims.cloud/v8hy7-usgkq6wg", "links": { "sharing_link": "https://esims.cloud/v8hy7-usgkq6wg", "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$...", "direct_android_installation_url": "https://esimsetup.android.com/esim_qrcode_provisioning?carddata=LPA:1$...", "qrcode_url": "https://sandbox.airalo.com/qr?..." } } ~~~ `fullUrl` and `setup_url` are the first link that exists (the sharing link, else the Apple or Android link, else the QR image). `links` lists the links the order has. An order without any link is answered `404 setup_link_not_available`. ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `invalid_booking_id` | The booking reference is not valid | | `400` | `invalid_iccid`, `invalid_locale` | The ICCID or the locale is not valid | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `404` | `booking_not_found` | No eSIM booking of your agency has this reference | | `404` | `not_found` | The booking is a topup, which has no setup data | | `404` | `iccid_not_found` | The ICCID is not an eSIM of your agency in this environment | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | | `503` | `service_unavailable`, `service_busy` | The supplier connection or the service is busy (instructions by ICCID). Retry in a few seconds | When the supplier cannot give the instructions, the answer carries the supplier's own status. --- # Suppliers > Discover every enabled supplier across all products, with capabilities and void deadlines. Endpoint: `GET /api/v1/suppliers` Source: https://fx-port.com/docs/suppliers `GET /api/v1/suppliers` **Permission:** Read. **Rate limit:** the default group, 6,000 requests per minute per key. Returns the suppliers that are **enabled** for your key, grouped by product: flights, hotels and eSIMs. This endpoint spans every product, not only flights. A product with no enabled supplier is left out, and so are disabled suppliers. > [!NOTE] > **Read the list, do not hard-code it.** The suppliers FX-Port connects to change over time, and so do their ids and > descriptions. Your integration should read this list (and cache it for a while: see [Rate limits](https://fx-port.com/docs/rate-limits.md)) > and take the `id` it needs from it. ## Request **cURL** ~~~bash curl --request GET \ --url https://api.fx-port.com/api/v1/suppliers \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const response = await fetch("https://api.fx-port.com/api/v1/suppliers", { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { products } = await response.json(); const flights = products.find((entry) => entry.product === "flights")?.suppliers ?? []; const canHold = flights.filter((supplier) => supplier.capabilities?.hold); ~~~ **Python** ~~~python import requests response = requests.get( "https://api.fx-port.com/api/v1/suppliers", headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) products = response.json()["products"] ~~~ ## Response example A sandbox answer. `description` is free text written by FX-Port: it is shortened to `...` here and can change. ~~~json { "environment": "sandbox", "success": true, "products": [ { "product": "flights", "suppliers": [ { "id": "amadeus_aqc_dz_1", "name": "Amadeus Algeria", "technology": "amadeus_aqc", "routes": ["DZ"], "description": "...", "capabilities": { "hold": true, "void": true, "flexible_dates": true, "ancillaries": false }, "voidDeadline": "2026-11-01T23:00:00Z", "void_deadline": "2026-11-01T23:00:00Z" }, { "id": "duffel_1", "name": "Duffel", "technology": "duffel", "routes": ["*"], "description": "...", "capabilities": { "hold": false, "void": false, "flexible_dates": false, "ancillaries": true }, "voidDeadline": "2026-11-02T00:00:00Z", "void_deadline": "2026-11-02T00:00:00Z" } ] }, { "product": "hotels", "suppliers": [ { "id": "worldota_1", "name": "RateHawk", "technology": "worldota", "description": "..." } ] }, { "product": "esims", "suppliers": [ { "id": "airalo_1", "name": "Airalo", "technology": "airalo", "description": "..." } ] } ], "meta": { "environment": "sandbox", "permissionRequired": "read", "permission_required": "read" } } ~~~ ## What the list contains | Product | Supplier `id` | Name | `technology` | |---|---|---|---| | Flights | `amadeus_aqc_dz_1` | Amadeus Algeria | `amadeus_aqc` | | Flights | `duffel_1` | Duffel | `duffel` | | Hotels | `worldota_1` | RateHawk | `worldota` | | eSIMs | `airalo_1` | Airalo | `airalo` | These are the suppliers of the sandbox today. The eSIM block is listed when the eSIM supplier is configured. FX-Port can enable more suppliers (and disable some): the list tells what your key can use right now. ## Field reference ### Every supplier | Field | Type | Description | |---|---|---| | `id` | string | The supplier identifier. Send it as `supplier_id` where an endpoint takes one (for example in a flight search) | | `name` | string | The name of the supplier | | `technology` | string | How FX-Port connects to the supplier: `amadeus_aqc` (an Amadeus office with negotiated fares), `duffel`, `worldota` (hotels) or `airalo` (eSIMs) today. New values appear when connections are added: do not hard-code them | | `description` | string | What the supplier is good for. Free text: do not parse it | ### Flight suppliers only | Field | Type | Description | |---|---|---| | `routes` | string[] | The markets the supplier serves. A country code (for example `"DZ"`) means routes from and to that country; `"*"` means every market | | `capabilities.hold` | boolean | The supplier can hold a booking to pay later | | `capabilities.void` | boolean | The supplier can void an issued ticket on the day of issue | | `capabilities.flexible_dates` | boolean | The supplier supports the flexible-dates search | | `capabilities.ancillaries` | boolean | The supplier sells extras such as seats and bags | | `voidDeadline` | string | The next void cut-off, in UTC: the next midnight in the supplier's time zone (see below). `void_deadline` carries the same value | Hotel and eSIM suppliers have only `id`, `name`, `technology` and `description`. ### `meta` | Field | Description | |---|---| | `environment` | The environment of your key: `sandbox` or `live` | | `permissionRequired` | `read`: the permission the endpoint needs. `permission_required` carries the same value | ## Sandbox and live The endpoint, the shape and the way to read it are the same in both environments, and the list is **not filtered by the environment of your key**: `meta.environment` only tells which environment answered. What differs is what a supplier's own test system can do: see [Sandbox](https://fx-port.com/docs/sandbox.md). ## Supplier ids in your bookings The id in this list is the one to send to the API. The bookings of your account carry the id stored on the booking (`supplierId` in the [booking history](https://fx-port.com/docs/account/history.md)), and that id is not always the same one: | Supplier | Id in this list | `supplierId` on bookings | |---|---|---| | Amadeus Algeria | `amadeus_aqc_dz_1` | `amadeus_aqc_dz_1` | | Duffel | `duffel_1` | `duffel_1` | | RateHawk | `worldota_1` | `ratehawk` | | Airalo | `airalo_1` | `airalo` | Do not match bookings with this list on the id alone: use `providerName` or keep your own mapping. ## The `voidDeadline` field > [!WARNING] > `voidDeadline` is recalculated on every request: **do not keep it across days**. It is the next midnight in the > supplier's operating time zone, expressed in UTC. It is present on the flight suppliers whatever `capabilities.void` > says (a supplier with `void: false` still carries one), so read `capabilities.void` first. Void eligibility also varies > by airline: check it immediately before you attempt a void. ## Errors | HTTP | `code` | Cause | |---|---|---| | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | --- # Account API overview > Agency balances, the financial ledger and cross-product booking history. Source: https://fx-port.com/docs/account Read-only endpoints for the state of your account: the balance of each currency, the money movements of a balance, and the bookings of your agency across **all products** (flights, hotels, eSIMs and transfers). **Permission:** Read (every endpoint in this section). **Rate limit:** the default group, 6,000 requests per minute per key (see [Rate limits](https://fx-port.com/docs/rate-limits.md)). > [!NOTE] > These endpoints expose account-level data for reconciliation, dashboards and integration monitoring. They are > **not** the booking operations API. For full traveler details, fare rules and booking actions, use the > product-specific endpoints (for example `POST /api/v1/flights/get_booking`). ## Endpoints | Method | Path | Description | |---|---|---| | `GET` | [`/api/v1/balances`](https://fx-port.com/docs/account/balances.md) | The balance of each currency your agency holds | | `GET` | [`/api/v1/balances/{balance_id}/history`](https://fx-port.com/docs/account/balance-history.md) | The money movements of one balance | | `GET` | [`/api/v1/history`](https://fx-port.com/docs/account/history.md) | Booking summaries across all products | | `GET` | [`/api/v1/bookings/{booking_id}`](https://fx-port.com/docs/account/booking-detail.md) | One booking in detail, by its reference | ## Conventions of the account endpoints - **The agency comes from the key.** Never send `agency_id`: an API key that does is refused with `400 agency_id_not_allowed`. - **Environment.** A sandbox key reads sandbox data and a live key reads live data. Every body says which in `environment`. - **Pagination.** `page` starts at 1 (at most 10,000) and `limit` is 1 to **100** (default 20); a larger `limit` is refused with `422 validation_error`, and an empty `page` or `limit` (`?page=`) means not sent. The answer has `pagination: { page, limit, total, total_pages, hasMore, has_more }`. `total` is the real number of entries that match and `total_pages` is `total` divided by `limit`, rounded up. **Follow `has_more`, not `total_pages`**: each page is served from the newest entries only (200 per product for the history, 500 per source for a balance history), so older entries are reached by narrowing the list with `date_from` and `date_to`. `hasMore` and `has_more` carry the same value. - **Dates.** `date_from` and `date_to` take an ISO 8601 date or datetime in any common spelling: `2026-10-01` or `20261001`, a fraction of 1 to 9 digits, an offset written `Z`, `+01:00` or `+0100` (in a URL write the `+` as `%2B`). A bare date means 00:00:00 UTC for `date_from` and the end of the day (23:59:59.999999 UTC) for `date_to`; a datetime without an offset is read as UTC. Years 1900 to 2200. Something that is not an ISO 8601 date (`01/10/2026`, an epoch number, `yesterday`) is `400 invalid_date`, and a `date_from` after `date_to` is `400 invalid_date_range`. Both bounds are inclusive. - **Product and status.** `product` takes any case and the singular names (`flight`, `hotel`, `esim`, `transfer`); `status` takes any case and reads spaces and hyphens as underscores (`On Hold` is `on_hold`). - **Legacy names.** `from` and `from_date` are the previous names of `date_from`, and `to` and `to_date` of `date_to`. They still work and the response carries a `deprecated_field` warning; when both spellings are sent, `date_from` and `date_to` win. - **Names in the answers.** The booking summaries use camelCase keys for historical reasons (`bookingId`, `totalPrice` ...). Every key that matters has a **snake_case twin** with the same value (`booking_id`, `total_price` ...), listed in the tables of each page, and `?naming=snake` returns every key in snake_case. The `record` of a booking and the balance rows are snake_case already. See [Conventions and naming](https://fx-port.com/docs/conventions.md). - **Timestamps** are ISO 8601 with a zone, in UTC. - **Amounts** are in your agency currency, rounded to two decimals. ## Sandbox behaviour | Endpoint | In the sandbox | |---|---| | `GET /balances` | An envelope with an **empty** `data` and a `meta` that says why: `financialOperationsEnabled: false` and a message. It is not a bare array | | `GET /balances/{balance_id}/history` | Always empty (`pagination.total` is 0), for any `balance_id` of up to 64 characters | | `GET /history` | Sandbox bookings only | | `GET /bookings/{booking_id}` | Sandbox bookings only. The balance operations of a flight or hotel are empty | Sandbox bookings never touch the live ledger. ## Error responses Every error has a stable `code` in `errors[0].code`. The older `detail.error` carries the same code. | HTTP | `code` | Cause | |---|---|---| | `400` | `agency_id_not_allowed` | An API key sent `agency_id` | | `400` | `invalid_date` | `date_from` or `date_to` is not an ISO 8601 value, or is outside 1900 to 2200 | | `400` | `invalid_date_range` | `date_from` is after `date_to` | | `400` | `invalid_product` | `product` is not one of `all`, `flights`, `hotels`, `esims`, `transfers` (or their singular names). `details.allowed` lists them | | `400` | `invalid_status` | `status` is not a plain status name: letters, digits, `_`, spaces or hyphens, at most 32 characters | | `400` | `invalid_booking_id` | The booking reference has more than 100 characters or characters other than letters, digits and `._:=+~-` | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `404` | `balance_not_found` | The `balance_id` is not a balance of your agency (live) | | `404` | `booking_not_found` | No booking matches in this environment | | `422` | `validation_error` | `page` is below 1 or above 10,000, `limit` is above 100, or a value is too long | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | | `500` | `internal_error` | An error on our side. Retry; contact support if it persists | | `503` | `service_overloaded` | The service is shedding load for a moment (its database pool is full). Nothing was read: retry after the seconds in `Retry-After` (2 to 5) | --- # List balances > Current balance for each currency the agency holds. Endpoint: `GET /api/v1/balances` Source: https://fx-port.com/docs/account/balances `GET /api/v1/balances` **Permission:** Read Returns the balance of your agency in each currency it holds, ordered by currency code. Keep the `id` of each balance: it is the `balance_id` of the [balance history](https://fx-port.com/docs/account/balance-history.md). Never send `agency_id`: the agency comes from the key, and an API key that sends it is refused with `400 agency_id_not_allowed`. **Sandbox:** the sandbox has no balances. `data` is empty and `meta` says so (see below). ## Request example **cURL** ~~~bash curl --request GET \ --url https://api.fx-port.com/api/v1/balances \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' ~~~ **Node.js** ~~~js const response = await fetch("https://api.fx-port.com/api/v1/balances", { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: balances } = await response.json(); const dzd = balances.find((balance) => balance.currency_code === "DZD"); ~~~ **Python** ~~~python import requests response = requests.get( "https://api.fx-port.com/api/v1/balances", headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) balances = response.json()["data"] ~~~ ## Response example An agency can hold several currencies. Each row has its own `id`. These amounts are examples: the values of a live answer are your own. ~~~json { "environment": "live", "success": true, "data": [ { "id": "db002f7a-c136-4f32-b19d-38a2542f2af9", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "currency_code": "DZD", "currency": "DZD", "available_balance": 412500.0, "created_at": "2025-08-22T10:24:06.533800+00:00", "updated_at": "2026-10-17T04:45:15.697817+00:00" }, { "id": "a71c9e34-2b18-40df-9c77-6b0a1f2e5d40", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "currency_code": "EUR", "currency": "EUR", "available_balance": 18750.5, "created_at": "2025-08-22T10:24:06.533800+00:00", "updated_at": "2026-10-16T22:10:03.120400+00:00" } ], "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "live" } } ~~~ The same call with a **sandbox** key answers (an envelope with an empty list, not a bare array): ~~~json { "environment": "sandbox", "success": true, "data": [], "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox", "financialOperationsEnabled": false, "financial_operations_enabled": false, "message": "Sandbox bookings do not debit live balances; no sandbox balance ledger is exposed." } } ~~~ ## Field reference | Field | Description | |---|---| | `id` | The balance UUID: send it as `balance_id` to the history endpoint | | `agency_id` | Your agency UUID | | `currency_code` | ISO 4217 currency code, for example `DZD`, `EUR`, `USD`. `currency` carries the same value (the name the other endpoints use) | | `available_balance` | The amount you can spend, in this currency | | `created_at` | When the balance row was created | | `updated_at` | When the balance last moved | | `meta.agencyId` | Your agency UUID. `meta.agency_id` carries the same value | | `meta.financialOperationsEnabled` | Sandbox only, always `false`. `meta.financial_operations_enabled` carries the same value | | `meta.message` | Sandbox only: why `data` is empty | ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `agency_id_not_allowed` | An API key sent `agency_id` | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | | `503` | `service_overloaded` | The service is shedding load for a moment (its database pool is full). Nothing was read: retry after the seconds in `Retry-After` (2 to 5) | --- # Balance history > The complete financial ledger of one currency balance: top-ups, booking debits and refunds. Endpoint: `GET /api/v1/balances/{balance_id}/history` Source: https://fx-port.com/docs/account/balance-history `GET /api/v1/balances/{balance_id}/history` **Permission:** Read The money movements recorded against one balance: top-ups, booking debits and refunds, newest first. Call [`GET /api/v1/balances`](https://fx-port.com/docs/account/balances.md) first to get the `balance_id` of the currency you want. **Sandbox:** the answer is always empty (`pagination.total` is `0`) and `meta` says why. **Live:** a `balance_id` that is not a balance of your agency, or that is not a UUID, is answered `404 balance_not_found`. ## The `source` field Each entry has a `source`: | `source` | What it represents | |---|---| | `"transactions"` | **Authoritative balance movements.** Recorded every time the balance changes: top-up, booking charge or refund. **Use these for reconciliation totals.** eSIM orders and topups have no audit source: their debits are `transactions` rows. Transfers have no audit source either | | `"flight_balance_operations"` | **Per-booking flight audit records.** Granular events of a booking (initial debit, exchange fee, adjustment, refund). One booking can produce several rows | | `"hotel_balance_operations"` | **Per-booking hotel audit records.** The same idea as the flight operations | A single action, for example confirming a flight, produces **both** a `transactions` row and a `flight_balance_operations` row, and both appear in this answer. **Sum only the `transactions` rows** for financial totals. The `description` of an eSIM debit is `eSIM Order ` (a topup: `eSIM TopUp `), where the order code is the `order_id` of the booking. ## Parameters | Parameter | Type | Description | |---|---|---| | `balance_id` (path) | string | The `id` of a balance from `GET /api/v1/balances` | | `date_from` | string | Inclusive start. Any ISO 8601 spelling is read: a date (`2026-07-01` or `20260701`, 00:00:00 UTC) or a datetime (`2026-07-01T00:00:00Z`) with 1 to 9 fraction digits and an offset written `Z`, `+01:00` or `+0100` (in a URL write the `+` as `%2B`). Without an offset a datetime is UTC | | `date_to` | string | Inclusive end, in the same spellings. A bare date means the end of that day (23:59:59.999999 UTC). Not before `date_from` | | `type` | string | Keep one type. **Exact and case sensitive**: `CREDIT`, `DEBIT` or `REFUND` match `transactions`; `credit`, `debit` or `refund` match the two `*_balance_operations` sources. Up to 32 characters | | `page` | integer | The page, starting at 1 (at most 10,000). Default 1. An empty value means not sent | | `limit` | integer | Entries per page, 1 to **100**. Default 20. A larger value is refused with `422 validation_error`; an empty value means not sent | **Legacy names** (still work; the response carries a `deprecated_field` warning; `date_from` and `date_to` win when both are sent): | Legacy | Use instead | |---|---| | `from`, `from_date` | `date_from` | | `to`, `to_date` | `date_to` | Never send `agency_id`: an API key that does is refused with `400 agency_id_not_allowed`. A value that is not an ISO 8601 date (`01/10/2026`, an epoch number, `yesterday`) is `400 invalid_date`, and `date_from` after `date_to` is `400 invalid_date_range`. ## Movement types | Type | Direction | When it appears | |---|---|---| | `CREDIT` / `credit` | Funds **in** | Manual top-ups, deposits returned when a hold is released | | `DEBIT` / `debit` | Funds **out** | Booking confirmations, exchange fees, manual debit adjustments | | `REFUND` / `refund` | Funds **in** | Full or partial refund after a booking is cancelled or voided | Upper case values appear in `transactions` rows and lower case values in `*_balance_operations` rows. ## Request example **cURL** ~~~bash curl --get \ --url https://api.fx-port.com/api/v1/balances/db002f7a-c136-4f32-b19d-38a2542f2af9/history \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --data-urlencode 'date_from=2026-10-01' \ --data-urlencode 'date_to=2026-10-31' \ --data-urlencode 'limit=50' ~~~ **Node.js** ~~~js const balanceId = "db002f7a-c136-4f32-b19d-38a2542f2af9"; const params = new URLSearchParams({ date_from: "2026-10-01", date_to: "2026-10-31", limit: "50" }); const response = await fetch(`https://api.fx-port.com/api/v1/balances/${balanceId}/history?${params}`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: entries, pagination } = await response.json(); // reconciliation: only the authoritative movements const net = entries .filter((entry) => entry.source === "transactions") .reduce((sum, entry) => sum + (entry.type === "DEBIT" ? -entry.amount : entry.amount), 0); ~~~ **Python** ~~~python import requests balance_id = "db002f7a-c136-4f32-b19d-38a2542f2af9" response = requests.get( f"https://api.fx-port.com/api/v1/balances/{balance_id}/history", params={"date_from": "2026-10-01", "date_to": "2026-10-31", "limit": 50}, headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) body = response.json() ~~~ ## Response example The amounts are examples. A live answer: ~~~json { "environment": "live", "success": true, "data": [ { "source": "transactions", "id": "3e8b1c02-d7a4-4f5e-9b2a-1c0d8e7f3a91", "type": "CREDIT", "amount": 2500.0, "currency": "DZD", "description": "Balance top-up", "balanceId": "db002f7a-c136-4f32-b19d-38a2542f2af9", "balance_id": "db002f7a-c136-4f32-b19d-38a2542f2af9", "createdAt": "2026-10-11T09:15:00.000000+00:00", "created_at": "2026-10-11T09:15:00.000000+00:00" }, { "source": "flight_balance_operations", "id": "658185df-234c-43ba-8175-ba9a5abe6007", "type": "debit", "amount": 87.5, "currency": "DZD", "description": "Exchange fee debit for flight booking Y7PAEU", "bookingRecordId": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "booking_record_id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "createdAt": "2026-10-09T14:30:00.000000+00:00", "created_at": "2026-10-09T14:30:00.000000+00:00" }, { "source": "transactions", "id": "31936df0-299d-429e-b0f5-056a6c0587cc", "type": "DEBIT", "amount": 4925.44, "currency": "DZD", "description": "Booking confirmation: flight Y7PAEU (ALG → ORN)", "balanceId": "db002f7a-c136-4f32-b19d-38a2542f2af9", "balance_id": "db002f7a-c136-4f32-b19d-38a2542f2af9", "createdAt": "2026-10-01T15:42:34.272792+00:00", "created_at": "2026-10-01T15:42:34.272792+00:00" } ], "pagination": { "page": 1, "limit": 20, "total": 3, "total_pages": 1, "hasMore": false, "has_more": false }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "balanceId": "db002f7a-c136-4f32-b19d-38a2542f2af9", "balance_id": "db002f7a-c136-4f32-b19d-38a2542f2af9", "balanceCurrency": "DZD", "balance_currency": "DZD", "environment": "live" } } ~~~ ## Field reference | Field | Present when | Description | |---|---|---| | `source` | Always | Where the entry comes from: see the table above | | `id` | Always | The entry UUID | | `type` | Always | `CREDIT`, `DEBIT` or `REFUND` (upper case in `transactions`, lower case in operations) | | `amount` | Always | The amount, always positive: `type` gives the direction | | `currency` | Always | ISO 4217 code: always the currency of the balance | | `description` | Always | A label with the booking reference and the action. It can be `null` | | `balanceId` / `balance_id` | `transactions` only | The balance the movement was recorded against | | `bookingRecordId` / `booking_record_id` | operations only | The internal `id` of the booking: use it to match the entry with the [booking history](https://fx-port.com/docs/account/history.md) | | `createdAt` / `created_at` | Always | ISO 8601 datetime, UTC | | `pagination.hasMore` / `has_more` | Always | `true` when another page can be served | | `meta.balanceId`, `meta.balanceCurrency` | Live | The balance of the history and its currency. Twins: `balance_id`, `balance_currency` | ### How far back the pages go `pagination.total` is the real number of entries that match. The pages are built from the **newest entries of each source** (up to 500 per source), so the oldest entries of a long history cannot be reached by paging: follow `has_more` and narrow the list with `date_from` and `date_to` to read an older period. > [!NOTE] > **Reconciliation tip.** A single booking event produces both a `transactions` row and a > `flight_balance_operations` row. To compute the net change of a balance, sum only the `transactions` rows. To > trace one booking, match the `bookingRecordId` of the operation rows with the `id` of the booking in the > [history](https://fx-port.com/docs/account/history.md). ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `agency_id_not_allowed` | An API key sent `agency_id` | | `400` | `invalid_date`, `invalid_date_range` | A date is not valid, or `date_from` is after `date_to` | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `404` | `balance_not_found` | The `balance_id` is not a balance of your agency (live), or is longer than 64 characters (any environment) | | `422` | `validation_error` | `page` is below 1 or above 10,000, or `limit` is above 100 | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | | `503` | `service_overloaded` | The service is shedding load for a moment (its database pool is full). Nothing was read: retry after the seconds in `Retry-After` (2 to 5) | --- # Booking history > Booking summaries across all products, newest first. Endpoint: `GET /api/v1/history` Source: https://fx-port.com/docs/account/history `GET /api/v1/history` **Permission:** Read Booking summaries across one or all products (flights, hotels, eSIMs and transfers), newest first, limited to the environment of your key. eSIM orders **and** topups are listed (`type` tells them apart). > [!NOTE] > The records are lightweight: enough for status tracking, reconciliation and dashboards. For one booking in full, use > the [booking detail](https://fx-port.com/docs/account/booking-detail.md) (`GET /api/v1/bookings/{booking_id}`) or the product's own > endpoint (for example `POST /api/v1/flights/get_booking`). ## Query parameters | Parameter | Type | Default | Description | |---|---|---|---| | `product` | string | `all` | `all`, `flights`, `hotels`, `esims` or `transfers`. Case does not matter and the singular names (`flight`, `hotel`, `esim`, `transfer`) are read as the plural. Anything else is `400 invalid_product` | | `status` | string | none | Keep bookings with exactly this status, for example `confirmed`. Case does not matter; spaces and hyphens are read as underscores (`On Hold` is `on_hold`); letters, digits and `_`, at most 32 characters. `pending` is accepted as another name of `awaiting_payment`. A status nothing has returns an empty list | | `date_from` | string | none | Inclusive start, on the creation time of the booking. Any ISO 8601 spelling is read: a date (`2026-07-01` or `20260701`, 00:00:00 UTC) or a datetime with 1 to 9 fraction digits and an offset written `Z`, `+01:00` or `+0100` (in a URL write the `+` as `%2B`). Without an offset a datetime is UTC | | `date_to` | string | none | Inclusive end, in the same spellings. A bare date means the end of that day (23:59:59.999999 UTC). Not before `date_from` | | `page` | integer | `1` | The page, starting at 1 (at most 10,000). An empty value means not sent | | `limit` | integer | `20` | Entries per page, 1 to **100** (not 200). A larger value is refused with `422 validation_error`; an empty value means not sent | **Legacy names** (still work; the response carries a `deprecated_field` warning; `date_from` and `date_to` win when both are sent): | Legacy | Use instead | |---|---| | `from`, `from_date` | `date_from` | | `to`, `to_date` | `date_to` | Never send `agency_id`: an API key that does is refused with `400 agency_id_not_allowed`. ### Pagination `pagination` is `{ page, limit, total, total_pages, hasMore, has_more }`. `total` is the **real number** of bookings that match (all products together). The pages are served from the **200 newest bookings of each product**, so with `product=all` at most 800 bookings can be reached by paging: follow `has_more` (`hasMore` is the same value), and use `date_from` and `date_to` to read an older period. ## Request examples **cURL** ~~~bash # The latest eSIM bookings curl --get \ --url https://api.fx-port.com/api/v1/history \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --data-urlencode 'product=esims' \ --data-urlencode 'limit=2' # Confirmed flights of a period curl --get \ --url https://api.fx-port.com/api/v1/history \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --data-urlencode 'product=flights' \ --data-urlencode 'status=confirmed' \ --data-urlencode 'date_from=2026-11-01' \ --data-urlencode 'date_to=2026-11-30' ~~~ **Node.js** ~~~js const params = new URLSearchParams({ product: "flights", status: "confirmed", date_from: "2026-11-01", limit: "100" }); const response = await fetch(`https://api.fx-port.com/api/v1/history?${params}`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); const { data: bookings, pagination } = await response.json(); // keep the internal id as your key; bookingId is a display reference const ids = bookings.map((booking) => booking.id); ~~~ **Python** ~~~python import requests response = requests.get( "https://api.fx-port.com/api/v1/history", params={"product": "esims", "limit": 50, "date_from": "2026-11-01"}, headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) body = response.json() ~~~ ## Response example An eSIM order, as the API returns it (a sandbox answer of the request above): ~~~json { "environment": "sandbox", "success": true, "data": [ { "product": "esims", "id": "639417b4-b103-4ece-bd5d-850315322278", "bookingId": "FX-1793546602916", "booking_id": "FX-1793546602916", "status": "confirmed", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "agent_id": null, "currency": "DZD", "totalPrice": 468.84, "total_price": 468.84, "netPrice": 418.6, "b2b_price": 418.6, "agencyCommission": 50.24, "agency_commission": 50.24, "providerName": "Airalo", "provider_name": "Airalo", "supplierId": "airalo", "supplier_id": "airalo", "createdAt": "2026-11-01T15:23:22.961228+00:00", "created_at": "2026-11-01T15:23:22.961228+00:00", "updatedAt": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "orderId": "20261101-106852", "order_id": "20261101-106852", "type": "esim", "packageName": "1 GB - 7 days • France", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customerEmail": "traveler@example.com", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123" } ], "pagination": { "page": 1, "limit": 2, "total": 80, "total_pages": 40, "hasMore": true, "has_more": true }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox", "product": "esims" } } ~~~ The entries of the other products have the same first fifteen keys and their own keys after `updatedAt`. They are shown without their snake_case twins to keep them short: **Flights** ~~~json { "product": "flights", "id": "8723790a-0417-46bb-8247-07254b214a4c", "bookingId": "Y7INFJ", "status": "awaiting_payment", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "currency": "DZD", "totalPrice": 32593.93, "netPrice": 32561.36, "agencyCommission": 32.57, "providerName": "Amadeus Algeria", "supplierId": "amadeus_aqc_dz_1", "createdAt": "2026-11-01T14:31:20.248661+00:00", "updatedAt": "2026-11-01T14:31:22.611902+00:00", "departureAirport": "ALG", "arrivalAirport": "CDG", "departureDate": "2026-12-15T07:15:00", "arrivalDate": "2026-12-15T09:45:00", "ticketNumber": null, "requireAcd": false, "voucherUrl": "https://files.example.com/flight-bookings/Y7INFJ-JOHN-SMITH.pdf", "voucherUrlWithPrice": "https://files.example.com/flight-bookings/Y7INFJ-JOHN-SMITH.pdf", "voucherUrlNoPrice": "https://files.example.com/flight-bookings/Y7INFJ-JOHN-SMITH-no-price.pdf" } ~~~ **Hotels** ~~~json { "product": "hotels", "id": "34a08cf3-dff6-45c6-bdae-882061aa78f5", "bookingId": "HMVXX4ZQ3", "status": "refunded", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "currency": "DZD", "totalPrice": 1484.97, "netPrice": 1482.0, "agencyCommission": 2.97, "providerName": "ETG - RateHawk", "supplierId": "ratehawk", "createdAt": "2026-11-01T14:22:00.924125+00:00", "updatedAt": "2026-11-01T14:22:32.992144+00:00", "hotelId": "test_hotel_do_not_book", "hotelName": "Test Hotel (Do Not Book) test", "checkinDate": "2026-12-01", "checkoutDate": "2026-12-04", "bookingMode": "standard", "voucherUrl": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlWithPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlNoPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith-no-price.pdf" } ~~~ **Transfers** ~~~json { "product": "transfers", "id": "4745c761-0737-421a-aaab-c90f9c0aca8f", "bookingId": "VIA-1782555657-QCF", "status": "refunded", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": "5d6e7f80-91a2-4b3c-8d4e-5f6071829304", "currency": "DZD", "totalPrice": 15124.38, "netPrice": 15002.38, "agencyCommission": 122.0, "providerName": "AirportTransfer.com", "supplierId": null, "createdAt": "2026-06-27T10:20:59.188372+00:00", "updatedAt": "2026-06-27T10:29:28.684+00:00", "arrivalDate": "2026-08-14T16:00:00+00:00", "flightNumber": "593", "voucherUrl": null, "receiptUrl": "https://files.example.com/transfer-bookings/VIA-1782555657-QCF.pdf" } ~~~ ## Summary fields **Every product** | Field (twin) | Description | |---|---| | `product` | `flights`, `hotels`, `esims` or `transfers` | | `id` | The internal id of the booking (UUID). Stable and unique: **use it as your key** | | `bookingId` (`booking_id`) | The public reference: the airline record locator of a flight (**not unique** across suppliers), the hotel booking id, `FX-` followed by a number for an eSIM, or the transfer reference | | `status` | The state of the booking (see below) | | `environment` | `sandbox` or `live` | | `agencyId` (`agency_id`) | Your agency UUID | | `agentId` (`agent_id`) | The user of your agency who created the booking, or `null` when it was created through an API key without a user | | `currency` | Your agency currency: the currency of every amount | | `totalPrice` (`total_price`) | What the traveler pays, with your commission | | `agencyCommission` (`agency_commission`) | Your commission as an **amount**. `0` when there is none | | `netPrice` (`b2b_price`) | The amount **debited from your balance**: `totalPrice` minus `agencyCommission` | | `providerName` (`provider_name`) | The supplier that fulfilled the booking | | `supplierId` (`supplier_id`) | The supplier id stored on the booking (see the note below) | | `createdAt` (`created_at`), `updatedAt` (`updated_at`) | ISO 8601, UTC | **Flights**: `departureAirport`, `arrivalAirport` (IATA), `departureDate`, `arrivalDate` (local, without a zone), `ticketNumber` (`null` until issued), `requireAcd` (`require_acd`), `voucherUrl` and `voucherUrlWithPrice` (the confirmation PDF with prices) and `voucherUrlNoPrice` (without prices, to give to the traveler). The voucher links are `null` while the PDF is generated. **Hotels**: `hotelId`, `hotelName` (can be `null` on a failed booking), `checkinDate`, `checkoutDate` (`YYYY-MM-DD`), `bookingMode`, and the same three voucher links. **eSIMs**: `orderId` (`order_id`, the supplier's order code), `type` (`esim` or `topup`), `packageName` (`package_name`), `iccid`, `customerEmail` (`customer_email`), `customer_first_name`, `customer_last_name`, `customer_phone`. **Transfers**: `arrivalDate`, `flightNumber`, `voucherUrl` (the supplier voucher: `null` until a driver is assigned) and `receiptUrl` (FX-Port's receipt PDF, once the booking is confirmed). > [!NOTE] > `supplierId` is the id stored on the booking: `amadeus_aqc_dz_1` and `duffel_1` for flights, but `ratehawk` for > hotels and `airalo` for eSIMs, while the [supplier list](https://fx-port.com/docs/suppliers.md) calls the same suppliers `worldota_1` and > `airalo_1`. Do not match the two lists on the id. ## Status values | Status | Products | Meaning | |---|---|---| | `confirmed` | All | The booking is confirmed | | `awaiting_payment` | Flights | A held booking that is not paid yet. `pending` is accepted as another name in the filter | | `cancelled` | Flights | The booking was cancelled | | `failed` | Flights, eSIMs | The supplier refused the booking | | `refunded` | Flights, hotels, transfers | The booking was refunded | | `partial_refund` | Flights | Part of the booking was refunded | These are the values seen today; a product can use others, and a filter on a status nothing has answers an empty list. ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `invalid_product` | `product` is not one of the allowed values (`details.allowed` lists them) | | `400` | `invalid_status` | `status` is not a plain status name (letters, digits, `_`, spaces or hyphens, at most 32 characters) | | `400` | `invalid_date`, `invalid_date_range` | A date is not valid, or `date_from` is after `date_to` | | `400` | `agency_id_not_allowed` | An API key sent `agency_id` | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `422` | `validation_error` | `page` is below 1 or above 10,000, or `limit` is above 100 | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | | `503` | `service_overloaded` | The service is shedding load for a moment (its database pool is full). Nothing was read: retry after the seconds in `Retry-After` (2 to 5) | --- # Booking detail > A single booking by its public reference, with the pricing breakdown and the traveler pricing. Endpoint: `GET /api/v1/bookings/{booking_id}` Source: https://fx-port.com/docs/account/booking-detail `GET /api/v1/bookings/{booking_id}` **Permission:** Read Returns one booking of your agency, whatever its product: the **summary** of the [history](https://fx-port.com/docs/account/history.md), the stored **record**, and, for flights and hotels, the **price breakdown** and the **balance operations**; for flights also the **price per traveler**. ## The reference you can send `booking_id` is the internal `id` of the booking (a UUID) or the public reference of its product. The reference is looked up in these fields, in this order, and the first booking found is returned: | Product | Fields tried | |---|---| | `flights` | `booking_id` (the airline record locator, the PNR), the supplier's order id, then `id` | | `hotels` | `booking_id`, then `id` | | `esims` | `booking_id` (`FX-` followed by a number), the supplier's order code (`order_id`), the **ICCID** (18 to 22 digits, or a `PENDING-...` placeholder), then `id` | | `transfers` | `booking_id`, then `id` | Without `product`, the products are tried in the order flights, hotels, eSIMs, transfers. The reference is at most 100 characters: letters, digits and `._:=+~-` (`400 invalid_booking_id` otherwise). > [!WARNING] > **Prefer the internal `id` (UUID).** A PNR or a booking reference is **not globally unique**: the same record locator can > exist at two airlines or suppliers, so a lookup by PNR can match another booking than the one you mean. The internal `id` > is in every booking payload, unique and stable: store it, and treat the PNR as a display reference. **An ICCID returns the original order**, never a later topup. An eSIM that was topped up has several bookings with the same ICCID; the lookup by ICCID answers the oldest order of type `esim`. Look a topup up by its `booking_id` or its `id`. ## Query parameters | Parameter | Default | Description | |---|---|---| | `product` | `all` | Look in one product only: `flights`, `hotels`, `esims` or `transfers` (case does not matter; the singular names are read as the plural). It is faster, and it avoids a match in another product. A reference that belongs to another product than the one you ask for is `404 booking_not_found` | Never send `agency_id`: an API key that does is refused with `400 agency_id_not_allowed`. ## Request examples **cURL** ~~~bash # Recommended: the internal id (a UUID), which is unambiguous curl --get \ --url https://api.fx-port.com/api/v1/bookings/711371c3-00aa-4be2-b790-9f0ca2ccfdb7 \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --data-urlencode 'product=flights' # An eSIM order by its public reference curl --get \ --url https://api.fx-port.com/api/v1/bookings/FX-1793546602916 \ --header 'Authorization: Bearer fxp_test_YOUR_KEY' \ --data-urlencode 'product=esims' ~~~ **Node.js** ~~~js const id = "711371c3-00aa-4be2-b790-9f0ca2ccfdb7"; const response = await fetch(`https://api.fx-port.com/api/v1/bookings/${id}?product=flights`, { headers: { Authorization: "Bearer fxp_test_YOUR_KEY" }, }); if (response.status === 404) { // booking_not_found: not in this environment, or not in this product } const { data: booking } = await response.json(); ~~~ **Python** ~~~python import requests response = requests.get( "https://api.fx-port.com/api/v1/bookings/FX-1793546602916", params={"product": "esims"}, headers={"Authorization": "Bearer fxp_test_YOUR_KEY"}, timeout=30, ) booking = response.json()["data"] ~~~ ## Response examples **eSIMs** An eSIM order (a sandbox answer). The summary keys come with their snake_case twins: ~~~json { "environment": "sandbox", "success": true, "data": { "product": "esims", "id": "639417b4-b103-4ece-bd5d-850315322278", "bookingId": "FX-1793546602916", "booking_id": "FX-1793546602916", "status": "confirmed", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "agent_id": null, "currency": "DZD", "totalPrice": 468.84, "total_price": 468.84, "netPrice": 418.6, "b2b_price": 418.6, "agencyCommission": 50.24, "agency_commission": 50.24, "providerName": "Airalo", "provider_name": "Airalo", "supplierId": "airalo", "supplier_id": "airalo", "createdAt": "2026-11-01T15:23:22.961228+00:00", "created_at": "2026-11-01T15:23:22.961228+00:00", "updatedAt": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "orderId": "20261101-106852", "order_id": "20261101-106852", "type": "esim", "packageName": "1 GB - 7 days • France", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customerEmail": "traveler@example.com", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "record": { "id": "639417b4-b103-4ece-bd5d-850315322278", "booking_id": "FX-1793546602916", "order_id": "20261101-106852", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "esim", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "environment": "sandbox" } }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox" } } ~~~ **Flights** A confirmed flight, shortened: the summary keeps only some of its keys (the [history](https://fx-port.com/docs/account/history.md#summary-fields) lists them all), the snake_case twins are left out, and `record` leaves out its keys without a value: ~~~json { "environment": "sandbox", "success": true, "data": { "product": "flights", "id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "bookingId": "Y7PAEU", "status": "confirmed", "environment": "sandbox", "currency": "DZD", "totalPrice": 4930.37, "netPrice": 4925.44, "agencyCommission": 4.93, "providerName": "Amadeus Algeria", "supplierId": "amadeus_aqc_dz_1", "ticketNumber": "124-2446977397", "record": { "id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "booking_id": "Y7PAEU", "status": "confirmed", "currency": "DZD", "total_price": 4930.37, "agency_commission": 4.93, "departure_airport": "ALG", "arrival_airport": "ORN", "departure_date": "2026-12-22T19:45:00", "arrival_date": "2026-12-22T20:45:00", "ticket_number": "124-2446977397", "require_acd": false, "bucket_link": "https://files.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-confirmed.pdf", "bucket_link_no_price": "https://files.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-confirmed-no-price.pdf", "paid_at": "2026-11-01T15:26:14.842190+00:00", "created_at": "2026-11-01T15:26:14.871035+00:00", "updated_at": "2026-11-01T15:26:23.505356+00:00", "environment": "sandbox" }, "pricing": [ { "supplier_currency": "DZD", "agency_currency": "DZD", "base_price": 3160.0, "taxes_and_fees": 1765.44, "agency_commission": 4.93, "final_price": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:22.176516+00:00", "environment": "sandbox" } ], "travelerPricing": [ { "traveler_id": "1", "traveler_type": "ADULT", "fare_option": "STANDARD", "supplier_currency": "DZD", "agency_currency": "DZD", "base": 3160.0, "total": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:15.133066+00:00", "environment": "sandbox" } ], "balanceOperations": [] }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox" } } ~~~ **Hotels** A refunded hotel booking, shortened the same way: ~~~json { "environment": "sandbox", "success": true, "data": { "product": "hotels", "id": "34a08cf3-dff6-45c6-bdae-882061aa78f5", "bookingId": "HMVXX4ZQ3", "status": "refunded", "environment": "sandbox", "currency": "DZD", "totalPrice": 1484.97, "netPrice": 1482.0, "agencyCommission": 2.97, "providerName": "ETG - RateHawk", "supplierId": "ratehawk", "hotelId": "test_hotel_do_not_book", "checkinDate": "2026-12-01", "checkoutDate": "2026-12-04", "record": { "id": "34a08cf3-dff6-45c6-bdae-882061aa78f5", "booking_id": "HMVXX4ZQ3", "status": "refunded", "currency": "DZD", "total_price": 1484.97, "agency_commission": 2.97, "hotel_id": "test_hotel_do_not_book", "hotel_name": "Test Hotel (Do Not Book) test", "checkin_date": "2026-12-01", "checkout_date": "2026-12-04", "booking_mode": "standard", "free_cancellation_deadline_at": null, "supplier_id": "ratehawk", "refund_amount": 1482.0, "refund_currency": "DZD", "environment": "sandbox" }, "pricing": [ { "id": "72db7216-177e-4c5b-bde6-74c5ca3c8db2", "created_at": "2026-11-01T14:22:05.162117+00:00", "agency_currency": "DZD", "total_price": 1484.97, "agency_commission": 2.97, "environment": "sandbox", "b2b_price": 1482.0 } ], "balanceOperations": [] }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox" } } ~~~ A transfer has the summary and the `record` only. The balance operations of a flight or a hotel are empty in the sandbox. ## Field reference **Summary.** The keys before `record` are the ones of the [history](https://fx-port.com/docs/account/history.md#summary-fields), with the same snake_case twins. **`record`** is the stored booking in snake_case. A key that does not apply to the product is absent. | Product | Keys of `record` | |---|---| | All | `id`, `booking_id`, `status`, `currency`, `total_price`, `agency_commission`, `created_at`, `updated_at`, `environment` | | Flights | `departure_airport`, `arrival_airport`, `departure_date`, `arrival_date`, `return_departure_date`, `return_arrival_date`, `included_checked_bags`, `ticket_number` (the e-ticket number once issued), `require_acd`, `bucket_link` (the confirmation PDF with prices), `bucket_link_no_price`, `refund_amount`, `refund_currency`, `refund_method`, `paid_at`, `voided_at` | | Hotels | `hotel_id`, `hotel_name`, `checkin_date`, `checkout_date`, `booking_mode`, `bucket_link`, `bucket_link_no_price`, `refund_amount`, `refund_currency`, `free_cancellation_deadline_at`, `paid_at`, `supplier_id` | | eSIMs | `order_id`, `type`, `package_name`, `iccid`, `customer_email`, `customer_first_name`, `customer_last_name`, `customer_phone`, `provider_name`, `supplier_id` | | Transfers | `arrival_date`, `flight_number`, `provider_name`, `bucket_link` (FX-Port's receipt), `voucher_link` (the supplier voucher), `refunded_amount`, `refunded_currency` | **`pricing[]`** (flights and hotels) | Field | Description | |---|---| | `agency_currency` | Your agency currency | | `base_price`, `taxes_and_fees` | Flights. The fare before taxes, and the taxes and fees **without** your commission | | `final_price` | Flights. What the traveler pays: `base_price` + `taxes_and_fees` + `agency_commission` | | `total_price` | Hotels. What the traveler pays | | `agency_commission` | Your commission as an amount | | `b2b_price` | The amount **debited from your agency balance**: the total minus `agency_commission` | | `supplier_currency` | Flights. The currency the supplier prices in | **`travelerPricing[]`** (flights; twin `traveler_pricing`): one row per traveler with `traveler_id`, `traveler_type`, `fare_option`, `base`, `total` and `b2b_price` (the amount debited for this traveler). **`balanceOperations[]`** (flights and hotels; twin `balance_operations`): the balance operations of the booking with `id`, `type` (`debit`, `credit` or `refund`), `amount`, `currency`, `description` and `createdAt` (`created_at`). They are the same operations as the `*_balance_operations` entries of the [balance history](https://fx-port.com/docs/account/balance-history.md). ## Errors | HTTP | `code` | Cause | |---|---|---| | `400` | `invalid_booking_id` | The reference is longer than 100 characters or has characters other than letters, digits and `._:=+~-` | | `400` | `invalid_product` | `product` is not one of the allowed values | | `400` | `agency_id_not_allowed` | An API key sent `agency_id` | | `401` | `authentication_required`, `invalid_api_key` | Missing or invalid API key | | `404` | `booking_not_found` | No booking of your agency in this environment has this reference, or it belongs to another product than `product` | | `429` | `rate_limit_exceeded` | Over 6,000 requests per minute for this key | | `503` | `service_overloaded` | The service is shedding load for a moment (its database pool is full). Nothing was read: retry after the seconds in `Retry-After` (2 to 5) | --- # Use these docs with AI > Open any page in ChatGPT or Claude, copy it as Markdown, or give an assistant llms.txt and the OpenAPI file. Source: https://fx-port.com/docs/ai-tools These docs are written to be read by developers **and** by AI assistants and agents. Every page is available as Markdown, the whole API is described in an OpenAPI 3.1 file, and the names the API uses are published as data. This page lists what exists and how to use it. ## Give an assistant the docs | What | Where | Use it to | |---|---|---| | **Any page as Markdown** | Add `.md` to its address: `https://fx-port.com/docs/flights/book.md`. The home page is `https://fx-port.com/docs/introduction.md`. | Paste one page, or let an assistant fetch it. | | **Page index** | [`https://fx-port.com/llms.txt`](https://fx-port.com/llms.txt) | A short index (title, address, one-line description of every page) for tools that follow the `llms.txt` convention. | | **Everything in one file** | [`https://fx-port.com/llms-full.txt`](https://fx-port.com/llms-full.txt) | Load the whole documentation into a long-context model. | | **OpenAPI 3.1** | [`https://fx-port.com/openapi.json`](https://fx-port.com/openapi.json) | Generate clients, tool definitions and tests. Every operation has an `operationId`, examples and its error codes. | | **Postman collections** | [Environments and Postman](https://fx-port.com/docs/environments.md#postman-collections) | Try every endpoint by hand. | | **The names of the API** | `GET https://api.fx-port.com/api/v1/conventions/naming` (no key) | Every previous spelling and snake_case twin as JSON. | Every page has a **Use with AI** menu next to its title: it opens ChatGPT or Claude on the Markdown of that page, copies the page as Markdown, or links the `llms.txt` and OpenAPI files. ## A prompt for a coding assistant Paste this at the start of a conversation, or into the instructions file of your assistant, then add what you want to build: ~~~text You are integrating the FX-Port travel API (flights, hotels, eSIMs). Documentation: https://fx-port.com/llms-full.txt and the OpenAPI file https://fx-port.com/openapi.json. Follow these rules. - Base URL https://api.fx-port.com/api/v1. Auth: Authorization: Bearer . Keys start with fxp_test_ (sandbox) or fxp_live_ (live). Use the sandbox key while developing. Keep the key on the server, in an environment variable. - Names: send snake_case (search_id, offer_id, booking_type). Read the snake_case twin of a response key when it exists (search_id next to searchId), or add ?naming=snake. - Flights: search -> price_flight -> book_flight -> get_booking. Always price right before you book. Send send_email=false while testing. - Hotels: in the sandbox you can search and price any hotel but you can only BOOK the test hotels test_hotel_do_not_book (any residency) and test_hotel (residency dz, ru or us). Cancel every sandbox booking. Use the mini hotel data for result lists and the full data only for one hotel's details page. - Errors: a call failed when the status is 4xx/5xx OR the body has success=false. Parse errors[0].code and branch on it, never on the message. Log request_id. Retry only 429 and 5xx with exponential backoff and jitter, honour Retry-After. - Never retry book/pay/cancel calls blindly: read the booking back first. There is no Idempotency-Key header. - Send a User-Agent of your own. Ask for gzip. Cache airports, suppliers, hotel content and eSIM packages. - Never print or log API keys or traveler documents. ~~~ ## Building an agent on top of the API An AI agent that searches and books for people needs a few habits that the API supports: 1. **Generate tools from the OpenAPI file.** Each `operationId` (`getFlights`, `priceFlight`, `bookFlight` ...) becomes a tool; the descriptions and examples are written to be read by a model. Give an agent only the read operations unless a human confirms the booking. 2. **Use a Read key for exploration and a Read+Write key only for the step that books.** A read key cannot book, pay or cancel: it fails with `403 read_write_permission_required`. 3. **Separate "price" from "book".** Show the priced offer to the user (or to your policy engine), then book with the same `search_id` and `offer_id`. Fares move: price again if the user waits. 4. **Treat `errors[].type` as the control flow.** `validation_error` means fix the arguments, `supplier_error` means search again or retry, `rate_limit_error` means wait for `Retry-After`. See [Errors](https://fx-port.com/docs/errors.md). 5. **Keep a trace.** Store the `request_id` of every call next to the booking it created. 6. **Stay in the sandbox until a human says go.** The same code runs on live by changing the key; make that switch explicit and reviewed. ## Keep the answers small Flight searches can be megabytes of JSON. When an agent reads them, let your code filter the answer (cheapest offers, duration, stops) before it reaches the model, and use [streaming](https://fx-port.com/docs/streaming.md) when you want results to appear as they arrive. For hotels, use the compact [mini data](https://fx-port.com/docs/hotels/hotel-data.md) for lists. --- # API reference > Every endpoint of the FX-Port API: parameters, request and response objects, examples in nine languages and the errors each can return. Source: https://fx-port.com/docs/api The FX-Port API is a REST API with predictable URLs, JSON request and response bodies, and standard HTTP status codes. Use it from your server to search, price and book flights and hotels, sell eSIMs, and read your balances and bookings. This reference is generated from the [OpenAPI document](https://fx-port.com/openapi.json), so it always matches what the API accepts and returns. New here? Start with the [Quickstart](https://fx-port.com/docs/quickstart.md). ## Base URL ~~~ https://api.fx-port.com/api/v1 ~~~ Live and sandbox share the base URL: the **key** you send decides which environment a request belongs to. Keys start with `fxp_test_` for the sandbox and `fxp_live_` for live. Every path in this reference is relative to the base URL. ## Authentication Send your key as a bearer token: ~~~http Authorization: Bearer fxp_test_YOUR_KEY ~~~ Create keys in the [dashboard](https://fx-port.com/dashboard/api). A **Read** key can search, price and read; a **Read+Write** key can also book, pay and cancel. Each endpoint page says which one it needs. See [Authentication](https://fx-port.com/docs/authentication.md) and [Security](https://fx-port.com/docs/security.md). ## Requests and responses - Bodies are JSON (`Content-Type: application/json`), except the ACD passport upload, which is `multipart/form-data`. - Request fields use `snake_case`. Older endpoints also accept their legacy `camelCase` spellings, and the reference marks them as deprecated and names the spelling to use: see [Conventions and naming](https://fx-port.com/docs/conventions.md) and the [list of legacy names](https://fx-port.com/docs/legacy-names.md). Responses keep their older keys and add `snake_case` twins; `?naming=snake` returns `snake_case` only. - Every JSON response starts with an `environment` field (`sandbox` or `live`), also sent in the `X-Environment` header, and carries a `request_id` (also the `X-Request-Id` header) to quote to support. - Prices come back in the currency of the agency that owns the key. You never send a currency. - The travel dates in the examples are fixed dates: send a date 30 to 60 days ahead, because a date in the past is refused with `date_in_past`. - A failed call returns the same error body everywhere (`errors[].code`, `errors[].type`, `errors[].message`): see [Errors](https://fx-port.com/docs/errors.md). - Keys are limited per minute: see [Rate limits](https://fx-port.com/docs/rate-limits.md). Flight searches can be streamed: see [Streaming](https://fx-port.com/docs/streaming.md). ## Endpoints | Method | Path | Permission | Description | | --- | --- | --- | --- | | `GET` | `/api/v1/flights/airports` | None | [Search airports](https://fx-port.com/docs/api/search-airports.md) | | `GET` | `/api/v1/flights/top-airports` | None | [Get the most searched airports](https://fx-port.com/docs/api/top-airports.md) | | `POST` | `/api/v1/get_flights` | Read | [Search flights](https://fx-port.com/docs/api/get-flights.md) | | `POST` | `/api/v1/flights/flexible_search` | Read | [Search flexible dates](https://fx-port.com/docs/api/flexible-search.md) | | `POST` | `/api/v1/flights/price_flight` | Read | [Price a flight offer](https://fx-port.com/docs/api/price-flight.md) | | `POST` | `/api/v1/flights/book_flight` | Read+Write | [Book a flight offer](https://fx-port.com/docs/api/book-flight.md) | | `POST` | `/api/v1/flights/get_booking` | Read | [Get a flight booking](https://fx-port.com/docs/api/get-booking.md) | | `POST` | `/api/v1/flights/get_ticket` | Read | [Get the ticket PDF of a booking](https://fx-port.com/docs/api/get-ticket.md) | | `POST` | `/api/v1/flights/get_ticket_number` | Read | [Get the e-ticket number of a booking](https://fx-port.com/docs/api/get-ticket-number.md) | | `POST` | `/api/v1/flights/get_order_quote` | Read | [Get the current quote of an order](https://fx-port.com/docs/api/get-order-quote.md) | | `POST` | `/api/v1/flights/pay_order` | Read+Write | [Pay a held order and issue the tickets](https://fx-port.com/docs/api/pay-order.md) | | `POST` | `/api/v1/flights/cancel_order` | Read+Write | [Cancel a held reservation](https://fx-port.com/docs/api/cancel-order.md) | | `POST` | `/api/v1/flights/cancel_quote` | Read | [Quote the refund of an issued order](https://fx-port.com/docs/api/cancel-quote.md) | | `POST` | `/api/v1/flights/confirm_cancel` | Read+Write | [Confirm the cancellation of an order](https://fx-port.com/docs/api/confirm-cancel.md) | | `POST` | `/api/v1/flights/get_acd_documents` | Read | [Get the ACD document state of a booking](https://fx-port.com/docs/api/get-acd-documents.md) | | `POST` | `/api/v1/flights/upload_acd_documents` | Read+Write | [Upload ACD passport documents](https://fx-port.com/docs/api/upload-acd-documents.md) | | `GET` | `/api/v1/hotels/locations/search` | Read | [Search destinations and get coordinates](https://fx-port.com/docs/api/search-hotel-locations.md) | | `GET` | `/api/v1/hotels/name-search` | Read | [Find a hotel by name](https://fx-port.com/docs/api/get-hotel-name-search.md) | | `POST` | `/api/v1/hotels/search-by-name` | Read | [Find a hotel by name (JSON body)](https://fx-port.com/docs/api/post-hotel-name-search.md) | | `POST` | `/api/v1/hotels/search_advanced` | Read | [Geo search: hotels around a point](https://fx-port.com/docs/api/search-hotels.md) | | `POST` | `/api/v1/hotels/search` | Read | [Read the content of many hotels by id](https://fx-port.com/docs/api/get-hotel-content.md) | | `POST` | `/api/v1/hotels/details` | Read | [Read the raw hotel documents by id](https://fx-port.com/docs/api/get-hotel-details.md) | | `POST` | `/api/v1/hotels/get_hotel_info` | Read | [Read one hotel for the hotel page](https://fx-port.com/docs/api/get-hotel-info.md) | | `POST` | `/api/v1/hotels/get_rates` | Read | [Get the rooms and rates of one hotel](https://fx-port.com/docs/api/get-hotel-rates.md) | | `POST` | `/api/v1/hotels/pre_book` | Read+Write | [Lock a rate before booking](https://fx-port.com/docs/api/pre-book-hotel.md) | | `POST` | `/api/v1/hotels/book_hotel` | Read+Write | [Book a locked rate](https://fx-port.com/docs/api/book-hotel.md) | | `POST` | `/api/v1/hotels/cancel_hotel` | Read+Write | [Cancel a confirmed hotel booking](https://fx-port.com/docs/api/cancel-hotel.md) | | `GET` | `/api/v1/hotels/get_voucher` | Read | [Get the voucher PDF links of a booking](https://fx-port.com/docs/api/get-hotel-voucher.md) | | `GET` | `/api/v1/esims/packages` | Read | [List eSIM packages](https://fx-port.com/docs/api/list-esim-packages.md) | | `GET` | `/api/v1/esims/compatible-devices` | Read | [List eSIM compatible devices](https://fx-port.com/docs/api/list-esim-compatible-devices.md) | | `POST` | `/api/v1/esims/orders` | Read+Write | [Create an eSIM order](https://fx-port.com/docs/api/create-esim-order.md) | | `GET` | `/api/v1/esims/topup-packages/{iccid}` | Read | [List topup packages of an eSIM](https://fx-port.com/docs/api/list-esim-topup-packages.md) | | `POST` | `/api/v1/esims/topups` | Read+Write | [Top up an eSIM](https://fx-port.com/docs/api/create-esim-topup.md) | | `GET` | `/api/v1/esims/usage/{iccid}` | Read | [Get the usage of an eSIM](https://fx-port.com/docs/api/get-esim-usage.md) | | `GET` | `/api/v1/esims/usage/bulk` | Read | [Get the usage of several eSIMs](https://fx-port.com/docs/api/get-esim-usage-bulk.md) | | `GET` | `/api/v1/esims/bookings` | Read | [List eSIM bookings](https://fx-port.com/docs/api/list-esim-bookings.md) | | `GET` | `/api/v1/esims/bookings/{iccid}/topup-history` | Read | [List the topups of an eSIM](https://fx-port.com/docs/api/list-esim-topup-history.md) | | `GET` | `/api/v1/esims/bookings/{booking_id}/verify` | Read | [Check that an eSIM booking is yours](https://fx-port.com/docs/api/verify-esim-booking.md) | | `GET` | `/api/v1/esims/bookings/{booking_id}/setup-instructions` | Read | [Get the setup data of a booking](https://fx-port.com/docs/api/get-esim-setup-instructions.md) | | `GET` | `/api/v1/esims/instructions/{iccid}` | Read | [Get eSIM installation instructions](https://fx-port.com/docs/api/get-esim-instructions.md) | | `GET` | `/api/v1/balances` | Read | [List balances](https://fx-port.com/docs/api/list-balances.md) | | `GET` | `/api/v1/balances/{balance_id}/history` | Read | [Get the history of a balance](https://fx-port.com/docs/api/get-balance-history.md) | | `GET` | `/api/v1/history` | Read | [List booking history](https://fx-port.com/docs/api/list-booking-history.md) | | `GET` | `/api/v1/bookings/{booking_id}` | Read | [Get a booking](https://fx-port.com/docs/api/get-account-booking.md) | | `GET` | `/api/v1/suppliers` | Read | [List suppliers](https://fx-port.com/docs/api/list-suppliers.md) | ## For tools and AI The whole reference is one file: [`/openapi.json`](https://fx-port.com/openapi.json) (OpenAPI 3.1, with an `operationId`, examples and error codes for every endpoint). Every page of this site is also available as Markdown by adding `.md` to its address, and [`/llms.txt`](https://fx-port.com/llms.txt) indexes them. See [Use these docs with AI](https://fx-port.com/docs/ai-tools.md). --- # Search airports > Airport and city autocomplete for the origin and destination of a search: send what the user has typed (an IATA code, a city or an airport name) and use the ap of the chosen record as origin or destination. Endpoint: `GET /flights/airports` Source: https://fx-port.com/docs/api/search-airports `GET /api/v1/flights/airports` No API key is needed to call this endpoint. No key is needed. The endpoint is built for a debounced typeahead: wait about 300 ms after the last keystroke and cancel the request that is in flight. The answer is a flat array with no `environment` key, and it can be kept for a minute by a browser and five minutes by a CDN (`Cache-Control: public, max-age=60, s-maxage=300`). A city code such as `PAR` (`isMetroOnly: true`) is valid as an origin or a destination and stands for every airport of the city. The lookups of one IP address are limited: a `429` carries `Retry-After`. When the lookup service is down the endpoint can answer `200` with up to 10 airports from a local list instead of an error. ## Request ### Query parameters - `query` (string, required): What the user typed: an IATA code (`ALG`), a city (`Paris`) or an airport name. 1 to 64 characters once cleaned: control characters are removed and runs of spaces become one. - `locale` (string, optional): The language of the names, `ll` or `ll-RR` (`en`, `fr`, `ar`, `es-AR`, `zh-CN` ...). Case and `_` or `-` are normalised (`FR` is `fr`); anything else is read as `en`. A well-formed locale the lookup does not know is not rejected: the names come back in English. Default `en`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/flights/airports?query=ALG&locale=en' ``` ## Response ### 200: A flat **array** of airports and cities, best match first. A real record has about 40 keys: only the ones listed in the schema are meant for your use. ```json [ { "id": "ALG", "displayname": "Houari Boumediene, Algiers, Algeria, (ALG)", "loctype": "ap", "cid": 3, "rid": 480, "ctid": 10465, "lat": 36.6985, "lng": 3.20672, "cc": "DZ", "country": "Algeria", "rc": "AL", "cityname": "Algiers, Algeria", "timezone": "Africa/Algiers", "utc": "+01:00", "airportname": "Houari Boumediene", "ap": "ALG", "apicode": "ALG", "box_maxX": 3.26672, "box_maxY": 36.7335, "box_minX": 3.1467199999999997, "box_minY": 36.663500000000006, "cityonly": "Algiers", "destination_images": { "image_jpeg": "https://content.r9cdn.net/rimg/dimg/a2/04/dd9815df-city-10465-164dc51f899.jpg?width=128&height=128&xhint=2298&yhint=1960&outputtype=JPEG&crop=true", "image_webp": "https://content.r9cdn.net/rimg/dimg/a2/04/dd9815df-city-10465-164dc51f899.jpg?width=128&height=128&xhint=2298&yhint=1960&outputtype=WEBP&crop=true" }, "displayType": { "type": "airport", "displayName": "Airport" }, "entityKey": "place:Houari_Boumediene_Airport", "indexId": "176626", "isMetroOnly": false, "kayakId": "ALG", "kayakType": "ap", "locationname": "Algiers, Algeria", "name": "Houari Boumediene", "objectID": "P189646", "placeID": "176626", "ptid": "AALG", "region": "Algiers", "searchFormPrimary": "ALG", "searchFormSecondary": "Algiers, Algeria", "secondary": "Algiers, Algeria", "shortdisplayname": "Algiers, Algeria (ALG)", "smartyDisplay": "Houari Boumediene, Algiers, Algeria, (ALG)" }, { "id": "AHO", "displayname": "Alghero-Fertilia, Alghero, Sardinia, Italy, (AHO)", "loctype": "ap", "cid": 118, "rid": 1673, "ctid": 32049, "lat": 40.63213, "lng": 8.29077, "cc": "IT", "country": "Italy", "rc": "SA", "cityname": "Alghero, Sardinia, Italy", "timezone": "Europe/Rome", "utc": "+02:00", "airportname": "Alghero-Fertilia", "ap": "AHO", "apicode": "AHO", "box_maxX": 8.35077, "box_maxY": 40.66712999999999, "box_minX": 8.23077, "box_minY": 40.59713, "cityonly": "Alghero", "destination_images": { "image_jpeg": "https://content.r9cdn.net/rimg/dimg/ee/86/623980ff-city-32049-1700cc8a82e.jpg?width=128&height=128&xhint=2050&yhint=1293&outputtype=JPEG&crop=true", "image_webp": "https://content.r9cdn.net/rimg/dimg/ee/86/623980ff-city-32049-1700cc8a82e.jpg?width=128&height=128&xhint=2050&yhint=1293&outputtype=WEBP&crop=true" }, "displayType": { "type": "airport", "displayName": "Airport" }, "entityKey": "place:Fertilia_Airport", "indexId": "177457", "isMetroOnly": false, "kayakId": "AHO", "kayakType": "ap", "locationname": "Alghero, Sardinia, Italy", "name": "Alghero-Fertilia", "objectID": "P233628", "placeID": "177457", "ptid": "AAHO", "region": "Sardinia", "searchFormPrimary": "AHO", "searchFormSecondary": "Alghero, Sardinia, Italy", "secondary": "Alghero, Sardinia, Italy", "shortdisplayname": "Alghero, Sardinia, Italy (AHO)", "smartyDisplay": "Alghero-Fertilia, Alghero, Sardinia, Italy, (AHO)", "subregion": "Sassari" } ] ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 422 | `invalid_query` | query must be 1-64 characters. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Search airports](https://fx-port.com/docs/flights/airports.md) --- # Get the most searched airports > The most searched airports, for the first suggestions of an empty search box before the user types anything. Endpoint: `GET /flights/top-airports` Source: https://fx-port.com/docs/api/top-airports `GET /api/v1/flights/top-airports` No API key is needed to call this endpoint. No key is needed. `airport_type` chooses the ranking: `from` for departure airports (the default) or `to` for arrival airports. Add `market_region`, a country code, to rank for one market. Each airport has the fields of the airport lookup plus `popularity_score`, `popularity_scopes` (the `locale` and `global` scores), `recent_score` and `last_seen_day`. Without a key `environment` is always `live`. ## Request ### Query parameters - `airport_type` (string, optional): `from` for departure airports or `to` for arrival airports. One of `from`, `to`. Default `from`. - `locale` (string, optional): The language of the names, read as for the airport lookup. Default `en`. - `limit` (integer, optional): How many airports, 1 to 10. Between 1 and 10. Default `5`. - `market_region` (string, optional): A country or region code (`DZ`, `FR`): ranks the airports for one market first. Also accepted as `market_country`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/flights/top-airports?airport_type=to&locale=en&limit=1' ``` ## Response ### 200: The airports and what the ranking used. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `airports` (array of object): The most searched airports, most searched first. Each record has the fields of an airport plus `payload_locale`, `popularity_score`, `popularity_scopes` (`locale` and `global`), `recent_score` and `last_seen_day`. - `ap` (string): The IATA code: use it as `origin` or `destination` in a search. - `airportname` (string): The airport name in the requested locale. - `cityname` (string): City and country in the requested locale. - `cityonly` (string): The city alone. - `cc` (string): ISO 3166-1 alpha-2 country code. - `country` (string): The country name. - `timezone` (string): The IANA time zone. - `utc` (string): The UTC offset at the time of the query. - `lat` (number): Latitude. - `lng` (number): Longitude. - `displayname` (string): A full label for a result list. - `shortdisplayname` (string): A short label for the selected value. - `loctype` (string): `ap` for an airport, `city` for a city. - `isMetroOnly` (boolean): `true` for a city code that stands for all the airports of a city (`PAR`): valid as an `origin` or `destination`. - `context` (object): What the ranking used. - `locale` (string): The language of the names, normalised. - `airport_type` (string): Which ranking was used. One of `from`, `to`. - `agency_id` (string, nullable): An internal ranking scope of FX-Port's own apps: `null` in your answers. - `market_region` (string, nullable): The `market_region` you sent, in upper case; `null` when you sent none. ```json { "environment": "live", "airports": [ { "ap": "JED", "airportname": "King Abdulaziz Intl", "cityname": "Jeddah, Saudi Arabia", "cityonly": "Jeddah", "cc": "SA", "country": "Saudi Arabia", "timezone": "Asia/Riyadh", "utc": "+03:00", "lat": 21.66982, "lng": 39.15052, "displayname": "King Abdulaziz Intl, Jeddah, Saudi Arabia, (JED)", "shortdisplayname": "Jeddah, Saudi Arabia (JED)", "loctype": "ap", "isMetroOnly": false, "payload_locale": "en", "popularity_score": 366.667, "popularity_scopes": { "locale": 25.54597518549043, "global": 97.28943021306868 }, "recent_score": 242, "last_seen_day": "2026-11-01" } ], "context": { "locale": "en", "airport_type": "to", "agency_id": null, "market_region": null } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 422 | `validation_error` | Input should be 'from' or 'to' | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Search airports](https://fx-port.com/docs/flights/airports.md#top-airports) --- # Search flights > Search flights across every compatible supplier and read the offers grouped by supplier. Endpoint: `POST /get_flights` Source: https://fx-port.com/docs/api/get-flights `POST /api/v1/get_flights` Requires an API key with **Read** permission; a **Read+Write** key works too. The route is a one-way (`origin`, `destination`, `departure_date`), a round trip (add `return_date`) or up to six flights in `segments` for an open-jaw or multi-city trip. Values a real client can send are read (`"2"` for a count, `premium economy` for a cabin, a space between airline codes), with a `coerced_value` warning when their meaning had to be guessed; only garbage is refused with a `400` and a stable code, before any supplier is called. **What comes back.** `suppliers[]` has one entry for each supplier that answered, with its own `searchId` and its offers in `results.offers[]`. Keep the `searchId` of the entry an offer is in together with the `id` of the offer: `price_flight` and `book_flight` need the pair. A supplier that fails does not fail the search: it is listed in `failures[]` with a stable `cause` (`supplier_busy` or `service_overloaded` when FX-Port lacked the capacity to serve it for a moment) and the status is still `200`. Only when no supplier answers is the status `503`: `all_suppliers_failed`, or `supplier_busy` or `service_overloaded` with `Retry-After` when every supplier was refused for lack of capacity. **Size and time.** An answer takes two to eight seconds, because it waits for the slowest supplier, and weighs 0.7 MB to 14 MB of JSON with no pagination. Send `Accept-Encoding: gzip`: a typical 695 KB answer is 26 KB on the wire. Identical searches that arrive at the same moment from the same agency, environment and currency are merged into one supplier fan-out; nothing is kept afterwards, and every caller still gets its own `requestId` and headers and a `searchId` that works with `price_flight`. **Streaming.** Add `stream=true` (or send `Accept: text/event-stream`) and the same search arrives as server-sent events: the first offers after about two and a half seconds, the rest as each supplier answers. The events are listed under *Streaming events*; the Streaming guide has complete client code. Keep at most 4 streams open at the same time for one API key. **Sandbox.** Two suppliers answer: `amadeus_aqc_dz_1` (routes that touch Algeria: hold, same-day void, flexible dates) and `duffel_1` (worldwide, instant ticketing). `CDG` to `ALG` returns fares with `pricing.acd.applied: true`, which need passports after booking. ## Request ### Query parameters - `stream` (boolean, optional): `true` (`1` and `yes` also work) returns the answer as server-sent events (`text/event-stream`) instead of one JSON document. `Accept: text/event-stream` has the same effect. The body of the request is the same in both modes; a request that is refused is answered with an ordinary JSON error before the stream starts. Keep at most 4 streams open at the same time per API key. Default `false`. ### Body Required, JSON. - `origin` (string, optional): Departure IATA code: three ASCII letters, an airport (`ALG`) or a city (`PAR`). Lower case is read as upper case. Several airports: `"ALG,ORN"` or `["ALG","ORN"]`, up to five. - `destination` (string, optional): Arrival IATA code. Must differ from `origin`. Several airports are written like for `origin`. - `departure_date` (string, optional): `YYYY-MM-DD`, a real date from yesterday up to 365 days ahead (`date_too_far` beyond). `return_date` and every segment follow the same limits. - `return_date` (string, optional): Makes the search a round trip. On or after `departure_date`. - `segments` (array of object, optional): 1 to 6 flights instead of `origin`, `destination` and `departure_date`: open-jaw and multi-city. At most 6 items. - `origin` (string, required): Departure IATA code (three letters). A segment takes one code, not a list. - `destination` (string, required): Arrival IATA code. Must differ from `origin`. - `departure_date` (string, required): Local departure date, `YYYY-MM-DD`, up to 365 days ahead. The dates must not go backwards. - `date` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: Local departure date, `YYYY-MM-DD`, up to 365 days ahead. - `departureDate` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: Local departure date, `YYYY-MM-DD`, up to 365 days ahead. - `departure` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: Local departure date, `YYYY-MM-DD`, up to 365 days ahead. - `url` (string, optional): A path-style search used by FX-Port's own site, for example `flights/ALG-CDG/2026-12-15/economy/1adults`. It wins over every route field. Prefer the fields above. At most 512 characters. - `passengers` (object, optional): How many travelers of each type. Every field is optional. At most 9 seated passengers (adults, seniors, young adults, children and seated infants together), and not more lap or seated infants than adults and seniors. Counts and ages are JSON integers: numeric text and integral floats (`"2"`, `2.0`) are read with a `coerced_value` warning, an empty text or `null` means not sent, and words, fractions, booleans, negatives and counts above 99 are refused. - `adults` (integer, optional): Adults (12 or older). The default is one adult when nothing is sent. Between 0 and 99. - `seniors` (integer, optional): Seniors. Between 0 and 99. - `young_adults` (integer, optional): Young adults (12 to 17). Between 0 and 99. - `children` (integer, optional): Children (2 to 11). Without `child_ages` every child is searched at age 11. Between 0 and 99. - `seated_infants` (integer, optional): Infants under 2 with their own seat. Between 0 and 99. - `infants` (integer, optional): Lap infants under 2: no seat, at most as many as adults and seniors. Between 0 and 99. - `child_ages` (array of integer, optional): The age of each child. The ages win over the counts: a disagreement is adjusted and reported in a `coerced_value` warning. A text list (`"7,9"`) or one bare number is read as a list, also with a warning. - `young_adult_ages` (array of integer, optional): The age of each young adult. - `seated_infant_ages` (array of integer, optional): The age of each seated infant. - `infant_ages` (array of integer, optional): The age of each lap infant. Without it every lap infant is searched at age 0, with no warning. - `young` (integer, deprecated, optional): Legacy name: use `young_adults`. The previous spelling of `young_adults`: Young adults (12 to 17). - `youngAdults` (integer, deprecated, optional): Legacy name: use `young_adults`. The previous spelling of `young_adults`: Young adults (12 to 17). - `toddlers` (integer, deprecated, optional): Legacy name: use `seated_infants`. The previous spelling of `seated_infants`: Infants under 2 with their own seat. - `seatedInfants` (integer, deprecated, optional): Legacy name: use `seated_infants`. The previous spelling of `seated_infants`: Infants under 2 with their own seat. - `childAges` (array, deprecated, optional): Legacy name: use `child_ages`. The previous spelling of `child_ages`: The age of each child. - `youngAdultAges` (array, deprecated, optional): Legacy name: use `young_adult_ages`. The previous spelling of `young_adult_ages`: The age of each young adult. - `seatedInfantAges` (array, deprecated, optional): Legacy name: use `seated_infant_ages`. The previous spelling of `seated_infant_ages`: The age of each seated infant. - `infantAges` (array, deprecated, optional): Legacy name: use `infant_ages`. The previous spelling of `infant_ages`: The age of each lap infant. - `cabin_class` (string, optional): The cabin. Case, spaces, dashes and underscores do not matter (`Premium Economy` is `premium_economy`); `premium`, `economy_class`, `business_class`, `first_class` and `premium_economy_class` are read as their cabin with a `coerced_value` warning, and an empty text means the default. Any other value (`coach`, `Y`) is `400 invalid_cabin_class`. Default `economy`. One of `economy`, `premium_economy`, `business`, `first`, `any`. Default `economy`. - `nonstop` (boolean, optional): `true` returns non-stop offers only. - `refundable` (boolean, optional): `true` asks the supplier for refundable fares. A hint, not a guarantee. - `checked_bags` (boolean, optional): `true` asks for fares with a checked bag included. Ignored by Duffel. - `max_price` (number, optional): A price ceiling. Not enforced in the sandbox. `0`, an empty text and `null` mean no ceiling: the field is ignored (`0` and an empty text add a `coerced_value` warning). Numeric text is read as a number with a warning; a negative or non-numeric value is `400 invalid_field_type`. - `included_airlines` (object, optional): Up to 20 airline codes: only these carriers. - `excluded_airlines` (object, optional): Up to 20 airline codes to remove. - `preferred_airlines` (object, optional): Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `flexible` (boolean, optional): Also returns the date matrix (`flexPrices`) from the suppliers that support it. - `flexible_only` (boolean, optional): The matrix without offers. `POST /flights/flexible_search` sends it for you. - `from_cc` (string, optional): ISO country code of the origin: exactly two letters. Used to choose suppliers. - `to_cc` (string, optional): ISO country code of the destination: exactly two letters. Used to choose suppliers. - `countries` (array of string, optional): Up to 10 country codes of the route, to override the ones FX-Port infers. At most 10 items. - `filters` (object, optional): The airline lists in their older nested form. Only the three airline lists are read from it; any other key is ignored. Prefer the top-level fields. - `included_airlines` (object, optional): Up to 20 airline codes: only these carriers. - `excluded_airlines` (object, optional): Up to 20 airline codes to remove. - `preferred_airlines` (object, optional): Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `airlineInclude` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airline_include` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includeAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includedAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `included_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airlineExclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airline_exclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludeAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludedAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excluded_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `preferredAirlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `supplier_id` (string, optional): Restrict the search to one supplier id (a comma separated list is accepted). Strict: an unknown or disabled id is `400 invalid_supplier_selection`. - `supplier_ids` (array of string, optional): Restrict the search to this set. Strict, up to 10. At most 10 items. - `preferred_suppliers` (array of string, optional): A hint: ids that are not usable are ignored and the automatic selection applies. - `max_suppliers` (integer, optional): Keep only the first N selected suppliers, in priority order. `"3"` and `3.0` are read as 3 with a `coerced_value` warning; an empty text means no limit. - `loyalty_program_accounts` (array of object, optional): Up to 10 frequent-flyer accounts. At most 10 items. - `airline_iata_code` (string, required): IATA code of the airline of the programme. - `account_number` (string, required): The frequent-flyer number. - `first_name` (string, optional): Given name of the member, matched with the passenger at booking. - `last_name` (string, optional): Family name of the member. - `airlineIataCode` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `airline_code` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `airlineCode` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `programOwner` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `program_owner` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `accountNumber` (string, deprecated, optional): Legacy name: use `account_number`. The previous spelling of `account_number`: The frequent-flyer number. - `membershipNumber` (string, deprecated, optional): Legacy name: use `account_number`. The previous spelling of `account_number`: The frequent-flyer number. - `membership_number` (string, deprecated, optional): Legacy name: use `account_number`. The previous spelling of `account_number`: The frequent-flyer number. - `firstName` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name of the member, matched with the passenger at booking. - `givenName` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name of the member, matched with the passenger at booking. - `given_name` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name of the member, matched with the passenger at booking. - `lastName` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `familyName` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `family_name` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `surname` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `acd_exempt_company_id` (string, optional): For agencies that settle in DZD and have an ACD-exempt company: its id, to price the fares without ACD. - `agent_id` (string, optional): Your own id of the agent who searches; stored with the search and the booking. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `departureDate` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: `YYYY-MM-DD`, a real date from yesterday up to 365 days ahead (`date_too_far` beyond). - `returnDate` (string, deprecated, optional): Legacy name: use `return_date`. The previous spelling of `return_date`: Makes the search a round trip. - `cabinClass` (string, deprecated, optional): Legacy name: use `cabin_class`. The previous spelling of `cabin_class`: The cabin. - `travelers` (object, deprecated, optional): Legacy name: use `passengers`. The previous spelling of `passengers`: `passengers`. - `direct` (boolean, deprecated, optional): Legacy name: use `nonstop`. The previous spelling of `nonstop`: `true` returns non-stop offers only. - `checkedBags` (boolean, deprecated, optional): Legacy name: use `checked_bags`. The previous spelling of `checked_bags`: `true` asks for fares with a checked bag included. - `maxPrice` (number, deprecated, optional): Legacy name: use `max_price`. The previous spelling of `max_price`: A price ceiling. - `flexibleOnly` (boolean, deprecated, optional): Legacy name: use `flexible_only`. The previous spelling of `flexible_only`: The matrix without offers. - `fromCc` (string, deprecated, optional): Legacy name: use `from_cc`. The previous spelling of `from_cc`: ISO country code of the origin: exactly two letters. - `toCc` (string, deprecated, optional): Legacy name: use `to_cc`. The previous spelling of `to_cc`: ISO country code of the destination: exactly two letters. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Restrict the search to one supplier id (a comma separated list is accepted). - `supplierIds` (array, deprecated, optional): Legacy name: use `supplier_ids`. The previous spelling of `supplier_ids`: Restrict the search to this set. - `preferredSuppliers` (array, deprecated, optional): Legacy name: use `preferred_suppliers`. The previous spelling of `preferred_suppliers`: A hint: ids that are not usable are ignored and the automatic selection applies. - `maxSuppliers` (integer, deprecated, optional): Legacy name: use `max_suppliers`. The previous spelling of `max_suppliers`: Keep only the first N selected suppliers, in priority order. - `airlineInclude` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airline_include` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includeAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includedAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `included_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airlineExclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airline_exclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludeAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludedAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excluded_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `preferredAirlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `agentId` (string, deprecated, optional): Legacy name: use `agent_id`. The previous spelling of `agent_id`: Your own id of the agent who searches; stored with the search and the booking. - `acdExemptCompanyId` (string, deprecated, optional): Legacy name: use `acd_exempt_company_id`. The previous spelling of `acd_exempt_company_id`: For agencies that settle in DZD and have an ACD-exempt company: its id, to price the fares without ACD. - `loyaltyProgramAccounts` (array, deprecated, optional): Legacy name: use `loyalty_program_accounts`. The previous spelling of `loyalty_program_accounts`: Up to 10 frequent-flyer accounts. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/get_flights \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` ## Response ### 200: The offers grouped by supplier (the JSON mode; with `stream=true` the same data arrives as the events below). A supplier that failed is in `failures` and the status is still `200`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` on every `200`, even if some suppliers failed: read `suppliers[n].success` and `failures`. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this search, 8 characters, for support. - `searchType` (string, deprecated): Legacy name: use `search_type`. The type of the search, derived from your route. One of `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW`, `MULTI_CITY`. - `search_type` (string): The type of the search, derived from your route. The snake_case twin of `searchType`, with the same value: use this name. One of `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW`, `MULTI_CITY`. - `flexible` (boolean): Whether the date matrix was requested. - `flexibleOnly` (boolean): Only present (and `true`) for a flexible-only search. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent by the whole search. - `processing_time` (number): Seconds spent by the whole search. The snake_case twin of `processingTime`, with the same value: use this name. - `suppliers` (array of object): One entry for each supplier that answered. - `success` (boolean): Whether this supplier returned usable results. - `searchId` (string, deprecated): Legacy name: use `search_id`. **Save this.** `fs_` (Amadeus Algeria) or `fs_dfx_` (Duffel). Send it as `search_id` to `price_flight` and `book_flight`. Valid for 24 hours, but a supplier withdraws offers earlier. - `search_id` (string): **Save this.** `fs_` (Amadeus Algeria) or `fs_dfx_` (Duffel). Send it as `search_id` to `price_flight` and `book_flight`. Valid for 24 hours, but a supplier withdraws offers earlier. The snake_case twin of `searchId`, with the same value: use this name. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. What the supplier can do in general: hold. Read it again on the priced offer. - `support_hold` (boolean): What the supplier can do in general: hold. Read it again on the priced offer. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. What the supplier can do in general: same-day void. - `support_void` (boolean): What the supplier can do in general: same-day void. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. ISO 8601 UTC end of the same-day void window, `null` when the supplier cannot void. - `void_deadline` (string, nullable): ISO 8601 UTC end of the same-day void window, `null` when the supplier cannot void. The snake_case twin of `voidDeadline`, with the same value: use this name. - `filtersApplied` (object, deprecated): Legacy name: use `filters_applied`. The filters you asked for, echoed. - `filters_applied` (object): The filters you asked for, echoed. The snake_case twin of `filtersApplied`, with the same value: use this name. - `warnings` (array of object): Notes of the supplier. - `meta` (object): Who answered and how fast. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `technologyType` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `supplierName` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `supplierName`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds this supplier took. - `processing_time` (number): Seconds this supplier took. The snake_case twin of `processingTime`, with the same value: use this name. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `results` (object): The offers of one supplier. - `totalOffers` (integer): Number of offers of this result set. Always equals the length of `offers`: there is no pagination. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `offers` (array of FlightOffer): The offers, in the order the supplier ranks them. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's (Duffel: USD to DZD, 250.0). Every amount is already converted. - `exchange_rate` (number): Only when the supplier's currency is not your agency's (Duffel: USD to DZD, 250.0). Every amount is already converted. The snake_case twin of `exchangeRate`, with the same value: use this name. - `flexPrices` (object): The date matrix of a flexible search: one cheapest fare for each candidate date. An entry has no offer id: to book a date, search for it. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of the prices of `matrix`. - `matrix` (array of object): One entry for each date (one-way: 7 dates) or date pair (round trip, open-jaw: up to 49). - `departure` (string): Candidate departure date, `YYYY-MM-DD`. - `return` (string): Candidate return date. Only for a round trip or an open-jaw. - `status` (string): `available` when the supplier has a fare for this date or pair. One of `available`, `no_flights_available`. - `finalPrice` (number, nullable, deprecated): Legacy name: use `final_price`. What the traveler pays, in `agencyCurrency`. - `final_price` (number, nullable): What the traveler pays, in `agencyCurrency`. The snake_case twin of `finalPrice`, with the same value: use this name. - `basePrice` (number, nullable, deprecated): Legacy name: use `base_price`. The airline's base fare. - `base_price` (number, nullable): The airline's base fare. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, nullable, deprecated): Legacy name: use `taxes_and_fees`. Taxes and fees. - `taxes_and_fees` (number, nullable): Taxes and fees. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, nullable, deprecated): Legacy name: use `agency_commission`. Your commission. - `agency_commission` (number, nullable): Your commission. The snake_case twin of `agencyCommission`, with the same value: use this name. - `b2bPrice` (number, nullable, deprecated): Legacy name: use `b2b_price`. The amount debited from your balance. - `b2b_price` (number, nullable): The amount debited from your balance. The snake_case twin of `b2bPrice`, with the same value: use this name. - `agencyCurrency` (string): Currency of the prices of the entry. - `flight` (object): The cheapest flight behind the price of a date. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): Flight number **with** the carrier code (`IB1378`), unlike the offers. - `operatingCarrier` (string): IATA code of the operating airline. - `validatingAirline` (string): IATA code of the validating airline, as a text (a list of objects in offers). - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `departureTime` (string): `HH:MM`. - `arrivalTime` (string): `HH:MM`. - `departureAirport` (string): IATA code. - `arrivalAirport` (string): IATA code. - `cabinClass` (string): Cabin of the fare. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `includedCheckedBags` (object): Checked baggage included. - `quantity` (integer): Number of checked bags included. - `summary` (object): Counters of the search. - `totalSuppliers` (integer): The suppliers that answered (the same as `successfulSuppliers`). In the stream: the suppliers that were called. - `successfulSuppliers` (integer): Suppliers that returned results. - `failedSuppliers` (integer): Suppliers listed in `failures`. - `totalOffers` (integer): Offers of all the suppliers together. - `failures` (array of object): Only when a supplier failed. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier that failed. - `supplier_id` (string): The supplier that failed. The snake_case twin of `supplierId`, with the same value: use this name. - `error` (string): A fixed sentence about the cause, never the supplier's own text. - `cause` (string): Why the supplier failed: a stable code to branch on. `supplier_temporarily_unavailable`: the supplier is skipped for a short while after repeated failures. `supplier_busy`: FX-Port's capacity towards the supplier is full for a moment, the supplier was not called, retry in a few seconds. `service_overloaded`: FX-Port was too busy to prepare the results of the supplier, retry in a few seconds. One of `supplier_timeout`, `supplier_api_error`, `supplier_auth_error`, `supplier_error`, `supplier_temporarily_unavailable`, `supplier_busy`, `service_overloaded`. ```json { "environment": "sandbox", "success": true, "requestId": "54139f32", "searchType": "ONE_WAY", "flexible": false, "processingTime": 2.243, "suppliers": [ { "success": true, "searchId": "fs_e1853430-16e2-4bcb-8f11-4f7bfbc33602", "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "filtersApplied": {}, "warnings": [], "meta": { "supplierId": "amadeus_aqc_dz_1", "technologyType": "amadeus_aqc", "supplierName": "Amadeus Algeria", "processingTime": 2.174, "supplierCurrency": "DZD", "agencyCurrency": "DZD" }, "results": { "totalOffers": 66, "supplierCurrency": "DZD", "agencyCurrency": "DZD", "offers": [ { "id": "fx_offer_b1c2a850", "type": "flight_offer", "supplier": { "id": "amadeus_aqc_dz_1", "name": "Amadeus Algeria", "originalId": "6" }, "bookableSeats": 9, "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "filtersApplied": {}, "warnings": [], "instantTicketingRequired": false, "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 18520, "taxesAndFees": 11306.17, "agencyCommission": 29.83, "finalPrice": 29856, "b2bPrice": 29826.17, "acd": { "applied": false }, "additionalServices": [] }, "itinerary": [ { "direction": "outbound", "duration": "PT2H30M", "stops": 0, "segments": [ { "id": "27", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "4", "datetime": "2026-12-15T07:15:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "CDG", "airportName": "Paris Charles de Gaulle Airport", "cityName": "Paris", "countryCode": "FR", "terminal": "2D", "datetime": "2026-12-15T09:45:00", "timeZone": "Europe/Paris" }, "flight": { "carrierCode": "AH", "carrierName": "AIR ALGERIE", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "flightNumber": "1002", "aircraftCode": "7M8", "aircraftName": "BOEING 737 MAX 8", "operatingCarrier": "AH", "operatingCarrierName": "AIR ALGERIE" }, "duration": "PT2H30M", "cabin": "ECONOMY", "bookingClass": "Q", "fareBasis": "QDZOO", "brandedFare": "STANDARD", "brandedFareLabel": "" } ], "conditions": { "refundBeforeDeparture": { "allowed": false }, "changeBeforeDeparture": { "allowed": true } } } ], "travelerPricing": [ { "travelerId": "1", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "base": 18520, "total": 29856, "b2bPrice": 29826.17 }, "fareDetailsBySegment": [ { "segmentId": "27", "cabin": "ECONOMY", "fareBasis": "QDZOO", "brandedFare": "STANDARD", "brandedFareLabel": "", "bookingClass": "Q", "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [] } ] } ], "conditions": { "refundBeforeDeparture": { "allowed": false }, "changeBeforeDeparture": { "allowed": true } }, "fareRules": { "exchange": { "allowed": true }, "refund": { "allowed": false }, "revalidation": { "allowed": false }, "supplierCurrency": "DZD", "agencyCurrency": "DZD" }, "validatingAirline": [ { "code": "AH", "name": "AIR ALGERIE" } ], "additionalServices": [] } ] } }, { "success": true, "searchId": "fs_dfx_a3aeaa36-74ad-424b-b88d-e6bdc9a4ba94", "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "meta": { "supplierId": "duffel_1", "technologyType": "duffel", "supplierName": "Duffel", "processingTime": 1.34, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "results": { "totalOffers": 92, "supplierCurrency": "USD", "agencyCurrency": "DZD", "offers": [ { "id": "fx_offer_9ab6d940", "type": "flight_offer", "supplier": { "id": "duffel_1", "name": "Duffel", "originalId": "off_034q6JKdiQDZ8HIDMFus64" }, "bookableSeats": null, "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "instantTicketingRequired": false, "lastTicketingDate": "2026-11-01", "lastTicketingDateTime": "2026-11-01T15:52:40.830072Z", "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16385, "taxesAndFees": 5226.2, "agencyCommission": 21.62, "finalPrice": 21632.82, "b2bPrice": 21611.2, "additionalServices": [] }, "itinerary": [ { "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": [ { "id": "seg_0000BC0a5iUChSV6Qrz0Co", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "2", "datetime": "2026-12-15T10:50:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "CDG", "airportName": "Paris Charles de Gaulle Airport", "cityName": "Paris", "countryCode": "FR", "terminal": "1", "datetime": "2026-12-15T13:14:00", "timeZone": "Europe/Paris" }, "flight": { "carrierCode": "ZZ", "carrierName": "Duffel Airways", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/ZZ.svg", "flightNumber": "7426", "aircraftCode": null, "aircraftName": null, "operatingCarrier": "ZZ", "operatingCarrierName": "Duffel Airways" }, "duration": "PT2H24M", "cabin": "ECONOMY", "bookingClass": null, "fareBasis": "Y20LGTN2", "brandedFare": "BASIC", "brandedFareLabel": "Basic" } ], "conditions": { "changeBeforeDeparture": { "allowed": false }, "refundBeforeDeparture": { "allowed": false } } } ], "travelerPricing": [ { "travelerId": "pas_0000BC0a5iH5UE0bmBqWkl", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "base": 16385, "total": 21632.82, "b2bPrice": 21611.2 }, "fareDetailsBySegment": [ { "segmentId": "seg_0000BC0a5iUChSV6Qrz0Co", "cabin": "ECONOMY", "fareBasis": "Y20LGTN2", "brandedFare": "BASIC", "brandedFareLabel": "Basic", "bookingClass": null, "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [ { "description": "WIFI", "isChargeable": true, "amenityType": "WIFI", "amenityProvider": { "name": "BrandedFare" } }, { "description": "POWER SUPPLY", "isChargeable": false, "amenityType": "ENTERTAINMENT", "amenityProvider": { "name": "BrandedFare" } } ] } ] } ], "conditions": { "refundBeforeDeparture": { "allowed": false }, "changeBeforeDeparture": { "allowed": false } }, "fareRules": { "exchange": { "allowed": false }, "refund": { "allowed": false }, "revalidation": { "allowed": false }, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "validatingAirline": [ { "code": "ZZ", "name": "Duffel Airways" } ] } ] }, "exchangeRate": 250 } ], "summary": { "totalSuppliers": 2, "successfulSuppliers": 2, "failedSuppliers": 0, "totalOffers": 158 } } ``` ## Streaming events With `stream=true` the response is `text/event-stream`: a sequence of events, each an `event:` line and a `data:` line with a JSON payload. ### `start` First, at once (`id` 0). The request id, the suppliers that will answer, and `environment` as the first key. - `environment` (string): `sandbox` or `live`. Only this event carries it, as its first key. - `type` (string): Always `start`. Always `start`. - `requestId` (string): The id of this search, 8 characters. - `searchType` (string): The raw classification of the route: `ONE WAY`, `ROUNDTRIP`, `MULTIPLE CITY` or `OPEN`. Not the vocabulary of the JSON mode: read `search_type`. - `search_type` (string): The type of the search in the vocabulary of the JSON mode. One of `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW`, `MULTI_CITY`. - `flexible` (boolean): Whether the date matrix was requested. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. - `expectedSuppliers` (array of string): The suppliers called for this search: the compatible ones. ```text event: start data: {"environment":"sandbox","type":"start","requestId":"d36b6918","searchType":"ONE WAY","flexible":false,"timestamp":"2026-11-01T15:22:57.762071","expectedSuppliers":["amadeus_aqc_dz_1","duffel_1"],"search_type":"ONE_WAY"} ``` ### `supplier_start` A supplier answered with **more than 50 offers**: the result set without its offers (`results.chunked`, `chunkSize`, `totalChunks`). Its offers follow in `offers_chunk` events. - `type` (string): `supplier_result`: the whole result set (50 offers or fewer). `supplier_start`: the result set without its offers (more than 50): they follow in `offers_chunk` events. One of `supplier_result`, `supplier_start`. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. - `supplier_id` (string): The supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `success` (boolean): Always `true` here. - `data` (object): A result set: the answer of one supplier, or one later batch of it. - `success` (boolean): Whether this supplier returned usable results. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this search. - `request_id` (string): The id of this search. The snake_case twin of `requestId`, with the same value: use this name. - `searchId` (string, deprecated): Legacy name: use `search_id`. The `searchId` of **this result set**. Price and book an offer with the `searchId` of the result set it arrived in. - `search_id` (string): The `searchId` of **this result set**. Price and book an offer with the `searchId` of the result set it arrived in. The snake_case twin of `searchId`, with the same value: use this name. - `searchType` (string, deprecated): Legacy name: use `search_type`. `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW` or `MULTI_CITY`. - `search_type` (string): `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW` or `MULTI_CITY`. The snake_case twin of `searchType`, with the same value: use this name. - `flexible` (boolean): Only when the search asked for the date matrix: `true`. - `flexibleOnly` (boolean): Only for a flexible-only search: `true`. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds this supplier took. - `processing_time` (number): Seconds this supplier took. The snake_case twin of `processingTime`, with the same value: use this name. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `results` (object): The offers of a result set. - `totalOffers` (integer): The number of offers of **this result set**. For a chunked set it is the total of the chunks. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `offers` (array of FlightOffer): The offers: all of them for `supplier_result`; empty for `supplier_start`, whose offers follow in `offers_chunk` events. - `chunked` (boolean): Only on `supplier_start`: `true`. - `chunkSize` (integer): Only on `supplier_start`: offers in a chunk (50). - `totalChunks` (integer): Only on `supplier_start`: how many `offers_chunk` events follow. - `meta` (object): The supplier block of a result set of the stream. - `filtersApplied` (object, deprecated): Legacy name: use `filters_applied`. The filters you asked for, echoed. - `filters_applied` (object): The filters you asked for, echoed. The snake_case twin of `filtersApplied`, with the same value: use this name. - `warnings` (array of object): Notes of the supplier. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `technologyType` (string): `amadeus_aqc` or `duffel`. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. Whether the supplier can hold. - `support_hold` (boolean): Whether the supplier can hold. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. Whether the supplier can void the same day. - `support_void` (boolean): Whether the supplier can void the same day. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. End of the same-day void window. - `void_deadline` (string, nullable): End of the same-day void window. The snake_case twin of `voidDeadline`, with the same value: use this name. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's. - `exchange_rate` (number): Only when the supplier's currency is not your agency's. The snake_case twin of `exchangeRate`, with the same value: use this name. - `flexPrices` (object): The date matrix of a flexible search: one cheapest fare for each candidate date. An entry has no offer id: to book a date, search for it. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of the prices of `matrix`. - `matrix` (array of object): One entry for each date (one-way: 7 dates) or date pair (round trip, open-jaw: up to 49). - `departure` (string): Candidate departure date, `YYYY-MM-DD`. - `return` (string): Candidate return date. Only for a round trip or an open-jaw. - `status` (string): `available` when the supplier has a fare for this date or pair. One of `available`, `no_flights_available`. - `finalPrice` (number, nullable, deprecated): Legacy name: use `final_price`. What the traveler pays, in `agencyCurrency`. - `final_price` (number, nullable): What the traveler pays, in `agencyCurrency`. The snake_case twin of `finalPrice`, with the same value: use this name. - `basePrice` (number, nullable, deprecated): Legacy name: use `base_price`. The airline's base fare. - `base_price` (number, nullable): The airline's base fare. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, nullable, deprecated): Legacy name: use `taxes_and_fees`. Taxes and fees. - `taxes_and_fees` (number, nullable): Taxes and fees. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, nullable, deprecated): Legacy name: use `agency_commission`. Your commission. - `agency_commission` (number, nullable): Your commission. The snake_case twin of `agencyCommission`, with the same value: use this name. - `b2bPrice` (number, nullable, deprecated): Legacy name: use `b2b_price`. The amount debited from your balance. - `b2b_price` (number, nullable): The amount debited from your balance. The snake_case twin of `b2bPrice`, with the same value: use this name. - `agencyCurrency` (string): Currency of the prices of the entry. - `flight` (object): The cheapest flight behind the price of a date. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): Flight number **with** the carrier code (`IB1378`), unlike the offers. - `operatingCarrier` (string): IATA code of the operating airline. - `validatingAirline` (string): IATA code of the validating airline, as a text (a list of objects in offers). - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `departureTime` (string): `HH:MM`. - `arrivalTime` (string): `HH:MM`. - `departureAirport` (string): IATA code. - `arrivalAirport` (string): IATA code. - `cabinClass` (string): Cabin of the fare. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `includedCheckedBags` (object): Checked baggage included. - `quantity` (integer): Number of checked bags included. - `complete` (boolean): Only on `supplier_result`: `false` means that **another batch of this supplier follows**, under another `searchId`. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds this supplier took. - `processing_time` (number): Seconds this supplier took. The snake_case twin of `processingTime`, with the same value: use this name. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: supplier_start data: {"type":"supplier_start","supplierId":"duffel_1","success":true,"data":{"success":true,"requestId":"d36b6918","searchId":"fs_dfx_59ce98b7-ce5a-4911-abb8-b43d4786e6c9","searchType":"ONE_WAY","processingTime":1.381,"supplierCurrency":"USD","agencyCurrency":"DZD","results":{"totalOffers":92,"supplierCurrency":"USD","agencyCurrency":"DZD","offers":[],"chunked":true,"chunkSize":50,"totalChunks":2},"meta":{"filtersApplied":{},"warnings":[],"supplierId":"duffel_1","technologyType":"duffel","supportHold":false,"supportVoid":false,"voidDeadline":null},"exchangeRate":250},"processingTime":1.381,"timestamp":"2026-11-01T15:22:59.161692"} ``` ### `offers_chunk` Up to 50 offers of the result set opened by the last `supplier_start` of the same supplier. - `type` (string): Always `offers_chunk`. Always `offers_chunk`. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. Add the offers to the result set opened by the last `supplier_start` of this supplier. - `supplier_id` (string): The supplier. Add the offers to the result set opened by the last `supplier_start` of this supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `chunkNumber` (integer): The number of this chunk, from 1. - `totalChunks` (integer): How many chunks the result set has. - `offersInChunk` (integer): Offers in this chunk (50 at most). - `offers` (array of FlightOffer): The offers of this chunk. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: offers_chunk data: {"type":"offers_chunk","supplierId":"duffel_1","chunkNumber":1,"totalChunks":2,"offersInChunk":50,"offers":[{"id":"fx_offer_9ab6d940","type":"flight_offer","supplier":{"id":"duffel_1","name":"Duffel","originalId":"off_034q6JKdiQDZ8HIDMFus64"},"bookableSeats":null,"supportHold":false,"supportVoid":false,"voidDeadline":null,"filtersApplied":{},"warnings":[],"instantTicketingRequired":false,"lastTicketingDate":"2026-11-01","lastTicketingDateTime":"2026-11-01T15:52:40.830072Z","pricing":{"supplierCurrency":"USD","agencyCurrency":"DZD","basePrice":16385,"taxesAndFees":5226.2,"agencyCommission":21.62,"finalPrice":21632.82,"b2bPrice":21611.2,"additionalServices":[]},"itinerary":[{"direction":"outbound","duration":"PT2H24M","stops":0,"segments":[{"id":"seg_0000BC0a5iUChSV6Qrz0Co","departure":{"airportCode":"ALG","airportName":"Houari Boumediene Airport","cityName":"Algiers","countryCode":"DZ","terminal":"2","datetime":"2026-12-15T10:50:00","timeZone":"Africa/Algiers"},"arrival":{"airportCode":"CDG","airportName":"Paris Charles de Gaulle Airport","cityName":"Paris","countryCode":"FR","terminal":"1","datetime":"2026-12-15T13:14:00","timeZone":"Europe/Paris"},"flight":{"carrierCode":"ZZ","carrierName":"Duffel Airways","carrierLogoUrl":"https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/ZZ.svg","flightNumber":"7426","aircraftCode":null,"aircraftName":null,"operatingCarrier":"ZZ","operatingCarrierName":"Duffel Airways"},"duration":"PT2H24M","cabin":"ECONOMY","bookingClass":null,"fareBasis":"Y20LGTN2","brandedFare":"BASIC","brandedFareLabel":"Basic"}],"conditions":{"changeBeforeDeparture":{"allowed":false},"refundBeforeDeparture":{"allowed":false}}}],"travelerPricing":[{"travelerId":"pas_0000BC0a5iH5UE0bmBqWkl","travelerType":"ADULT","fareOption":"STANDARD","price":{"supplierCurrency":"USD","agencyCurrency":"DZD","base":16385,"total":21632.82,"b2bPrice":21611.2},"fareDetailsBySegment":[{"segmentId":"seg_0000BC0a5iUChSV6Qrz0Co","cabin":"ECONOMY","fareBasis":"Y20LGTN2","brandedFare":"BASIC","brandedFareLabel":"Basic","bookingClass":null,"includedCheckedBags":{"quantity":1},"includedCabinBags":{"quantity":1},"amenities":[{"description":"WIFI","isChargeable":true,"amenityType":"WIFI","amenityProvider":{"name":"BrandedFare"}},{"description":"POWER SUPPLY","isChargeable":false,"amenityType":"ENTERTAINMENT","amenityProvider":{"name":"BrandedFare"}}]}]}],"conditions":{"refundBeforeDeparture":{"allowed":false},"changeBeforeDeparture":{"allowed":false}},"fareRules":{"exchange":{"allowed":false},"refund":{"allowed":false},"revalidation":{"allowed":false},"supplierCurrency":"USD","agencyCurrency":"DZD"},"validatingAirline":[{"code":"ZZ","name":"Duffel Airways"}]}],"timestamp":"2026-11-01T15:22:59.161771"} ``` ### `flex_prices` The date matrix of a chunked result set, when `flexible: true`. Not seen in the sandbox: treat it as optional. - `type` (string): Always `flex_prices`. Always `flex_prices`. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. - `supplier_id` (string): The supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `flexPrices` (object): The date matrix of a flexible search: one cheapest fare for each candidate date. An entry has no offer id: to book a date, search for it. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of the prices of `matrix`. - `matrix` (array of object): One entry for each date (one-way: 7 dates) or date pair (round trip, open-jaw: up to 49). - `departure` (string): Candidate departure date, `YYYY-MM-DD`. - `return` (string): Candidate return date. Only for a round trip or an open-jaw. - `status` (string): `available` when the supplier has a fare for this date or pair. One of `available`, `no_flights_available`. - `finalPrice` (number, nullable, deprecated): Legacy name: use `final_price`. What the traveler pays, in `agencyCurrency`. - `final_price` (number, nullable): What the traveler pays, in `agencyCurrency`. The snake_case twin of `finalPrice`, with the same value: use this name. - `basePrice` (number, nullable, deprecated): Legacy name: use `base_price`. The airline's base fare. - `base_price` (number, nullable): The airline's base fare. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, nullable, deprecated): Legacy name: use `taxes_and_fees`. Taxes and fees. - `taxes_and_fees` (number, nullable): Taxes and fees. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, nullable, deprecated): Legacy name: use `agency_commission`. Your commission. - `agency_commission` (number, nullable): Your commission. The snake_case twin of `agencyCommission`, with the same value: use this name. - `b2bPrice` (number, nullable, deprecated): Legacy name: use `b2b_price`. The amount debited from your balance. - `b2b_price` (number, nullable): The amount debited from your balance. The snake_case twin of `b2bPrice`, with the same value: use this name. - `agencyCurrency` (string): Currency of the prices of the entry. - `flight` (object): The cheapest flight behind the price of a date. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): Flight number **with** the carrier code (`IB1378`), unlike the offers. - `operatingCarrier` (string): IATA code of the operating airline. - `validatingAirline` (string): IATA code of the validating airline, as a text (a list of objects in offers). - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `departureTime` (string): `HH:MM`. - `arrivalTime` (string): `HH:MM`. - `departureAirport` (string): IATA code. - `arrivalAirport` (string): IATA code. - `cabinClass` (string): Cabin of the fare. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `includedCheckedBags` (object): Checked baggage included. - `quantity` (integer): Number of checked bags included. - `count` (integer): Entries of the matrix. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ### `supplier_complete` After the last chunk of a result set. `complete: false` means that another batch of this supplier follows. - `type` (string): Always `supplier_complete`. Always `supplier_complete`. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. - `supplier_id` (string): The supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `totalOffers` (integer): Offers of **that result set**. The total of the search is in `summary`. - `hasFlexPrices` (boolean): Whether the result set carries a date matrix. - `flexPricesCount` (integer): Entries of the matrix. - `complete` (boolean): `false`: another batch of this supplier follows. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: supplier_complete data: {"type":"supplier_complete","supplierId":"amadeus_aqc_dz_1","totalOffers":68,"hasFlexPrices":false,"flexPricesCount":0,"complete":false,"timestamp":"2026-11-01T15:22:59.819079"} ``` ### `supplier_result` A supplier, or a later batch of it, answered with **50 offers or fewer**: the whole result set in `data.results.offers`. `complete: false` means that another batch follows. - `type` (string): `supplier_result`: the whole result set (50 offers or fewer). `supplier_start`: the result set without its offers (more than 50): they follow in `offers_chunk` events. One of `supplier_result`, `supplier_start`. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. - `supplier_id` (string): The supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `success` (boolean): Always `true` here. - `data` (object): A result set: the answer of one supplier, or one later batch of it. - `success` (boolean): Whether this supplier returned usable results. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this search. - `request_id` (string): The id of this search. The snake_case twin of `requestId`, with the same value: use this name. - `searchId` (string, deprecated): Legacy name: use `search_id`. The `searchId` of **this result set**. Price and book an offer with the `searchId` of the result set it arrived in. - `search_id` (string): The `searchId` of **this result set**. Price and book an offer with the `searchId` of the result set it arrived in. The snake_case twin of `searchId`, with the same value: use this name. - `searchType` (string, deprecated): Legacy name: use `search_type`. `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW` or `MULTI_CITY`. - `search_type` (string): `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW` or `MULTI_CITY`. The snake_case twin of `searchType`, with the same value: use this name. - `flexible` (boolean): Only when the search asked for the date matrix: `true`. - `flexibleOnly` (boolean): Only for a flexible-only search: `true`. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds this supplier took. - `processing_time` (number): Seconds this supplier took. The snake_case twin of `processingTime`, with the same value: use this name. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `results` (object): The offers of a result set. - `totalOffers` (integer): The number of offers of **this result set**. For a chunked set it is the total of the chunks. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `offers` (array of FlightOffer): The offers: all of them for `supplier_result`; empty for `supplier_start`, whose offers follow in `offers_chunk` events. - `chunked` (boolean): Only on `supplier_start`: `true`. - `chunkSize` (integer): Only on `supplier_start`: offers in a chunk (50). - `totalChunks` (integer): Only on `supplier_start`: how many `offers_chunk` events follow. - `meta` (object): The supplier block of a result set of the stream. - `filtersApplied` (object, deprecated): Legacy name: use `filters_applied`. The filters you asked for, echoed. - `filters_applied` (object): The filters you asked for, echoed. The snake_case twin of `filtersApplied`, with the same value: use this name. - `warnings` (array of object): Notes of the supplier. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `technologyType` (string): `amadeus_aqc` or `duffel`. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. Whether the supplier can hold. - `support_hold` (boolean): Whether the supplier can hold. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. Whether the supplier can void the same day. - `support_void` (boolean): Whether the supplier can void the same day. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. End of the same-day void window. - `void_deadline` (string, nullable): End of the same-day void window. The snake_case twin of `voidDeadline`, with the same value: use this name. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's. - `exchange_rate` (number): Only when the supplier's currency is not your agency's. The snake_case twin of `exchangeRate`, with the same value: use this name. - `flexPrices` (object): The date matrix of a flexible search: one cheapest fare for each candidate date. An entry has no offer id: to book a date, search for it. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of the prices of `matrix`. - `matrix` (array of object): One entry for each date (one-way: 7 dates) or date pair (round trip, open-jaw: up to 49). - `departure` (string): Candidate departure date, `YYYY-MM-DD`. - `return` (string): Candidate return date. Only for a round trip or an open-jaw. - `status` (string): `available` when the supplier has a fare for this date or pair. One of `available`, `no_flights_available`. - `finalPrice` (number, nullable, deprecated): Legacy name: use `final_price`. What the traveler pays, in `agencyCurrency`. - `final_price` (number, nullable): What the traveler pays, in `agencyCurrency`. The snake_case twin of `finalPrice`, with the same value: use this name. - `basePrice` (number, nullable, deprecated): Legacy name: use `base_price`. The airline's base fare. - `base_price` (number, nullable): The airline's base fare. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, nullable, deprecated): Legacy name: use `taxes_and_fees`. Taxes and fees. - `taxes_and_fees` (number, nullable): Taxes and fees. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, nullable, deprecated): Legacy name: use `agency_commission`. Your commission. - `agency_commission` (number, nullable): Your commission. The snake_case twin of `agencyCommission`, with the same value: use this name. - `b2bPrice` (number, nullable, deprecated): Legacy name: use `b2b_price`. The amount debited from your balance. - `b2b_price` (number, nullable): The amount debited from your balance. The snake_case twin of `b2bPrice`, with the same value: use this name. - `agencyCurrency` (string): Currency of the prices of the entry. - `flight` (object): The cheapest flight behind the price of a date. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): Flight number **with** the carrier code (`IB1378`), unlike the offers. - `operatingCarrier` (string): IATA code of the operating airline. - `validatingAirline` (string): IATA code of the validating airline, as a text (a list of objects in offers). - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `departureTime` (string): `HH:MM`. - `arrivalTime` (string): `HH:MM`. - `departureAirport` (string): IATA code. - `arrivalAirport` (string): IATA code. - `cabinClass` (string): Cabin of the fare. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `includedCheckedBags` (object): Checked baggage included. - `quantity` (integer): Number of checked bags included. - `complete` (boolean): Only on `supplier_result`: `false` means that **another batch of this supplier follows**, under another `searchId`. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds this supplier took. - `processing_time` (number): Seconds this supplier took. The snake_case twin of `processingTime`, with the same value: use this name. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: supplier_result data: {"type":"supplier_result","supplierId":"amadeus_aqc_dz_1","success":true,"data":{"success":true,"requestId":"d36b6918","searchId":"fs_35418d61-6092-44e4-81b7-a6fe985feb79","searchType":"ONE_WAY","processingTime":2.516,"supplierCurrency":"DZD","agencyCurrency":"DZD","results":{"totalOffers":2,"supplierCurrency":"DZD","agencyCurrency":"DZD","offers":[{"id":"fx_offer_b1c2a850","type":"flight_offer","supplier":{"id":"amadeus_aqc_dz_1","name":"Amadeus Algeria","originalId":"6"},"bookableSeats":9,"supportHold":true,"supportVoid":true,"voidDeadline":"2026-11-01T23:00:00Z","filtersApplied":{},"warnings":[],"instantTicketingRequired":false,"pricing":{"supplierCurrency":"DZD","agencyCurrency":"DZD","basePrice":18520,"taxesAndFees":11306.17,"agencyCommission":29.83,"finalPrice":29856,"b2bPrice":29826.17,"acd":{"applied":false},"additionalServices":[]},"itinerary":[{"direction":"outbound","duration":"PT2H30M","stops":0,"segments":[{"id":"27","departure":{"airportCode":"ALG","airportName":"Houari Boumediene Airport","cityName":"Algiers","countryCode":"DZ","terminal":"4","datetime":"2026-12-15T07:15:00","timeZone":"Africa/Algiers"},"arrival":{"airportCode":"CDG","airportName":"Paris Charles de Gaulle Airport","cityName":"Paris","countryCode":"FR","terminal":"2D","datetime":"2026-12-15T09:45:00","timeZone":"Europe/Paris"},"flight":{"carrierCode":"AH","carrierName":"AIR ALGERIE","carrierLogoUrl":"https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg","flightNumber":"1002","aircraftCode":"7M8","aircraftName":"BOEING 737 MAX 8","operatingCarrier":"AH","operatingCarrierName":"AIR ALGERIE"},"duration":"PT2H30M","cabin":"ECONOMY","bookingClass":"Q","fareBasis":"QDZOO","brandedFare":"STANDARD","brandedFareLabel":""}],"conditions":{"refundBeforeDeparture":{"allowed":false},"changeBeforeDeparture":{"allowed":true}}}],"travelerPricing":[{"travelerId":"1","travelerType":"ADULT","fareOption":"STANDARD","price":{"supplierCurrency":"DZD","agencyCurrency":"DZD","base":18520,"total":29856,"b2bPrice":29826.17},"fareDetailsBySegment":[{"segmentId":"27","cabin":"ECONOMY","fareBasis":"QDZOO","brandedFare":"STANDARD","brandedFareLabel":"","bookingClass":"Q","includedCheckedBags":{"quantity":1},"includedCabinBags":{"quantity":1},"amenities":[]}]}],"conditions":{"refundBeforeDeparture":{"allowed":false},"changeBeforeDeparture":{"allowed":true}},"fareRules":{"exchange":{"allowed":true},"refund":{"allowed":false},"revalidation":{"allowed":false},"supplierCurrency":"DZD","agencyCurrency":"DZD"},"validatingAirline":[{"code":"AH","name":"AIR ALGERIE"}],"additionalServices":[]}]},"meta":{"filtersApplied":{},"warnings":[],"supplierId":"amadeus_aqc_dz_1","technologyType":"amadeus_aqc","supportHold":true,"supportVoid":true,"voidDeadline":"2026-11-01T23:00:00Z"}},"complete":true,"processingTime":2.516,"timestamp":"2026-11-01T15:23:00.284737"} ``` ### `error` A supplier failed. The stream goes on without it. `cause` is a stable code. - `type` (string): Always `error`. Always `error`. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier that failed. - `supplier_id` (string): The supplier that failed. The snake_case twin of `supplierId`, with the same value: use this name. - `success` (boolean): Always `false`. - `error` (string): A fixed sentence about the cause, never the supplier's own text. - `cause` (string): Why the supplier failed: a stable code to branch on. `supplier_timeout` also marks the suppliers still running when the stream reached its 300 s limit. `supplier_busy`: FX-Port's capacity towards the supplier is full for a moment, the supplier was not called, retry the search in a few seconds. `service_overloaded`: FX-Port was too busy to prepare the results of the supplier, retry the search in a few seconds. One of `supplier_timeout`, `supplier_api_error`, `supplier_auth_error`, `supplier_error`, `supplier_temporarily_unavailable`, `supplier_busy`, `service_overloaded`. - `processingTime` (number): Seconds spent. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: error data: {"type":"error","supplierId":"duffel_1","success":false,"error":"The supplier rejected the search request.","cause":"supplier_error","processingTime":0,"timestamp":"2026-11-01T15:23:32.838533"} ``` ### `summary` After every supplier has answered or failed. `truncated: true` when the stream reached its 300 s limit. - `type` (string): Always `summary`. Always `summary`. - `requestId` (string): The id of this search. - `success` (boolean): `true` when at least one supplier returned offers. - `processingTime` (number): Seconds the whole search took. - `summary` (object): The counters of the search. (In the same event `successfulSuppliers` is a list next to this object.) - `totalSuppliers` (integer): The suppliers that were called. - `successfulSuppliers` (integer): Suppliers that returned offers. - `failedSuppliers` (integer): Suppliers that failed. - `totalOffers` (integer): Offers of all result sets together. - `successfulSuppliers` (array of object): The suppliers that returned offers. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. - `supplier_id` (string): The supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `offersCount` (integer): Its offers. - `processingTime` (number): Seconds it took. - `failures` (array of object, nullable): The suppliers that failed: `null` when none did. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier. - `supplier_id` (string): The supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `error` (string): A fixed sentence about the cause. - `cause` (string): The stable cause. - `truncated` (boolean): Only present (and `true`) when the stream reached its 300 s limit: the suppliers that had not answered are in `failures` with the cause `supplier_timeout`. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: summary data: {"type":"summary","requestId":"d36b6918","success":true,"processingTime":2.581,"summary":{"totalSuppliers":2,"successfulSuppliers":2,"failedSuppliers":0,"totalOffers":162},"successfulSuppliers":[{"supplierId":"duffel_1","offersCount":92,"processingTime":1.381},{"supplierId":"amadeus_aqc_dz_1","offersCount":70,"processingTime":2.516}],"failures":null,"timestamp":"2026-11-01T15:23:00.286463"} ``` ### `done` Last, always (also after a crash). The stream is over only here. - `type` (string): Always `done`. Always `done`. - `requestId` (string): The id of this search. - `timestamp` (string): ISO 8601 UTC time of the event, without a `Z`: `2026-11-01T15:22:59.175148`. ```text event: done data: {"type":"done","requestId":"d36b6918","timestamp":"2026-11-01T15:23:00.286605"} ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_search_params` | Provide either 'url' or structured params (origin, destination, departure_date, cabin_class). | | 400 | `invalid_airport_code` | `origin` must be a 3-letter IATA code such as ALG. | | 400 | `date_in_past` | `departure_date` is in the past; use today or a later date. | | 400 | `invalid_cabin_class` | `cabin_class` must be one of: economy, premium_economy, business, first, any. | | 400 | `invalid_supplier_selection` | One or more requested suppliers cannot be used for this flight search. | | 400 | `traveler_rule_violation` | Total seated passengers cannot exceed 9 | | 403 | `acd_exemption_invalid` | The selected ACD exempt company is not linked to this agency. | | 413 | `request_too_large` | The request body carries too many values. | | 422 | `no_supplier_supports_flexible_search` | No flight supplier supports flexible-date search for this route. Flexible search is offered on routes that touch Algeria; use get_flights for fixed dates. | | 429 | `concurrency_limit_exceeded` | At most 4 streaming searches can run at the same time for one API key; wait for one to finish (or use the JSON response) and retry. | | 503 | `all_suppliers_failed` | No supplier returned results. See supplierErrors for details. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Search flights](https://fx-port.com/docs/flights/search.md) - [Streaming search results](https://fx-port.com/docs/streaming.md) - [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md) - [Filters](https://fx-port.com/docs/flights/search/filters.md) - [Supplier selection](https://fx-port.com/docs/flights/search/suppliers.md) - [Open-jaw and multi-city](https://fx-port.com/docs/flights/search/complex-itineraries.md) --- # Search flexible dates > Get a price calendar instead of offers: the cheapest fare of each day from three days before to three days after your travel dates. Endpoint: `POST /flights/flexible_search` Source: https://fx-port.com/docs/api/flexible-search `POST /api/v1/flights/flexible_search` Requires an API key with **Read** permission; a **Read+Write** key works too. The body is the one of `get_flights`; `flexible` and `flexible_only` are set for you. A one-way search returns 7 dates, a round trip or an open-jaw up to 49 date pairs (7 by 7). Each supplier entry has `flexPrices.matrix[]` and no `results`; `summary.totalOffers` is `0` and `flexibleOnly` is `true`. An entry has no offer id: to book a date, search for that exact date, then price and book as usual. The price of the matrix is an indication. Only suppliers with the flexible-dates capability are called: today Amadeus Algeria (`amadeus_aqc_dz_1`, routes that touch Algeria). Duffel is skipped, and `supplier_id: "duffel_1"` is a `400 invalid_supplier_selection`. A route nobody serves is `422 no_supplier_supports_flexible_search`. To get the offers and the matrix together, send `flexible: true` to `get_flights`. Identical flexible searches that arrive at the same moment from the same agency, environment and currency are merged into one supplier fan-out; nothing is kept afterwards, and every caller still gets its own `requestId` and headers. ## Request ### Body Required, JSON. - `origin` (string, optional): Departure IATA code: three ASCII letters, an airport (`ALG`) or a city (`PAR`). Lower case is read as upper case. Several airports: `"ALG,ORN"` or `["ALG","ORN"]`, up to five. - `destination` (string, optional): Arrival IATA code. Must differ from `origin`. Several airports are written like for `origin`. - `departure_date` (string, optional): `YYYY-MM-DD`, a real date from yesterday up to 365 days ahead (`date_too_far` beyond). `return_date` and every segment follow the same limits. - `return_date` (string, optional): Makes the search a round trip. On or after `departure_date`. - `segments` (array of object, optional): 1 to 6 flights instead of `origin`, `destination` and `departure_date`: open-jaw and multi-city. At most 6 items. - `origin` (string, required): Departure IATA code (three letters). A segment takes one code, not a list. - `destination` (string, required): Arrival IATA code. Must differ from `origin`. - `departure_date` (string, required): Local departure date, `YYYY-MM-DD`, up to 365 days ahead. The dates must not go backwards. - `date` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: Local departure date, `YYYY-MM-DD`, up to 365 days ahead. - `departureDate` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: Local departure date, `YYYY-MM-DD`, up to 365 days ahead. - `departure` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: Local departure date, `YYYY-MM-DD`, up to 365 days ahead. - `url` (string, optional): A path-style search used by FX-Port's own site, for example `flights/ALG-CDG/2026-12-15/economy/1adults`. It wins over every route field. Prefer the fields above. At most 512 characters. - `passengers` (object, optional): How many travelers of each type. Every field is optional. At most 9 seated passengers (adults, seniors, young adults, children and seated infants together), and not more lap or seated infants than adults and seniors. Counts and ages are JSON integers: numeric text and integral floats (`"2"`, `2.0`) are read with a `coerced_value` warning, an empty text or `null` means not sent, and words, fractions, booleans, negatives and counts above 99 are refused. - `adults` (integer, optional): Adults (12 or older). The default is one adult when nothing is sent. Between 0 and 99. - `seniors` (integer, optional): Seniors. Between 0 and 99. - `young_adults` (integer, optional): Young adults (12 to 17). Between 0 and 99. - `children` (integer, optional): Children (2 to 11). Without `child_ages` every child is searched at age 11. Between 0 and 99. - `seated_infants` (integer, optional): Infants under 2 with their own seat. Between 0 and 99. - `infants` (integer, optional): Lap infants under 2: no seat, at most as many as adults and seniors. Between 0 and 99. - `child_ages` (array of integer, optional): The age of each child. The ages win over the counts: a disagreement is adjusted and reported in a `coerced_value` warning. A text list (`"7,9"`) or one bare number is read as a list, also with a warning. - `young_adult_ages` (array of integer, optional): The age of each young adult. - `seated_infant_ages` (array of integer, optional): The age of each seated infant. - `infant_ages` (array of integer, optional): The age of each lap infant. Without it every lap infant is searched at age 0, with no warning. - `young` (integer, deprecated, optional): Legacy name: use `young_adults`. The previous spelling of `young_adults`: Young adults (12 to 17). - `youngAdults` (integer, deprecated, optional): Legacy name: use `young_adults`. The previous spelling of `young_adults`: Young adults (12 to 17). - `toddlers` (integer, deprecated, optional): Legacy name: use `seated_infants`. The previous spelling of `seated_infants`: Infants under 2 with their own seat. - `seatedInfants` (integer, deprecated, optional): Legacy name: use `seated_infants`. The previous spelling of `seated_infants`: Infants under 2 with their own seat. - `childAges` (array, deprecated, optional): Legacy name: use `child_ages`. The previous spelling of `child_ages`: The age of each child. - `youngAdultAges` (array, deprecated, optional): Legacy name: use `young_adult_ages`. The previous spelling of `young_adult_ages`: The age of each young adult. - `seatedInfantAges` (array, deprecated, optional): Legacy name: use `seated_infant_ages`. The previous spelling of `seated_infant_ages`: The age of each seated infant. - `infantAges` (array, deprecated, optional): Legacy name: use `infant_ages`. The previous spelling of `infant_ages`: The age of each lap infant. - `cabin_class` (string, optional): The cabin. Case, spaces, dashes and underscores do not matter (`Premium Economy` is `premium_economy`); `premium`, `economy_class`, `business_class`, `first_class` and `premium_economy_class` are read as their cabin with a `coerced_value` warning, and an empty text means the default. Any other value (`coach`, `Y`) is `400 invalid_cabin_class`. Default `economy`. One of `economy`, `premium_economy`, `business`, `first`, `any`. Default `economy`. - `nonstop` (boolean, optional): `true` returns non-stop offers only. - `refundable` (boolean, optional): `true` asks the supplier for refundable fares. A hint, not a guarantee. - `checked_bags` (boolean, optional): `true` asks for fares with a checked bag included. Ignored by Duffel. - `max_price` (number, optional): A price ceiling. Not enforced in the sandbox. `0`, an empty text and `null` mean no ceiling: the field is ignored (`0` and an empty text add a `coerced_value` warning). Numeric text is read as a number with a warning; a negative or non-numeric value is `400 invalid_field_type`. - `included_airlines` (object, optional): Up to 20 airline codes: only these carriers. - `excluded_airlines` (object, optional): Up to 20 airline codes to remove. - `preferred_airlines` (object, optional): Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `flexible` (boolean, optional): Also returns the date matrix (`flexPrices`) from the suppliers that support it. - `flexible_only` (boolean, optional): The matrix without offers. `POST /flights/flexible_search` sends it for you. - `from_cc` (string, optional): ISO country code of the origin: exactly two letters. Used to choose suppliers. - `to_cc` (string, optional): ISO country code of the destination: exactly two letters. Used to choose suppliers. - `countries` (array of string, optional): Up to 10 country codes of the route, to override the ones FX-Port infers. At most 10 items. - `filters` (object, optional): The airline lists in their older nested form. Only the three airline lists are read from it; any other key is ignored. Prefer the top-level fields. - `included_airlines` (object, optional): Up to 20 airline codes: only these carriers. - `excluded_airlines` (object, optional): Up to 20 airline codes to remove. - `preferred_airlines` (object, optional): Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `airlineInclude` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airline_include` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includeAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includedAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `included_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airlineExclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airline_exclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludeAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludedAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excluded_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `preferredAirlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `supplier_id` (string, optional): Restrict the search to one supplier id (a comma separated list is accepted). Strict: an unknown or disabled id is `400 invalid_supplier_selection`. - `supplier_ids` (array of string, optional): Restrict the search to this set. Strict, up to 10. At most 10 items. - `preferred_suppliers` (array of string, optional): A hint: ids that are not usable are ignored and the automatic selection applies. - `max_suppliers` (integer, optional): Keep only the first N selected suppliers, in priority order. `"3"` and `3.0` are read as 3 with a `coerced_value` warning; an empty text means no limit. - `loyalty_program_accounts` (array of object, optional): Up to 10 frequent-flyer accounts. At most 10 items. - `airline_iata_code` (string, required): IATA code of the airline of the programme. - `account_number` (string, required): The frequent-flyer number. - `first_name` (string, optional): Given name of the member, matched with the passenger at booking. - `last_name` (string, optional): Family name of the member. - `airlineIataCode` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `airline_code` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `airlineCode` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `programOwner` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `program_owner` (string, deprecated, optional): Legacy name: use `airline_iata_code`. The previous spelling of `airline_iata_code`: IATA code of the airline of the programme. - `accountNumber` (string, deprecated, optional): Legacy name: use `account_number`. The previous spelling of `account_number`: The frequent-flyer number. - `membershipNumber` (string, deprecated, optional): Legacy name: use `account_number`. The previous spelling of `account_number`: The frequent-flyer number. - `membership_number` (string, deprecated, optional): Legacy name: use `account_number`. The previous spelling of `account_number`: The frequent-flyer number. - `firstName` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name of the member, matched with the passenger at booking. - `givenName` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name of the member, matched with the passenger at booking. - `given_name` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name of the member, matched with the passenger at booking. - `lastName` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `familyName` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `family_name` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `surname` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name of the member. - `acd_exempt_company_id` (string, optional): For agencies that settle in DZD and have an ACD-exempt company: its id, to price the fares without ACD. - `agent_id` (string, optional): Your own id of the agent who searches; stored with the search and the booking. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `departureDate` (string, deprecated, optional): Legacy name: use `departure_date`. The previous spelling of `departure_date`: `YYYY-MM-DD`, a real date from yesterday up to 365 days ahead (`date_too_far` beyond). - `returnDate` (string, deprecated, optional): Legacy name: use `return_date`. The previous spelling of `return_date`: Makes the search a round trip. - `cabinClass` (string, deprecated, optional): Legacy name: use `cabin_class`. The previous spelling of `cabin_class`: The cabin. - `travelers` (object, deprecated, optional): Legacy name: use `passengers`. The previous spelling of `passengers`: `passengers`. - `direct` (boolean, deprecated, optional): Legacy name: use `nonstop`. The previous spelling of `nonstop`: `true` returns non-stop offers only. - `checkedBags` (boolean, deprecated, optional): Legacy name: use `checked_bags`. The previous spelling of `checked_bags`: `true` asks for fares with a checked bag included. - `maxPrice` (number, deprecated, optional): Legacy name: use `max_price`. The previous spelling of `max_price`: A price ceiling. - `flexibleOnly` (boolean, deprecated, optional): Legacy name: use `flexible_only`. The previous spelling of `flexible_only`: The matrix without offers. - `fromCc` (string, deprecated, optional): Legacy name: use `from_cc`. The previous spelling of `from_cc`: ISO country code of the origin: exactly two letters. - `toCc` (string, deprecated, optional): Legacy name: use `to_cc`. The previous spelling of `to_cc`: ISO country code of the destination: exactly two letters. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Restrict the search to one supplier id (a comma separated list is accepted). - `supplierIds` (array, deprecated, optional): Legacy name: use `supplier_ids`. The previous spelling of `supplier_ids`: Restrict the search to this set. - `preferredSuppliers` (array, deprecated, optional): Legacy name: use `preferred_suppliers`. The previous spelling of `preferred_suppliers`: A hint: ids that are not usable are ignored and the automatic selection applies. - `maxSuppliers` (integer, deprecated, optional): Legacy name: use `max_suppliers`. The previous spelling of `max_suppliers`: Keep only the first N selected suppliers, in priority order. - `airlineInclude` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airline_include` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includeAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `includedAirlines` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `include_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `included_airline` (object, deprecated, optional): Legacy name: use `included_airlines`. The previous spelling of `included_airlines`: Up to 20 airline codes: only these carriers. - `airlineExclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airline_exclude` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludeAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excludedAirlines` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `exclude_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `excluded_airline` (object, deprecated, optional): Legacy name: use `excluded_airlines`. The previous spelling of `excluded_airlines`: Up to 20 airline codes to remove. - `airlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `preferredAirlines` (object, deprecated, optional): Legacy name: use `preferred_airlines`. The previous spelling of `preferred_airlines`: Behaves exactly like `included_airlines`: a strict allow-list, not a ranking. - `agentId` (string, deprecated, optional): Legacy name: use `agent_id`. The previous spelling of `agent_id`: Your own id of the agent who searches; stored with the search and the booking. - `acdExemptCompanyId` (string, deprecated, optional): Legacy name: use `acd_exempt_company_id`. The previous spelling of `acd_exempt_company_id`: For agencies that settle in DZD and have an ACD-exempt company: its id, to price the fares without ACD. - `loyaltyProgramAccounts` (array, deprecated, optional): Legacy name: use `loyalty_program_accounts`. The previous spelling of `loyalty_program_accounts`: Up to 10 frequent-flyer accounts. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/flexible_search \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "origin": "ALG", "destination": "CDG", "departure_date": "2026-12-15", "cabin_class": "economy", "passengers": { "adults": 1 } }' ``` ## Response ### 200: The date matrix of each flexible-capable supplier. Check that `flexPrices` is present before you read it; entries with `status: no_flights_available` are expected. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` on every `200`, even if some suppliers failed: read `suppliers[n].success` and `failures`. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this search, 8 characters, for support. - `searchType` (string, deprecated): Legacy name: use `search_type`. The type of the search, derived from your route. One of `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW`, `MULTI_CITY`. - `search_type` (string): The type of the search, derived from your route. The snake_case twin of `searchType`, with the same value: use this name. One of `ONE_WAY`, `ROUND_TRIP`, `OPEN_JAW`, `MULTI_CITY`. - `flexible` (boolean): Whether the date matrix was requested. - `flexibleOnly` (boolean): Only present (and `true`) for a flexible-only search. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent by the whole search. - `processing_time` (number): Seconds spent by the whole search. The snake_case twin of `processingTime`, with the same value: use this name. - `suppliers` (array of object): One entry for each supplier that answered. - `success` (boolean): Whether this supplier returned usable results. - `searchId` (string, deprecated): Legacy name: use `search_id`. **Save this.** `fs_` (Amadeus Algeria) or `fs_dfx_` (Duffel). Send it as `search_id` to `price_flight` and `book_flight`. Valid for 24 hours, but a supplier withdraws offers earlier. - `search_id` (string): **Save this.** `fs_` (Amadeus Algeria) or `fs_dfx_` (Duffel). Send it as `search_id` to `price_flight` and `book_flight`. Valid for 24 hours, but a supplier withdraws offers earlier. The snake_case twin of `searchId`, with the same value: use this name. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. What the supplier can do in general: hold. Read it again on the priced offer. - `support_hold` (boolean): What the supplier can do in general: hold. Read it again on the priced offer. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. What the supplier can do in general: same-day void. - `support_void` (boolean): What the supplier can do in general: same-day void. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. ISO 8601 UTC end of the same-day void window, `null` when the supplier cannot void. - `void_deadline` (string, nullable): ISO 8601 UTC end of the same-day void window, `null` when the supplier cannot void. The snake_case twin of `voidDeadline`, with the same value: use this name. - `filtersApplied` (object, deprecated): Legacy name: use `filters_applied`. The filters you asked for, echoed. - `filters_applied` (object): The filters you asked for, echoed. The snake_case twin of `filtersApplied`, with the same value: use this name. - `warnings` (array of object): Notes of the supplier. - `meta` (object): Who answered and how fast. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `supplierId`, with the same value: use this name. - `technologyType` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `supplierName` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `supplierName`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds this supplier took. - `processing_time` (number): Seconds this supplier took. The snake_case twin of `processingTime`, with the same value: use this name. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `results` (object): The offers of one supplier. - `totalOffers` (integer): Number of offers of this result set. Always equals the length of `offers`: there is no pagination. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `offers` (array of FlightOffer): The offers, in the order the supplier ranks them. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's (Duffel: USD to DZD, 250.0). Every amount is already converted. - `exchange_rate` (number): Only when the supplier's currency is not your agency's (Duffel: USD to DZD, 250.0). Every amount is already converted. The snake_case twin of `exchangeRate`, with the same value: use this name. - `flexPrices` (object): The date matrix of a flexible search: one cheapest fare for each candidate date. An entry has no offer id: to book a date, search for it. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of the prices of `matrix`. - `matrix` (array of object): One entry for each date (one-way: 7 dates) or date pair (round trip, open-jaw: up to 49). - `departure` (string): Candidate departure date, `YYYY-MM-DD`. - `return` (string): Candidate return date. Only for a round trip or an open-jaw. - `status` (string): `available` when the supplier has a fare for this date or pair. One of `available`, `no_flights_available`. - `finalPrice` (number, nullable, deprecated): Legacy name: use `final_price`. What the traveler pays, in `agencyCurrency`. - `final_price` (number, nullable): What the traveler pays, in `agencyCurrency`. The snake_case twin of `finalPrice`, with the same value: use this name. - `basePrice` (number, nullable, deprecated): Legacy name: use `base_price`. The airline's base fare. - `base_price` (number, nullable): The airline's base fare. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, nullable, deprecated): Legacy name: use `taxes_and_fees`. Taxes and fees. - `taxes_and_fees` (number, nullable): Taxes and fees. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, nullable, deprecated): Legacy name: use `agency_commission`. Your commission. - `agency_commission` (number, nullable): Your commission. The snake_case twin of `agencyCommission`, with the same value: use this name. - `b2bPrice` (number, nullable, deprecated): Legacy name: use `b2b_price`. The amount debited from your balance. - `b2b_price` (number, nullable): The amount debited from your balance. The snake_case twin of `b2bPrice`, with the same value: use this name. - `agencyCurrency` (string): Currency of the prices of the entry. - `flight` (object): The cheapest flight behind the price of a date. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): Flight number **with** the carrier code (`IB1378`), unlike the offers. - `operatingCarrier` (string): IATA code of the operating airline. - `validatingAirline` (string): IATA code of the validating airline, as a text (a list of objects in offers). - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `departureTime` (string): `HH:MM`. - `arrivalTime` (string): `HH:MM`. - `departureAirport` (string): IATA code. - `arrivalAirport` (string): IATA code. - `cabinClass` (string): Cabin of the fare. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `includedCheckedBags` (object): Checked baggage included. - `quantity` (integer): Number of checked bags included. - `summary` (object): Counters of the search. - `totalSuppliers` (integer): The suppliers that answered (the same as `successfulSuppliers`). In the stream: the suppliers that were called. - `successfulSuppliers` (integer): Suppliers that returned results. - `failedSuppliers` (integer): Suppliers listed in `failures`. - `totalOffers` (integer): Offers of all the suppliers together. - `failures` (array of object): Only when a supplier failed. - `supplierId` (string, deprecated): Legacy name: use `supplier_id`. The supplier that failed. - `supplier_id` (string): The supplier that failed. The snake_case twin of `supplierId`, with the same value: use this name. - `error` (string): A fixed sentence about the cause, never the supplier's own text. - `cause` (string): Why the supplier failed: a stable code to branch on. `supplier_temporarily_unavailable`: the supplier is skipped for a short while after repeated failures. `supplier_busy`: FX-Port's capacity towards the supplier is full for a moment, the supplier was not called, retry in a few seconds. `service_overloaded`: FX-Port was too busy to prepare the results of the supplier, retry in a few seconds. One of `supplier_timeout`, `supplier_api_error`, `supplier_auth_error`, `supplier_error`, `supplier_temporarily_unavailable`, `supplier_busy`, `service_overloaded`. ```json { "environment": "sandbox", "success": true, "requestId": "9c844468", "searchType": "ONE_WAY", "flexible": true, "processingTime": 4.092, "suppliers": [ { "success": true, "searchId": "fs_4f72482f-0d24-4aba-882d-d4eb49f6d034", "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "filtersApplied": { "flexible": true, "flexibleOnly": true }, "warnings": [], "meta": { "supplierId": "amadeus_aqc_dz_1", "technologyType": "amadeus_aqc", "supplierName": "Amadeus Algeria", "processingTime": 4.029, "supplierCurrency": "DZD", "agencyCurrency": "DZD" }, "flexPrices": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "matrix": [ { "departure": "2026-12-12", "status": "available", "finalPrice": 26925.47, "basePrice": 18360, "taxesAndFees": 8538.57, "agencyCommission": 26.9, "flight": { "carrierCode": "IB", "flightNumber": "IB1378", "operatingCarrier": "IB", "validatingAirline": "IB", "duration": "PT9H45M", "stops": 1, "departureTime": "14:05", "arrivalTime": "23:50", "departureAirport": "ALG", "arrivalAirport": "CDG", "cabinClass": "ECONOMY", "brandedFare": null, "brandedFareLabel": null, "includedCheckedBags": { "quantity": 1 } }, "b2bPrice": 26898.57, "agencyCurrency": "DZD" }, { "departure": "2026-12-13", "status": "available", "finalPrice": 29638.41, "basePrice": 11810, "taxesAndFees": 17798.8, "agencyCommission": 29.61, "flight": { "carrierCode": "AZ", "flightNumber": "AZ801", "operatingCarrier": "AZ", "validatingAirline": "AZ", "duration": "PT5H10M", "stops": 1, "departureTime": "12:15", "arrivalTime": "17:25", "departureAirport": "ALG", "arrivalAirport": "CDG", "cabinClass": "ECONOMY", "brandedFare": null, "brandedFareLabel": null, "includedCheckedBags": { "quantity": 1 } }, "b2bPrice": 29608.8, "agencyCurrency": "DZD" } ] } } ], "flexibleOnly": true, "summary": { "totalSuppliers": 1, "successfulSuppliers": 1, "failedSuppliers": 0, "totalOffers": 0 } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_search_params` | Provide either 'url' or structured params (origin, destination, departure_date, cabin_class). | | 400 | `invalid_airport_code` | `origin` must be a 3-letter IATA code such as ALG. | | 400 | `date_in_past` | `departure_date` is in the past; use today or a later date. | | 400 | `invalid_cabin_class` | `cabin_class` must be one of: economy, premium_economy, business, first, any. | | 400 | `invalid_supplier_selection` | One or more requested suppliers cannot be used for this flight search. | | 400 | `traveler_rule_violation` | Total seated passengers cannot exceed 9 | | 403 | `acd_exemption_invalid` | The selected ACD exempt company is not linked to this agency. | | 413 | `request_too_large` | The request body carries too many values. | | 422 | `no_supplier_supports_flexible_search` | No flight supplier supports flexible-date search for this route. Flexible search is offered on routes that touch Algeria; use get_flights for fixed dates. | | 503 | `all_suppliers_failed` | No supplier returned results. See supplierErrors for details. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Flexible-date search](https://fx-port.com/docs/flights/flexible-search.md) - [Search flights](https://fx-port.com/docs/flights/search.md) --- # Price a flight offer > Confirm the current price and conditions of one offer of a search with its supplier, just before you book it. Endpoint: `POST /flights/price_flight` Source: https://fx-port.com/docs/api/price-flight `POST /api/v1/flights/price_flight` Requires an API key with **Read** permission; a **Read+Write** key works too. This call is required before `book_flight`: an offer that was not priced is refused with `404 offer_not_priced`. Send the `searchId` of the supplier entry the offer is in (`suppliers[n].searchId`, never the top-level `requestId`) and the `id` of the offer. The answer is the current version of the offer: its price can differ from the search, `supportHold` is final, and an Amadeus offer lists the extra bags that can be bought. Pricing reserves nothing. A `searchId` is valid for 24 hours, but a supplier withdraws offers sooner (a Duffel offer lives 15 to 60 minutes): on `410 offer_expired`, search again. A priced offer can be booked for 12 hours. ## Request ### Body Required, JSON. - `search_id` (string, required): The `searchId` of the **supplier entry** the offer is in (`suppliers[n].searchId`), never the top-level `requestId`. - `offer_id` (string, required): The `id` of the offer. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `searchId` (string, deprecated, optional): Legacy name: use `search_id`. The previous spelling of `search_id`: The `searchId` of the **supplier entry** the offer is in (`suppliers[n].searchId`), never the top-level `requestId`. - `offerId` (string, deprecated, optional): Legacy name: use `offer_id`. The previous spelling of `offer_id`: The `id` of the offer. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/price_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "search_id": "fs_dfx_a3aeaa36-74ad-424b-b88d-e6bdc9a4ba94", "offer_id": "fx_offer_89d6592c" }' ``` ## Response ### 200: The priced offer in `results.offers[0]`: the price to book, `supportHold` and, for Amadeus, the bags that can be added. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the offer was priced. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `offerId` (string, deprecated): Legacy name: use `offer_id`. The `id` of the priced offer. - `offer_id` (string): The `id` of the priced offer. The snake_case twin of `offerId`, with the same value: use this name. - `offerType` (string): Always `priced`. Always `priced`. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `results` (object): The priced offer. - `totalOffers` (integer): Always 1. - `supplierCurrency` (string): Currency in which the supplier prices. - `agencyCurrency` (string): Currency of your agency. - `offers` (array of FlightOffer): The priced offer. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's. - `exchange_rate` (number): Only when the supplier's currency is not your agency's. The snake_case twin of `exchangeRate`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "requestId": "76f8fc58", "offerId": "fx_offer_89d6592c", "offerType": "priced", "processingTime": 0.177, "supplierCurrency": "USD", "agencyCurrency": "DZD", "results": { "totalOffers": 1, "supplierCurrency": "USD", "agencyCurrency": "DZD", "offers": [ { "id": "fx_offer_89d6592c", "type": "flight_offer", "supplier": { "id": "duffel_1", "name": "Duffel", "originalId": "off_034q6JKduVe2mSW1fH8lge" }, "source": "duffel", "bookableSeats": null, "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "instantTicketingRequired": false, "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16975, "taxesAndFees": 5366.8, "agencyCommission": 22.35, "finalPrice": 22364.15, "b2bPrice": 22341.8, "additionalServices": [] }, "itinerary": [ { "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": [ { "id": "seg_0000BC0a5iVGdVLqUATqqr", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "2", "datetime": "2026-12-15T10:50:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "CDG", "airportName": "Paris Charles de Gaulle Airport", "cityName": "Paris", "countryCode": "FR", "terminal": "1", "datetime": "2026-12-15T13:14:00", "timeZone": "Europe/Paris" }, "flight": { "carrierCode": "BA", "carrierName": "British Airways", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/BA.svg", "flightNumber": "0107", "aircraftCode": null, "aircraftName": null, "operatingCarrier": "BA", "operatingCarrierName": "British Airways" }, "duration": "PT2H24M", "cabin": "ECONOMY", "bookingClass": null, "fareBasis": "Y20LGTN2", "brandedFare": "Basic", "brandedFareLabel": "Basic" } ], "conditions": { "changeBeforeDeparture": { "allowed": true }, "refundBeforeDeparture": { "allowed": true } } } ], "travelerPricing": [ { "travelerId": "pas_0000BC0a5iH5UE0bmBqWkl", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "base": 16975, "total": 22364.15, "b2bPrice": 22341.8 }, "fareDetailsBySegment": [ { "segmentId": "seg_0000BC0a5iVGdVLqUATqqr", "cabin": "ECONOMY", "fareBasis": "Y20LGTN2", "brandedFare": "Basic", "brandedFareLabel": "Basic", "bookingClass": "Economy", "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [] } ] } ], "conditions": { "refundBeforeDeparture": { "allowed": true }, "changeBeforeDeparture": { "allowed": true } }, "fareRules": { "refund": { "allowed": true }, "exchange": { "allowed": true }, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "validatingAirline": [ { "code": "BA", "name": "British Airways" } ], "additionalServices": { "baggage": [ { "id": "ase_0000BC0aGG1cCZZ8LCieTb", "type": "checked", "quantity": 1, "maxQuantity": 1, "maxWeightKg": 23, "maxWeightUnit": "kg", "priceAmount": 20, "convertedPriceAmount": 5000, "priceCurrency": "USD", "applicablePassengers": [ "pas_0000BC0a5iH5UE0bmBqWkl" ], "applicableSegments": [ "seg_0000BC0a5iVGdVLqUATqqr" ], "bookableByItinerary": false, "metadata": { "name": "CHECKED", "description": "Additional checked" } } ] } } ] }, "exchangeRate": 250 } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameters: search_id and offer_id are required | | 400 | `invalid_identifier` | `search_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 404 | `SEARCH_EXPIRED_OR_NOT_FOUND` | Search ID 'fs_does_not_exist' not found or has expired | | 404 | `OFFER_NOT_FOUND_IN_SEARCH` | Offer ID 'fx_offer_00000000' not found in search results | | 410 | `OFFER_EXPIRED` | This offer has expired or is no longer available | | 503 | `supplier_temporarily_unavailable` | Supplier 'duffel_1' circuit breaker is open: retry in a few moments. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Price a flight offer](https://fx-port.com/docs/flights/price.md) - [Flight pricing](https://fx-port.com/docs/flights/pricing.md) - [Search flights](https://fx-port.com/docs/flights/search.md) --- # Book a flight offer > Book an offer you have priced: ticketed at once (booking_type: issue) or held without payment (hold). Endpoint: `POST /flights/book_flight` Source: https://fx-port.com/docs/api/book-flight `POST /api/v1/flights/book_flight` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. Price the offer first with `price_flight`: an offer that was not priced is refused with `404 offer_not_priced`. Send the `search_id` and the `offer_id`, one object for each traveler in `passengers` (lap infants included, at most 18) and `contact_details` with the `email` and the `phone_number`, both required. Send the passport of every traveler: airlines can require it. `booking_type` is `issue` by default; `hold` is only valid when `supportHold` is `true` on the priced offer (`400 hold_not_supported` otherwise), and a hold is paid later with `pay_order`. An unknown `booking_type` is refused with `400`: it never becomes an order. The answer is the order: `booking.pnr`, the supplier's order id in `booking.reference` (use it as `order_id` afterwards) and in `postProcessing.bookingDbId` the FX-Port id (a UUID, the stable key of the booking), then the price block, the passengers and the itinerary. In the sandbox nothing is charged (`balanceDebited: false`); send `send_email: false` to keep the confirmation e-mail from going out. A fare with `pricing.acd.applied: true` also carries `requiresACD: true`: the booking needs one passport copy for every traveler, see Upload ACD documents. **A failed booking is always a JSON body**: `409` when the offer or the search was already booked, `503` when the supplier failed with a 5xx status, `422` for every other refusal of the supplier. You never receive an HTML `502` page. Duffel allows one booking per search: book a second offer of a search that was booked and you get `409`. ## Request ### Body Required, JSON. - `search_id` (string, required): The `searchId` of the supplier entry the priced offer came from. - `offer_id` (string, required): The `id` of the priced offer. - `booking_type` (string, optional): `issue` (default) for instant ticketing, `hold` for an unpaid reservation when `supportHold` is `true` after pricing. Any letter case; any other value is `400 invalid_booking_type`. One of `issue`, `hold`. Default `issue`. - `passengers` (array of object, required): One object per traveler, lap infants included. At most 18. At most 18 items. - `type` (string, required): `adult`, `senior`, `young_adult`, `child`, `seated_infant` or `infant` (a lap infant), in any letter case. Also accepted: `young`, `young_adults`, `infant_with_seat`, `held_infant`, `infant_without_seat`. - `gender` (string, required): `MALE` or `FEMALE` (`M` and `F` too), in any letter case. `X`, `U` or `UNSPECIFIED` for a passport marked X. - `first_name` (string, required): Given name as in the passport. Latin letters are what airlines need. Spaces and line breaks around it are removed before the checks. At most 64 characters. - `last_name` (string, required): Family name as in the passport. Spaces and line breaks around it are removed before the checks. At most 64 characters. - `date_of_birth` (string, required): `YYYY-MM-DD`, a real date that is not in the future (tomorrow is still accepted: time-zone slack). - `identity_documents` (array of object, optional): The passport, up to 5 documents. At most 5 items. - `type` (string, optional): `passport` (default). - `number` (string, optional): The document number: 1 to 40 letters, digits, spaces and `- . / _`. A JSON number is read as text with a `coerced_value` warning. - `issuing_country` (string, optional): The country that issued the document: ISO 3166-1 alpha-2 (`DZ`). Any code of 2 to 4 letters, optionally followed by `-` and 1 to 3 letters or digits (`GB-SCT`, `USPR`), is accepted and passed on in upper case without conversion. Anything else is `400 invalid_country_code`. - `expiry_date` (string, optional): `YYYY-MM-DD`. - `nationality` (string, optional): Accepted and ignored: suppliers use the issuing country. - `issuingCountry` (string, deprecated, optional): Legacy name: use `issuing_country`. The previous spelling of `issuing_country`: The country that issued the document: ISO 3166-1 alpha-2 (`DZ`). - `issuing_country_code` (string, deprecated, optional): Legacy name: use `issuing_country`. The previous spelling of `issuing_country`: The country that issued the document: ISO 3166-1 alpha-2 (`DZ`). - `issuingCountryCode` (string, deprecated, optional): Legacy name: use `issuing_country`. The previous spelling of `issuing_country`: The country that issued the document: ISO 3166-1 alpha-2 (`DZ`). - `expiryDate` (string, deprecated, optional): Legacy name: use `expiry_date`. The previous spelling of `expiry_date`: `YYYY-MM-DD`. - `expires_on` (string, deprecated, optional): Legacy name: use `expiry_date`. The previous spelling of `expiry_date`: `YYYY-MM-DD`. - `documentNumber` (string, deprecated, optional): Legacy name: use `number`. The previous spelling of `number`: The document number: 1 to 40 letters, digits, spaces and `- . - `unique_identifier` (string, deprecated, optional): Legacy name: use `number`. The previous spelling of `number`: The document number: 1 to 40 letters, digits, spaces and `- . - `id` (string, optional): An id of yours for the passenger. - `firstName` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name as in the passport. - `givenName` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name as in the passport. - `given_name` (string, deprecated, optional): Legacy name: use `first_name`. The previous spelling of `first_name`: Given name as in the passport. - `lastName` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name as in the passport. - `familyName` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name as in the passport. - `family_name` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name as in the passport. - `surname` (string, deprecated, optional): Legacy name: use `last_name`. The previous spelling of `last_name`: Family name as in the passport. - `dateOfBirth` (string, deprecated, optional): Legacy name: use `date_of_birth`. The previous spelling of `date_of_birth`: `YYYY-MM-DD`, a real date that is not in the future (tomorrow is still accepted: time-zone slack). - `bornOn` (string, deprecated, optional): Legacy name: use `date_of_birth`. The previous spelling of `date_of_birth`: `YYYY-MM-DD`, a real date that is not in the future (tomorrow is still accepted: time-zone slack). - `born_on` (string, deprecated, optional): Legacy name: use `date_of_birth`. The previous spelling of `date_of_birth`: `YYYY-MM-DD`, a real date that is not in the future (tomorrow is still accepted: time-zone slack). - `dob` (string, deprecated, optional): Legacy name: use `date_of_birth`. The previous spelling of `date_of_birth`: `YYYY-MM-DD`, a real date that is not in the future (tomorrow is still accepted: time-zone slack). - `identityDocuments` (array, deprecated, optional): Legacy name: use `identity_documents`. The previous spelling of `identity_documents`: The passport, up to 5 documents. - `documents` (array, deprecated, optional): Legacy name: use `identity_documents`. The previous spelling of `identity_documents`: The passport, up to 5 documents. - `passengerType` (string, deprecated, optional): Legacy name: use `type`. The previous spelling of `type`: `adult`, `senior`, `young_adult`, `child`, `seated_infant` or `infant` (a lap infant), in any letter case. - `contact_details` (object, required): How to reach the traveler. Both fields are required. - `email` (string, required): The e-mail of the contact, 254 characters at most. - `phone_number` (string, required): An international phone number (`+213...`): Duffel validates the format. FX-Port accepts digits with spaces, dots, dashes, slashes and brackets, one leading `+`, 5 to 24 digits and 32 characters at most; a dial code alone (`+213`) is `400 invalid_phone_number`. - `phone` (string, deprecated, optional): Legacy name: use `phone_number`. The previous spelling of `phone_number`: An international phone number (`+213...`): Duffel validates the format. - `phoneNumber` (string, deprecated, optional): Legacy name: use `phone_number`. The previous spelling of `phone_number`: An international phone number (`+213...`): Duffel validates the format. - `mobile` (string, deprecated, optional): Legacy name: use `phone_number`. The previous spelling of `phone_number`: An international phone number (`+213...`): Duffel validates the format. - `emailAddress` (string, deprecated, optional): Legacy name: use `email`. The previous spelling of `email`: The e-mail of the contact, 254 characters at most. - `send_email` (boolean, optional): `false` suppresses the confirmation e-mail. Default `true`. - `commission_override` (object, optional): Replaces your agency commission for this booking: `pricing.agencyCommission` and `pricing.finalPrice` change, `b2bPrice` does not. - `type` (string, required): `percentage` is a share of `b2bPrice`; `fixed` is `value` times the number of travelers. One of `percentage`, `fixed`. - `value` (number, required): A number from 0. Numeric text such as `"10"` is read as a number with a `coerced_value` warning. At least 0. - `agent_id` (string, optional): Your own id of the agent who books; stored on the booking. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `searchId` (string, deprecated, optional): Legacy name: use `search_id`. The previous spelling of `search_id`: The `searchId` of the supplier entry the priced offer came from. - `offerId` (string, deprecated, optional): Legacy name: use `offer_id`. The previous spelling of `offer_id`: The `id` of the priced offer. - `bookingType` (string, deprecated, optional): Legacy name: use `booking_type`. The previous spelling of `booking_type`: `issue` (default) for instant ticketing, `hold` for an unpaid reservation when `supportHold` is `true` after pricing. - `sendEmail` (boolean, deprecated, optional): Legacy name: use `send_email`. The previous spelling of `send_email`: `false` suppresses the confirmation e-mail. - `commissionOverride` (object, deprecated, optional): Legacy name: use `commission_override`. The previous spelling of `commission_override`: `commission_override`. - `agentId` (string, deprecated, optional): Legacy name: use `agent_id`. The previous spelling of `agent_id`: Your own id of the agent who books; stored on the booking. - `contact` (object, deprecated, optional): Legacy name: use `contact_details`. The previous spelling of `contact_details`: `contact_details`. - `contacts` (object, deprecated, optional): Legacy name: use `contact_details`. The previous spelling of `contact_details`: `contact_details`. - `contactDetails` (object, deprecated, optional): Legacy name: use `contact_details`. The previous spelling of `contact_details`: `contact_details`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/book_flight \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "search_id": "fs_85ac5f12-7456-4edf-a2fb-1d51239d278b", "offer_id": "fx_offer_9de60e2a", "booking_type": "hold", "send_email": false, "passengers": [ { "type": "adult", "gender": "MALE", "first_name": "James", "last_name": "Smith", "date_of_birth": "1988-06-15", "identity_documents": [ { "type": "passport", "number": "A12345678", "issuing_country": "DZ", "expiry_date": "2031-04-18" } ] } ], "contact_details": { "email": "traveler@example.com", "phone_number": "+15555550123" } }' ``` ## Response ### 200: The order. Keep `booking.reference` (the supplier's order id), `booking.pnr` and `postProcessing.bookingDbId`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the order was created (booking) or paid (payment). - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `paymentSucceeded` (boolean): `pay_order` only: whether the payment succeeded. Read it together with `success`. - `searchId` (string, deprecated): Legacy name: use `search_id`. `book_flight` only: the `search_id` you sent. - `search_id` (string): `book_flight` only: the `search_id` you sent. The snake_case twin of `searchId`, with the same value: use this name. - `offerId` (string, deprecated): Legacy name: use `offer_id`. `book_flight` only: the `offer_id` you sent. - `offer_id` (string): `book_flight` only: the `offer_id` you sent. The snake_case twin of `offerId`, with the same value: use this name. - `orderId` (string, deprecated): Legacy name: use `order_id`. `pay_order` only: the supplier's order id. - `order_id` (string): `pay_order` only: the supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `paymentDetails` (object): `pay_order` only: `paymentId`, `status`, `type` (`issuance`), `paymentAmount`, `currency`, `createdAt`, `message`. - `booking` (object): The order as the supplier knows it. - `reference` (string, deprecated): Legacy name: use `order_id`. The supplier's order id (`ord_...` for Duffel, a base64-like text for Amadeus; for a cancelled hold of Amadeus Algeria the PNR). Send it as `order_id` to `pay_order`, `cancel_order` and `cancel_quote`. - `order_id` (string): The supplier's order id (`ord_...` for Duffel, a base64-like text for Amadeus; for a cancelled hold of Amadeus Algeria the PNR). Send it as `order_id` to `pay_order`, `cancel_order` and `cancel_quote`. The snake_case twin of `reference`, with the same value: use this name. - `status` (string): `confirmed`, `pending` (a hold that is not paid yet), `awaiting_payment`, `cancelled`, `failed` or `partial_refund`. - `pnr` (string): The airline record locator. Not unique across suppliers and airlines. - `type` (string): `instant`: ticketed at once. `hold`: reserved, not paid. One of `instant`, `hold`. - `createdAt` (string): When the order was created. - `cancelledAt` (string, nullable): When the order was cancelled. - `content` (string): Who manages the order: `managed`. - `syncedAt` (string): When FX-Port last synchronised the order with the supplier. - `availableActions` (array of string): What can still be done with the order. - `voidableUntil` (string, nullable): ISO 8601 UTC end of the same-day void window. - `cancellation` (object, nullable): How the order was cancelled: `type`, `deleted`, `deletedAt`. - `supplier` (object): The supplier of the order. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `id`, with the same value: use this name. - `technology` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `offerId` (string, nullable, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. - `supplier_offer_id` (string, nullable): The supplier's own id of the offer. The snake_case twin of `offerId`, with the same value: use this name. - `agency` (object): Your agency. The id of the agency is not returned: you know it. - `currency` (string): ISO 4217 currency of your agency. Every amount of the response is in it. - `pricing` (object): The agency-facing price of an offer, a booking or a quote. Every amount is in `agencyCurrency`; the rate, when the currencies differ, is `exchangeRate` at the top level of the response. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. Every amount of the block is in it. - `basePrice` (number, deprecated): Legacy name: use `base_price`. The airline's base fare, before taxes, in the agency currency. - `base_price` (number): The airline's base fare, before taxes, in the agency currency. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, deprecated): Legacy name: use `taxes_and_fees`. Taxes plus the platform's fees, folded together. Excludes your agency commission. - `taxes_and_fees` (number): Taxes plus the platform's fees, folded together. Excludes your agency commission. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. The commission of your agency on this sale. - `agency_commission` (number): The commission of your agency on this sale. The snake_case twin of `agencyCommission`, with the same value: use this name. - `finalPrice` (number, deprecated): Legacy name: use `final_price`. What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. - `final_price` (number): What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. The snake_case twin of `finalPrice`, with the same value: use this name. - `b2bPrice` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance: `finalPrice - agencyCommission`. - `b2b_price` (number): The amount debited from your agency balance: `finalPrice - agencyCommission`. The snake_case twin of `b2bPrice`, with the same value: use this name. - `totalAmount` (number): Order quote only: an alias of `b2bPrice`. - `taxAmount` (number): Order quote of a Duffel order only: the airline's taxes in your agency currency. - `originalTaxAmount` (number): Order quote of a Duffel order only: the same taxes in the supplier currency. - `additionalServices` (array of object): Paid extras. A list in the offers of a search (empty for most offers). - `acd` (object): Whether the fare is an ACD fare. Present on the offers of Amadeus Algeria, absent on Duffel offers. An ACD fare carries more keys with the amounts of the adjustment: they are informational, can change without notice and are not part of the contract. - `applied` (boolean): `true` for an ACD fare: the booking needs one passport copy for every traveler, sent with `upload_acd_documents`. `false` for an ordinary fare. - `scenario` (string): Only on an ACD fare: the rule that adjusted the price. Informational. One of `A`, `B`. - `exempt` (boolean): `true` when the search named an ACD-exempt company of your agency: the fare is priced without ACD. - `reason` (string): Only on some ACD fares: why the rule applied. Informational; the wording can change. - `requiresACD` (boolean): Present only when `true`: the booking needs one passport copy for every traveler: see `upload_acd_documents`. - `payment` (object): The payment state of the order. - `status` (string): `awaiting_payment` for a hold, `paid` once ticketed. - `paidAt` (string, nullable): When the order was paid. - `paymentRequiredBy` (string, nullable): The payment deadline of a hold, when the supplier gives one. - `priceGuaranteeExpiresAt` (string, nullable): Until when the price is guaranteed. - `awaitingPayment` (boolean): `true` for a hold that still has to be paid. - `documents` (array of object): The e-tickets. Empty for a hold. - `type` (string): `electronic_ticket`. - `uniqueIdentifier` (string): The ticket number (Duffel sandbox: the text `1`). - `passengerIds` (array of string): The passengers the document is for. - `documentType` (string): `ETICKET`. - `status` (string): `ISSUED`. - `segmentIds` (array of string): The segments the document covers. - `bookingReferences` (array of object): The record locators. - `pnr` (string): The airline record locator. - `carrier` (object): The airline that issued the record locator. - `iataCode` (string): IATA code of the airline. - `name` (string): Name of the airline. - `logoUrl` (string): Address of the logo. - `logoLockupUrl` (string, nullable): Address of the wide logo. - `passengers` (array of object): The passengers. - `id` (string, deprecated): Legacy name: use `passenger_id`. Id of the passenger on the order: `"1"` (Amadeus) or `pas_...` (Duffel). - `passenger_id` (string): Id of the passenger on the order: `"1"` (Amadeus) or `pas_...` (Duffel). The snake_case twin of `id`, with the same value: use this name. - `type` (string, deprecated): Legacy name: use `passenger_type`. `ADULT`, `CHILD`, `INFANT_WITHOUT_SEAT` ... as the supplier writes it. - `passenger_type` (string): `ADULT`, `CHILD`, `INFANT_WITHOUT_SEAT` ... as the supplier writes it. The snake_case twin of `type`, with the same value: use this name. - `title` (string): `mr`, `mrs` ... derived from the gender. - `givenName` (string, deprecated): Legacy name: use `first_name`. Given name, as stored (Amadeus writes it in upper case). - `first_name` (string): Given name, as stored (Amadeus writes it in upper case). The snake_case twin of `givenName`, with the same value: use this name. - `familyName` (string, deprecated): Legacy name: use `last_name`. Family name, as stored. - `last_name` (string): Family name, as stored. The snake_case twin of `familyName`, with the same value: use this name. - `gender` (string): `male` or `female`. - `bornOn` (string, deprecated): Legacy name: use `date_of_birth`. Date of birth, `YYYY-MM-DD`. - `date_of_birth` (string): Date of birth, `YYYY-MM-DD`. The snake_case twin of `bornOn`, with the same value: use this name. - `email` (string): The e-mail of the contact. - `phoneNumber` (string, deprecated): Legacy name: use `phone_number`. The phone number of the contact. - `phone_number` (string): The phone number of the contact. The snake_case twin of `phoneNumber`, with the same value: use this name. - `infantPassengerId` (string, nullable): The id of the lap infant this adult carries. - `loyaltyProgrammeAccounts` (array of object): Frequent-flyer accounts. - `ticketNumber` (string, nullable): The ticket number of this passenger. `null` until the airline returns it. - `travelerPricing` (array of object): The price of each traveler. - `travelerId` (string, deprecated): Legacy name: use `passenger_id`. `"1"`, `"2"` ... for Amadeus, `pas_...` for Duffel. - `passenger_id` (string): `"1"`, `"2"` ... for Amadeus, `pas_...` for Duffel. The snake_case twin of `travelerId`, with the same value: use this name. - `travelerType` (string, deprecated): Legacy name: use `passenger_type`. `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). - `passenger_type` (string): `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). The snake_case twin of `travelerType`, with the same value: use this name. - `fareOption` (string): The fare option, for example `STANDARD`. - `price` (object): The price of one traveler. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. - `base` (number): The traveler's base fare in the agency currency. - `total` (number): The traveler's final price: what this traveler pays. The totals of all travelers add up to `pricing.finalPrice`. - `b2bPrice` (number): The traveler's share of the amount debited from your balance. - `fareDetailsBySegment` (array of object): The fare of this traveler on every segment. - `segmentId` (string): The `id` of the segment this fare detail is about. - `cabin` (string): Cabin of the fare on this segment. One of `ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS`, `FIRST`. - `fareBasis` (string, nullable): Fare basis code. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `bookingClass` (string, nullable): Booking class letter. - `includedCheckedBags` (object): The checked baggage included in the fare. - `quantity` (integer): Number of checked bags included in the fare. - `includedCabinBags` (object): The cabin baggage included in the fare. - `quantity` (integer): Number of cabin bags included in the fare. - `amenities` (array of object): What else the fare includes or sells (seat, bag, meal, Wi-Fi ...). - `itinerary` (array of object): The flights of the order. - `direction` (string): `outbound`, `return` ... - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `fareBrandName` (string): Name of the fare brand. - `origin` (object): Where this direction starts. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `timeZone` (string): IANA time zone. - `destination` (object): Where this direction ends. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `timeZone` (string): IANA time zone. - `segments` (array of object): The flights of this direction. - `id` (string): Id of the segment. - `departure` (object): Where and when a segment leaves or arrives. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `terminal` (string, nullable): Terminal, when the supplier says. - `datetime` (string): Local date and time at the airport, `YYYY-MM-DDTHH:MM:SS`, without a time zone. - `timeZone` (string): IANA time zone of the airport. - `arrival` (object): Where and when a segment leaves or arrives. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `terminal` (string, nullable): Terminal, when the supplier says. - `datetime` (string): Local date and time at the airport, `YYYY-MM-DDTHH:MM:SS`, without a time zone. - `timeZone` (string): IANA time zone of the airport. - `flight` (object): The airline and the number of the flight. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): The flight number. - `operatingCarrier` (string): IATA code of the operating airline. - `duration` (string): ISO 8601 duration. - `cabin` (string): The cabin. - `fareBasis` (string, nullable): Fare basis code. - `passengerDetails` (array of object): What each passenger has on this segment. - `conditions` (object, nullable): What the fare allows before departure. Indicative only. A Duffel order can carry `null` for both when the airline does not say. - `refundBeforeDeparture` (object, nullable): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `changeBeforeDeparture` (object, nullable): What the fare says about a change before departure. - `allowed` (boolean): Whether a change is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `conditions` (object): What the fare allows before departure. Indicative only. A Duffel order can carry `null` for both when the airline does not say. - `refundBeforeDeparture` (object, nullable): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `changeBeforeDeparture` (object, nullable): What the fare says about a change before departure. - `allowed` (boolean): Whether a change is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `fareRules` (object): What exchange, refund and revalidation allow. - `owner` (object): The airline that owns the order. - `id` (string, nullable): Duffel's id of the airline. - `iataCode` (string): IATA code of the airline that owns the order. - `name` (string): Name of the airline. - `logoUrl` (string): Address of the logo. - `logoLockupUrl` (string, nullable): Address of the wide logo. - `conditionsOfCarriageUrl` (string, nullable): Address of the conditions of carriage. - `services` (array of object): Extra services of the order (bags, seats). Empty. - `meta` (object): Capabilities of the order and what the supplier reports about it. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. Whether the supplier can hold. - `support_hold` (boolean): Whether the supplier can hold. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. Whether the supplier can void the ticket the same day. - `support_void` (boolean): Whether the supplier can void the ticket the same day. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. End of the same-day void window. - `void_deadline` (string, nullable): End of the same-day void window. The snake_case twin of `voidDeadline`, with the same value: use this name. - `voidWindowEndsAt` (string, nullable): End of the void window, when the supplier gives it. - `airlineInitiatedChanges` (array of object): Schedule changes the airline made. - `changes` (array of object): Changes of the order. - `users` (array of object): Users of the order. - `orderMetadata` (object, nullable): Supplier-specific metadata of the order. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's. - `exchange_rate` (number): Only when the supplier's currency is not your agency's. The snake_case twin of `exchangeRate`, with the same value: use this name. - `internalBooking` (object): The FX-Port identifiers of the booking. - `bookingId` (string): The PNR. - `internalId` (string): The FX-Port id of the booking (a UUID), the same as `postProcessing.bookingDbId` of the booking call. - `postProcessing` (object): What FX-Port did after the supplier call. - `bookingDbId` (string): The FX-Port id of the booking (a UUID). **The best key to store**: it is accepted as `booking_id` by `get_booking`. - `bucketLink` (string, nullable): Address of the PDF of the ticket, `null` until it is generated. A public link. - `emailSent` (boolean): Whether the confirmation e-mail went out. - `bookingStatusUpdated` (boolean): `cancel_order`: whether the FX-Port booking was updated. - `refundMethod` (string): `confirm_cancel`: where the refund went: `balance`. - `balanceDebited` (boolean): `book_flight` only. Always `false` in the sandbox: nothing is charged. - `transactionCreated` (boolean): `book_flight` only. Always `false` in the sandbox. - `payoutCreated` (boolean): `book_flight` only. Always `false` in the sandbox. - `error` (object): Only when `success` is `false`: the refusal of the supplier for `pay_order`. - `message` (string): The supplier's message. - `code` (string): The supplier's code: a number as text for Amadeus (`4926`), a name for Duffel (`order_not_cancellable`). - `type` (string): The supplier's error type. - `details` (array of object): The supplier's details, in its own format. - `userMessage` (string): A sentence the supplier wrote for the traveler, when there is one. - `errors` (array of object): Only when `success` is `false`: the unified error list. `errors[0].code` is the supplier's code in lower case and `errors[0].type` is `supplier_error`. - `code` (string): Stable, machine-readable code in snake_case. Branch on this, never on the message. - `type` (string): The family of the error: tells you how to react. One of `authentication_error`, `permission_error`, `validation_error`, `not_found`, `conflict`, `payment_error`, `rate_limit_error`, `supplier_error`, `internal_error`. - `title` (string): A short label of the error. - `message` (string): A sentence for a human: what is wrong and how to fix it. Wording can change; the code does not. - `field` (string, nullable): The request field the error is about, when there is one. - `details` (object, nullable): Structured extras of the error (for example `available` and `required` for an insufficient balance). - `documentation_url` (string): The documentation of this error code. ```json { "environment": "sandbox", "success": true, "requestId": "66e4b1a8", "searchId": "fs_85ac5f12-7456-4edf-a2fb-1d51239d278b", "offerId": "fx_offer_9de60e2a", "processingTime": 2.6235511302948, "booking": { "reference": "eJzTd9ePNA9wdA0FAAs8AmE", "status": "pending", "pnr": "Y7PAEU", "type": "hold", "createdAt": "2026-11-01T15:26:00.000", "cancelledAt": null, "content": "managed", "syncedAt": "2026-11-01T15:26:00.000", "availableActions": [ "confirm", "cancel" ], "voidableUntil": null, "cancellation": null }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus AQC", "offerId": "3" }, "agency": { "currency": "DZD" }, "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 3160, "taxesAndFees": 1765.44, "agencyCommission": 4.93, "finalPrice": 4930.37, "b2bPrice": 4925.44, "additionalServices": [], "acd": { "applied": false } }, "payment": { "status": "awaiting_payment", "paidAt": null, "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "awaitingPayment": true }, "documents": [], "bookingReferences": [ { "pnr": "Y7PAEU", "carrier": { "iataCode": "AH", "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg" } } ], "passengers": [ { "id": "1", "type": "ADULT", "title": "mr", "givenName": "JAMES", "familyName": "SMITH", "gender": "male", "bornOn": "1988-06-15", "email": "traveler@example.com", "phoneNumber": "15555550123", "infantPassengerId": null, "loyaltyProgrammeAccounts": [], "ticketNumber": null } ], "travelerPricing": [ { "travelerId": "1", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "base": 3160, "total": 4930.37, "b2bPrice": 4925.44 }, "fareDetailsBySegment": [ { "segmentId": "3", "cabin": "ECONOMY", "fareBasis": "Y", "brandedFare": "STANDARD", "brandedFareLabel": "STANDARD", "bookingClass": "Y", "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [] } ] } ], "itinerary": [ { "direction": "outbound", "duration": "PT1H", "stops": 0, "fareBrandName": "", "origin": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "timeZone": "Africa/Algiers" }, "destination": { "airportCode": "ORN", "airportName": "Oran International Airport", "cityName": "Oran", "countryCode": "DZ", "timeZone": "Africa/Algiers" }, "segments": [ { "id": "3", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "1", "datetime": "2026-12-22T19:45:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "ORN", "airportName": "Oran International Airport", "cityName": "Oran", "countryCode": "DZ", "terminal": null, "datetime": "2026-12-22T20:45:00", "timeZone": "Africa/Algiers" }, "flight": { "carrierCode": "AH", "carrierName": "Air Algerie", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "flightNumber": "6106", "aircraftCode": "73H", "aircraftName": null, "operatingCarrier": "AH", "operatingCarrierName": "Air Algerie", "operatingFlightNumber": "6106" }, "duration": "PT1H", "distance": null, "stops": [], "cabin": "ECONOMY", "cabinMarketingName": "Economy", "fareBasis": "Y", "brandedFare": "", "brandedFareLabel": "", "passengerDetails": [ { "passengerId": "1", "cabin": "ECONOMY", "cabinMarketingName": "Economy", "seat": null, "checkedBags": { "quantity": 1 }, "cabinBags": { "quantity": 1 } } ] } ], "conditions": { "changeBeforeDeparture": { "allowed": true } } } ], "conditions": { "refundBeforeDeparture": { "allowed": true }, "changeBeforeDeparture": { "allowed": true } }, "owner": { "id": null, "iataCode": "AH", "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg", "conditionsOfCarriageUrl": null }, "services": [], "meta": { "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "voidWindowEndsAt": null, "airlineInitiatedChanges": [], "changes": [], "users": [], "orderMetadata": null }, "fareRules": { "refund": { "allowed": true }, "exchange": { "allowed": true }, "agencyCurrency": "DZD" }, "postProcessing": { "bookingDbId": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "bucketLink": null, "emailSent": false }, "balanceDebited": false, "transactionCreated": false, "payoutCreated": false } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameters: search_id and offer_id are required | | 400 | `missing_field` | `passengers[0].first_name` is required. | | 400 | `invalid_booking_type` | `booking_type` must be "issue" (instant ticket) or "hold". | | 400 | `hold_not_supported` | This offer cannot be held; it must be booked instantly. | | 400 | `too_many_passengers` | A booking takes at most 18 passengers. | | 402 | `insufficient_balance` | Agency balance is insufficient. Available: 120.0 DZD, Required: 29876.81 DZD. | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `SEARCH_EXPIRED_OR_NOT_FOUND` | Search ID 'fs_does_not_exist' not found or has expired | | 404 | `OFFER_NOT_FOUND_IN_SEARCH` | Offer ID 'fx_offer_00000000' not found in search results | | 404 | `OFFER_NOT_PRICED` | Priced offer 'fx_offer_250b4eb7' not found in cache. Please price the offer first before booking. | | 409 | `OFFER_ALREADY_BOOKED` | This offer has already been booked successfully (PNR: BOKT52). Duplicate submissions are not allowed. | | 409 | `offer_request_already_booked` | Field 'selected_offers' has offers included in a offer request that has already been booked; please perform a new search | | 422 | `validation_type` | Field 'born_on' is invalid. Expected a date in ISO 8601 format (YYYY-MM-DD) | | 500 | `post_booking_failed` | The booking was placed but could not be recorded completely. Contact support with this request id before retrying. | | 503 | `supplier_temporarily_unavailable` | Supplier 'duffel_1' circuit breaker is open: retry in a few moments. | | 503 | `balance_lock_timeout` | The agency balance is busy with another operation. Retry in a few seconds. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Book a flight](https://fx-port.com/docs/flights/book.md) - [Flight passengers & ages](https://fx-port.com/docs/flights/passengers.md) - [Booking flows](https://fx-port.com/docs/flights/booking-flows.md) - [Upload ACD documents](https://fx-port.com/docs/flights/acd-documents.md) --- # Get a flight booking > Read a booking of your agency by its PNR, its supplier order id or its FX-Port id: status, prices, ticket number, the links of the PDF tickets and the ACD state. Endpoint: `POST /flights/get_booking` Source: https://fx-port.com/docs/api/get-booking `POST /api/v1/flights/get_booking` Requires an API key with **Read** permission; a **Read+Write** key works too. `booking_id` takes any of the three values (`order_id` and `pnr` are other names of the same field; when they are sent with a different value, `booking_id` wins and an `ignored_field` warning names the dropped one). Prefer the FX-Port `id` (a UUID) or the order id: **PNRs are not unique** across suppliers and airlines. A booking that is not yours, or that belongs to the other environment, is answered `404 booking_not_found`, the same as one that does not exist. `booking.status` is `confirmed`, `awaiting_payment` (a hold), `cancelled`, `partial_refund` or `failed`. `ticket_number` is `null` until the airline returns it: ask `get_ticket_number`. `acd_required` says whether the booking needs ACD passports and `acd_submitted` whether they are all uploaded; `acd_documents[]` and `acd_links` list the files that were uploaded (public links, without FX-Port's storage keys), but `get_acd_documents` is the better source for the state of each traveler. The links `pdf_url` and `pdf_url_no_price` (`bucket_link` and `bucket_link_no_price`) are **public**: anybody who has them can open the PDF, so treat them as sensitive. The booking is a database row: only the keys listed below are part of the contract. ## Request ### Body Required, JSON. - `booking_id` (string, optional): The PNR, the supplier order id or the FX-Port booking UUID. Prefer the UUID or the order id: PNRs are not unique. - `order_id` (string, optional): Another name of `booking_id`. - `pnr` (string, optional): Another name of `booking_id`. - `include_api_response` (boolean, optional): `get_booking` only: `true` adds `booking.apiResponse`. Default `false`. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR, the supplier order id or the FX-Port booking UUID. - `orderId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR, the supplier order id or the FX-Port booking UUID. - `includeApiResponse` (boolean, deprecated, optional): Legacy name: use `include_api_response`. The previous spelling of `include_api_response`: `get_booking` only: `true` adds `booking.apiResponse`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/get_booking \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7PAEU" }' ``` ## Response ### 200: The booking. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the booking was found. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `booking` (FlightBooking): A flight booking of your agency, as FX-Port stores it. The booking is a database row: more keys than the ones listed here exist (the void history, the details of a changed schedule ...); they are internal and not part of the contract. Where the ACD passports sit in FX-Port's storage is never returned: only their public links. ```json { "environment": "sandbox", "success": true, "requestId": "0c2b315c", "processingTime": 0.272, "booking": { "id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "booking_id": "Y7PAEU", "amadeus_id": "eJzTd9ePNA9wdA0FAAs8AmE", "status": "confirmed", "created_at": "2026-11-01T15:26:14.871035+00:00", "paid_at": "2026-11-01T15:26:21.961664+00:00", "updated_at": "2026-11-01T15:26:21.998944+00:00", "total_price": 4930.37, "agency_commission": 4.93, "currency": "DZD", "departure_airport": "ALG", "arrival_airport": "ORN", "departure_date": "2026-12-22T19:45:00", "arrival_date": "2026-12-22T20:45:00", "return_departure_date": null, "return_arrival_date": null, "included_checked_bags": "0", "ticketing_agreement": "DELAY_TO_CANCEL", "supplier_name": "Amadeus Algeria", "supplier_id": "amadeus_aqc_dz_1", "ticket_number": "124-2446977397", "bucket_link": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "bucket_link_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf", "email_sent": false, "require_acd": false, "acd_submitted": false, "acd_links": null, "acd_amount": null, "acd_paid": false, "acd_documents_revision": 0, "acd_documents": null, "has_changed": false, "new_flight_details": null, "voided_at": null, "refund_amount": null, "refund_currency": null, "refund_method": null, "agent_id": null, "environment": "sandbox", "pricingBreakdown": { "supplier_currency": "DZD", "agency_currency": "DZD", "base_price": 3160, "taxes_and_fees": 1765.44, "agency_commission": 4.93, "final_price": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:22.176516+00:00", "environment": "sandbox" }, "travelerPricing": [ { "traveler_id": "1", "traveler_type": "ADULT", "fare_option": "STANDARD", "supplier_currency": "DZD", "agency_currency": "DZD", "base": 3160, "total": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:15.133066+00:00", "environment": "sandbox" } ], "balanceOperations": [], "order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "pnr": "Y7PAEU", "pdf_url": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "pdf_url_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf", "acd_required": false, "requires_acd": false } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | booking_id is required | | 400 | `invalid_booking_id` | `booking_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 404 | `booking_not_found` | No booking found for 'ZZZZZZ' | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Get a booking](https://fx-port.com/docs/flights/get-booking.md) - [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md) --- # Get the ticket PDF of a booking > Get the download links of the ticket PDF of a booking (the hold voucher for a hold). Endpoint: `POST /flights/get_ticket` Source: https://fx-port.com/docs/api/get-ticket `POST /api/v1/flights/get_ticket` Requires an API key with **Read** permission; a **Read+Write** key works too. The PDF is generated after the booking: call again until `ticket_ready` is `true`. `ticket_ready` is `true` as soon as the booking has a PDF link, whatever its status: it does not mean that an e-ticket exists, ask `get_ticket_number` for that. `get_booking` returns the same links as `booking.pdf_url` and `booking.pdf_url_no_price` together with the rest of the booking. **The links are public.** Anybody who has a `pdf_url` can open the PDF with no key and no login; it holds the traveler's name, the itinerary and the fare. Do not log them, do not send them to third parties, and prefer to download the PDF on your server and serve it yourself. ## Request ### Body Required, JSON. - `booking_id` (string, optional): The PNR, the supplier order id or the FX-Port booking UUID. Prefer the UUID or the order id: PNRs are not unique. - `order_id` (string, optional): Another name of `booking_id`. - `pnr` (string, optional): Another name of `booking_id`. - `include_api_response` (boolean, optional): `get_booking` only: `true` adds `booking.apiResponse`. Default `false`. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR, the supplier order id or the FX-Port booking UUID. - `orderId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR, the supplier order id or the FX-Port booking UUID. - `includeApiResponse` (boolean, deprecated, optional): Legacy name: use `include_api_response`. The previous spelling of `include_api_response`: `get_booking` only: `true` adds `booking.apiResponse`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/get_ticket \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7PAEU" }' ``` ## Response ### 200: The links, or `ticket_ready: false` with a message while the PDF is being generated. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the booking was found. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `ticket_ready` (boolean): `true` as soon as the booking has a PDF link, whatever its status. It does not mean that an e-ticket exists: ask `get_ticket_number`. - `booking_id` (string): Echoes the value you sent: the PNR, the order id or the UUID. - `message` (string): Only when `ticket_ready` is `false`. - `download_url` (string, deprecated): Legacy name: use `pdf_url`. The PDF with the fare receipt. A public link. - `pdf_url` (string): The PDF with the fare receipt. A public link. The snake_case twin of `download_url`, with the same value: use this name. - `download_url_with_price` (string): The same address as `download_url`. - `download_url_no_price` (string, nullable, deprecated): Legacy name: use `pdf_url_no_price`. A second PDF of the same ticket without prices. A public link. - `pdf_url_no_price` (string, nullable): A second PDF of the same ticket without prices. A public link. The snake_case twin of `download_url_no_price`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "requestId": "71fdaeb7", "ticket_ready": true, "booking_id": "Y7PAEU", "download_url": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "download_url_with_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "download_url_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf" } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | booking_id is required | | 400 | `invalid_booking_id` | `booking_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 404 | `booking_not_found` | No booking found for 'ZZZZZZ' | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Get the ticket PDF](https://fx-port.com/docs/flights/get-ticket.md) - [Get a booking](https://fx-port.com/docs/flights/get-booking.md) --- # Get the e-ticket number of a booking > Get the e-ticket number of a booking, asking the airline for it when it is not stored yet. Endpoint: `POST /flights/get_ticket_number` Source: https://fx-port.com/docs/api/get-ticket-number `POST /api/v1/flights/get_ticket_number` Requires an API key with **Read** permission; a **Read+Write** key works too. Use it after an instant booking whose number has not arrived, or to find out whether the tickets of a booking were issued. This is a read endpoint that can **do work**: when the number is not stored, FX-Port asks the supplier (Amadeus, Travelport or Duffel) for the order, stores the number on the booking and can send the confirmation e-mail that was held back until the ticket exists. Calling it twice is harmless. Wait 5 to 10 seconds between calls instead of looping. `ticket_available: false` with a message means the airline has not returned a number yet. A hold has no ticket number until it is paid. `booking_response` is the stored answer of the booking, without the supplier's costs and commissions. Only bookings of your agency in the environment of your key are answered. ## Request ### Body Required, JSON. - `booking_id` (string, optional): The PNR, the supplier order id or the FX-Port booking UUID. Prefer the UUID or the order id: PNRs are not unique. - `order_id` (string, optional): Another name of `booking_id`. - `pnr` (string, optional): Another name of `booking_id`. - `include_api_response` (boolean, optional): `get_booking` only: `true` adds `booking.apiResponse`. Default `false`. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR, the supplier order id or the FX-Port booking UUID. - `orderId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR, the supplier order id or the FX-Port booking UUID. - `includeApiResponse` (boolean, deprecated, optional): Legacy name: use `include_api_response`. The previous spelling of `include_api_response`: `get_booking` only: `true` adds `booking.apiResponse`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/get_ticket_number \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7PAEU" }' ``` ## Response ### 200: The ticket number, or `ticket_available: false` with a message. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the booking was found. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `ticket_available` (boolean): `true` when the airline has returned a ticket number. - `ticket_number` (string, nullable): The e-ticket number, for example `124-2446977397` (Amadeus); in the Duffel sandbox the text `1`. Several tickets are joined in one text. `null` until available. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR. - `pnr` (string): The PNR. The snake_case twin of `booking_id`, with the same value: use this name. - `supplier_order_id` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `supplier_order_id`, with the same value: use this name. - `message` (string): Only when `ticket_available` is `false`. - `booking_response` (object): The stored answer of the booking, in the shape of the `book_flight` response, without the supplier's costs and commissions. ```json { "environment": "sandbox", "success": true, "requestId": "a8e2164f", "ticket_available": true, "ticket_number": "124-2446977397", "booking_id": "Y7PAEU", "supplier_order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "booking_response": { "booking": { "pnr": "Y7PAEU", "type": "instant", "status": "confirmed", "content": "managed", "syncedAt": "2026-11-01T15:26:21.896668", "createdAt": "2026-11-01T15:26:21.896668", "reference": "eJzTd9ePNA9wdA0FAAs8AmE", "cancelledAt": null, "cancellation": null, "voidableUntil": "2026-11-01T23:00:00Z", "availableActions": [] }, "supplier": { "id": "amadeus_aqc_dz_1", "name": "Amadeus AQC", "offerId": "1", "technology": "amadeus_aqc" }, "pricing": { "b2bPrice": 4736, "basePrice": 3160, "finalPrice": 4930.37, "taxesAndFees": 1770.37, "agencyCurrency": "DZD", "agencyCommission": 4.93, "supplierCurrency": "DZD", "additionalServices": [] }, "payment": { "paidAt": "2026-11-01T15:26:21.896710", "status": "paid", "awaitingPayment": false, "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null }, "documents": [ { "type": "electronic_ticket", "status": "ISSUED", "segmentIds": [ "1" ], "documentType": "ETICKET", "passengerIds": [ "1" ], "uniqueIdentifier": "124-2446977397" } ], "bookingReferences": [ { "pnr": "Y7PAEU", "carrier": { "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "iataCode": "AH", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg" } } ], "passengers": [ { "id": "1", "type": "ADULT", "email": "traveler@example.com", "title": "mr", "bornOn": "1988-06-15", "gender": "male", "givenName": "JAMES", "familyName": "SMITH", "phoneNumber": "15555550123", "ticketNumber": "124-2446977397", "infantPassengerId": null, "loyaltyProgrammeAccounts": [] } ], "owner": { "id": null, "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "iataCode": "AH", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg", "conditionsOfCarriageUrl": null } } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | booking_id is required | | 400 | `unsupported_supplier` | Ticket-number retrieval is only supported for Amadeus, Travelport and Duffel bookings. | | 404 | `booking_not_found` | No booking found for 'ZZZZZZ' | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Get the e-ticket number](https://fx-port.com/docs/flights/get-ticket-number.md) - [Get a booking](https://fx-port.com/docs/flights/get-booking.md) --- # Get the current quote of an order > Get the current price of an order, its status and whether it can still be paid. Endpoint: `POST /flights/get_order_quote` Source: https://fx-port.com/docs/api/get-order-quote `POST /api/v1/flights/get_order_quote` Requires an API key with **Read** permission; a **Read+Write** key works too. The step is optional: call it to preview or refresh the price of a held order before `pay_order`. Send one of `order_id` (the supplier's order id, **preferred**), `booking_id` and `pnr`: the first one present wins. The FX-Port UUID is not accepted here. An Amadeus order is answered from the stored booking; a Duffel order is priced by the supplier. The order must be a booking of your agency in the environment of your key: an unknown order and the order of another agency answer the same `404 not_found`, before any supplier is asked. **Read `success`.** When the supplier refuses the quote for an order FX-Port knows (a Duffel order that no longer exists at the supplier, for example) the answer is not a `404`: it is HTTP `200` with `success: false` and the supplier's own `error`, as in the second example. ## Request ### Body Required, JSON. - `order_id` (string, optional): The supplier's order id (`booking.reference` of the booking response). **Preferred**: a PNR is not unique. - `booking_id` (string, optional): The PNR of the booking. - `pnr` (string, optional): The airline record locator. - `supplier_id` (string, optional): Only used when the booking has no supplier of its own. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `orderId` (string, deprecated, optional): Legacy name: use `order_id`. The previous spelling of `order_id`: The supplier's order id (`booking.reference` of the booking response). - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR of the booking. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Only used when the booking has no supplier of its own. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/get_order_quote \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "eJzTd9ePNA9wdA0FAAs8AmE" }' ``` ## Response ### 200: The quote. `success: false` with `error` when the supplier refused it. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the quote could be built. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `orderQuote` (object): The quote. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `bookingReference` (string, deprecated): Legacy name: use `pnr`. The PNR. - `pnr` (string): The PNR. The snake_case twin of `bookingReference`, with the same value: use this name. - `status` (string): The status of the booking: `awaiting_payment`, `confirmed`, `cancelled`. - `pricing` (object): The agency-facing price of an offer, a booking or a quote. Every amount is in `agencyCurrency`; the rate, when the currencies differ, is `exchangeRate` at the top level of the response. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. Every amount of the block is in it. - `basePrice` (number, deprecated): Legacy name: use `base_price`. The airline's base fare, before taxes, in the agency currency. - `base_price` (number): The airline's base fare, before taxes, in the agency currency. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, deprecated): Legacy name: use `taxes_and_fees`. Taxes plus the platform's fees, folded together. Excludes your agency commission. - `taxes_and_fees` (number): Taxes plus the platform's fees, folded together. Excludes your agency commission. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. The commission of your agency on this sale. - `agency_commission` (number): The commission of your agency on this sale. The snake_case twin of `agencyCommission`, with the same value: use this name. - `finalPrice` (number, deprecated): Legacy name: use `final_price`. What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. - `final_price` (number): What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. The snake_case twin of `finalPrice`, with the same value: use this name. - `b2bPrice` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance: `finalPrice - agencyCommission`. - `b2b_price` (number): The amount debited from your agency balance: `finalPrice - agencyCommission`. The snake_case twin of `b2bPrice`, with the same value: use this name. - `totalAmount` (number): Order quote only: an alias of `b2bPrice`. - `taxAmount` (number): Order quote of a Duffel order only: the airline's taxes in your agency currency. - `originalTaxAmount` (number): Order quote of a Duffel order only: the same taxes in the supplier currency. - `additionalServices` (array of object): Paid extras. A list in the offers of a search (empty for most offers). - `acd` (object): Whether the fare is an ACD fare. Present on the offers of Amadeus Algeria, absent on Duffel offers. An ACD fare carries more keys with the amounts of the adjustment: they are informational, can change without notice and are not part of the contract. - `applied` (boolean): `true` for an ACD fare: the booking needs one passport copy for every traveler, sent with `upload_acd_documents`. `false` for an ordinary fare. - `scenario` (string): Only on an ACD fare: the rule that adjusted the price. Informational. One of `A`, `B`. - `exempt` (boolean): `true` when the search named an ACD-exempt company of your agency: the fare is priced without ACD. - `reason` (string): Only on some ACD fares: why the rule applied. Informational; the wording can change. - `paymentStatus` (object): Whether and until when the order can be paid. - `awaitingPayment` (boolean): `true` for a hold. - `paymentRequiredBy` (string, nullable): The payment deadline, when there is one. - `priceGuaranteeExpiresAt` (string, nullable): Until when the price is guaranteed. - `priceGuaranteed` (boolean): Whether the price of the hold is guaranteed. - `canPay` (boolean): Whether `pay_order` can pay the order now. - `liveMode` (boolean): `false` in the sandbox. - `supplier` (object): The supplier of the order. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `id`, with the same value: use this name. - `technology` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `offerId` (string, nullable, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. - `supplier_offer_id` (string, nullable): The supplier's own id of the offer. The snake_case twin of `offerId`, with the same value: use this name. - `agency` (object): Your agency. The id of the agency is not returned: you know it. - `currency` (string): ISO 4217 currency of your agency. Every amount of the response is in it. - `meta` (object): `priceGuaranteed`, `paymentDeadline`, `canPay`. - `exchangeRate` (object, deprecated): Legacy name: use `exchange_rate`. Duffel orders: `{ rate, marginPercent, fromCurrency, toCurrency }`. - `exchange_rate` (object): Duffel orders: `{ rate, marginPercent, fromCurrency, toCurrency }`. The snake_case twin of `exchangeRate`, with the same value: use this name. - `error` (object): Only when `success` is `false`: the refusal of the supplier for `get_order_quote`. - `message` (string): The supplier's message. - `code` (string): The supplier's code: a number as text for Amadeus (`4926`), a name for Duffel (`order_not_cancellable`). - `type` (string): The supplier's error type. - `details` (array of object): The supplier's details, in its own format. - `userMessage` (string): A sentence the supplier wrote for the traveler, when there is one. - `errors` (array of object): Only when `success` is `false`: the unified error list. `errors[0].code` is the supplier's code in lower case and `errors[0].type` is `supplier_error`. - `code` (string): Stable, machine-readable code in snake_case. Branch on this, never on the message. - `type` (string): The family of the error: tells you how to react. One of `authentication_error`, `permission_error`, `validation_error`, `not_found`, `conflict`, `payment_error`, `rate_limit_error`, `supplier_error`, `internal_error`. - `title` (string): A short label of the error. - `message` (string): A sentence for a human: what is wrong and how to fix it. Wording can change; the code does not. - `field` (string, nullable): The request field the error is about, when there is one. - `details` (object, nullable): Structured extras of the error (for example `available` and `required` for an insufficient balance). - `documentation_url` (string): The documentation of this error code. ```json { "environment": "sandbox", "success": true, "requestId": "469d8803", "orderId": "eJzTd9ePNA9wdA0FAAs8AmE", "processingTime": 0.134, "orderQuote": { "orderId": "eJzTd9ePNA9wdA0FAAs8AmE", "bookingReference": "Y7PAEU", "status": "awaiting_payment", "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 3160, "taxesAndFees": 1765.44, "agencyCommission": 4.93, "finalPrice": 4930.37, "b2bPrice": 4925.44, "additionalServices": [], "totalAmount": 4925.44 }, "paymentStatus": { "awaitingPayment": true, "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "priceGuaranteed": true, "canPay": true }, "liveMode": false }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus Algeria" }, "agency": { "currency": "DZD" }, "meta": { "priceGuaranteed": true, "paymentDeadline": null, "canPay": true } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameter: order_id (or booking_id / pnr) | | 400 | `invalid_booking_id` | `order_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 404 | `not_found` | Booking not found for order_id: ZZZZZZ | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Get an order quote](https://fx-port.com/docs/flights/get-order-quote.md) - [Pay for a held order](https://fx-port.com/docs/flights/pay-order.md) - [Booking flows](https://fx-port.com/docs/flights/booking-flows.md) --- # Pay a held order and issue the tickets > Pay for a held booking and issue the tickets. Endpoint: `POST /flights/pay_order` Source: https://fx-port.com/docs/api/pay-order `POST /api/v1/flights/pay_order` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. The agency balance is debited by `pricing.b2bPrice`; in the sandbox no money moves. The amount and the currency come from the stored booking: do **not** send `currency` (`400 bad_request`); an `amount` is ignored. Send one of `order_id` (the supplier's order id, **preferred**), `booking_id` and `pnr`. `payment_type` is `balance` (default), `card` or `arc_bsp_cash`; only Duffel reads it, Amadeus Algeria pays from the balance whatever you send. A Duffel order that was bought instantly cannot be paid later. **A refusal of the supplier is HTTP `200` with `success: false` and `paymentSucceeded: false`**: the status alone is not enough, read both. `error.code` is a number as text for Amadeus (`4926`: the order is already issued; `9112`: the reservation is cancelled or expired) and a name for Duffel. Do not retry a payment blindly: read the booking with `get_booking` first. A second `pay_order` for an order that is paid answers `4926`, which is harmless. The exception is `503 balance_lock_timeout` (live only): another operation of your agency held the balance for a moment, the payment never started, and the same request can be sent again after a few seconds. ## Request ### Body Required, JSON. - `order_id` (string, optional): The supplier's order id (`booking.reference` of the booking response). **Preferred**. - `booking_id` (string, optional): The PNR of the booking. - `pnr` (string, optional): The airline record locator. - `payment_type` (string, optional): Only Duffel reads it; Amadeus Algeria and Travelport pay from the balance whatever you send. Default `balance`. One of `balance`, `card`, `arc_bsp_cash`. Default `balance`. - `three_d_secure_session_id` (string, optional): For a Duffel card payment that needs 3-D Secure. - `price_guaranteed` (boolean, optional): Default `true`: pay the price of the hold. Leave it out. - `supplier_id` (string, optional): Only used when the booking has no supplier of its own. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `orderId` (string, deprecated, optional): Legacy name: use `order_id`. The previous spelling of `order_id`: The supplier's order id (`booking.reference` of the booking response). - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR of the booking. - `paymentType` (string, deprecated, optional): Legacy name: use `payment_type`. The previous spelling of `payment_type`: Only Duffel reads it; Amadeus Algeria and Travelport pay from the balance whatever you send. - `threeDSecureSessionId` (string, deprecated, optional): Legacy name: use `three_d_secure_session_id`. The previous spelling of `three_d_secure_session_id`: For a Duffel card payment that needs 3-D Secure. - `priceGuaranteed` (boolean, deprecated, optional): Legacy name: use `price_guaranteed`. The previous spelling of `price_guaranteed`: Default `true`: pay the price of the hold. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Only used when the booking has no supplier of its own. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/pay_order \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "payment_type": "balance" }' ``` ## Response ### 200: The paid order. `success: false` with `paymentSucceeded: false` and `error` when the supplier refused the payment. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the order was created (booking) or paid (payment). - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `paymentSucceeded` (boolean): `pay_order` only: whether the payment succeeded. Read it together with `success`. - `searchId` (string, deprecated): Legacy name: use `search_id`. `book_flight` only: the `search_id` you sent. - `search_id` (string): `book_flight` only: the `search_id` you sent. The snake_case twin of `searchId`, with the same value: use this name. - `offerId` (string, deprecated): Legacy name: use `offer_id`. `book_flight` only: the `offer_id` you sent. - `offer_id` (string): `book_flight` only: the `offer_id` you sent. The snake_case twin of `offerId`, with the same value: use this name. - `orderId` (string, deprecated): Legacy name: use `order_id`. `pay_order` only: the supplier's order id. - `order_id` (string): `pay_order` only: the supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `paymentDetails` (object): `pay_order` only: `paymentId`, `status`, `type` (`issuance`), `paymentAmount`, `currency`, `createdAt`, `message`. - `booking` (object): The order as the supplier knows it. - `reference` (string, deprecated): Legacy name: use `order_id`. The supplier's order id (`ord_...` for Duffel, a base64-like text for Amadeus; for a cancelled hold of Amadeus Algeria the PNR). Send it as `order_id` to `pay_order`, `cancel_order` and `cancel_quote`. - `order_id` (string): The supplier's order id (`ord_...` for Duffel, a base64-like text for Amadeus; for a cancelled hold of Amadeus Algeria the PNR). Send it as `order_id` to `pay_order`, `cancel_order` and `cancel_quote`. The snake_case twin of `reference`, with the same value: use this name. - `status` (string): `confirmed`, `pending` (a hold that is not paid yet), `awaiting_payment`, `cancelled`, `failed` or `partial_refund`. - `pnr` (string): The airline record locator. Not unique across suppliers and airlines. - `type` (string): `instant`: ticketed at once. `hold`: reserved, not paid. One of `instant`, `hold`. - `createdAt` (string): When the order was created. - `cancelledAt` (string, nullable): When the order was cancelled. - `content` (string): Who manages the order: `managed`. - `syncedAt` (string): When FX-Port last synchronised the order with the supplier. - `availableActions` (array of string): What can still be done with the order. - `voidableUntil` (string, nullable): ISO 8601 UTC end of the same-day void window. - `cancellation` (object, nullable): How the order was cancelled: `type`, `deleted`, `deletedAt`. - `supplier` (object): The supplier of the order. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `id`, with the same value: use this name. - `technology` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `offerId` (string, nullable, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. - `supplier_offer_id` (string, nullable): The supplier's own id of the offer. The snake_case twin of `offerId`, with the same value: use this name. - `agency` (object): Your agency. The id of the agency is not returned: you know it. - `currency` (string): ISO 4217 currency of your agency. Every amount of the response is in it. - `pricing` (object): The agency-facing price of an offer, a booking or a quote. Every amount is in `agencyCurrency`; the rate, when the currencies differ, is `exchangeRate` at the top level of the response. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. Every amount of the block is in it. - `basePrice` (number, deprecated): Legacy name: use `base_price`. The airline's base fare, before taxes, in the agency currency. - `base_price` (number): The airline's base fare, before taxes, in the agency currency. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, deprecated): Legacy name: use `taxes_and_fees`. Taxes plus the platform's fees, folded together. Excludes your agency commission. - `taxes_and_fees` (number): Taxes plus the platform's fees, folded together. Excludes your agency commission. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. The commission of your agency on this sale. - `agency_commission` (number): The commission of your agency on this sale. The snake_case twin of `agencyCommission`, with the same value: use this name. - `finalPrice` (number, deprecated): Legacy name: use `final_price`. What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. - `final_price` (number): What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. The snake_case twin of `finalPrice`, with the same value: use this name. - `b2bPrice` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance: `finalPrice - agencyCommission`. - `b2b_price` (number): The amount debited from your agency balance: `finalPrice - agencyCommission`. The snake_case twin of `b2bPrice`, with the same value: use this name. - `totalAmount` (number): Order quote only: an alias of `b2bPrice`. - `taxAmount` (number): Order quote of a Duffel order only: the airline's taxes in your agency currency. - `originalTaxAmount` (number): Order quote of a Duffel order only: the same taxes in the supplier currency. - `additionalServices` (array of object): Paid extras. A list in the offers of a search (empty for most offers). - `acd` (object): Whether the fare is an ACD fare. Present on the offers of Amadeus Algeria, absent on Duffel offers. An ACD fare carries more keys with the amounts of the adjustment: they are informational, can change without notice and are not part of the contract. - `applied` (boolean): `true` for an ACD fare: the booking needs one passport copy for every traveler, sent with `upload_acd_documents`. `false` for an ordinary fare. - `scenario` (string): Only on an ACD fare: the rule that adjusted the price. Informational. One of `A`, `B`. - `exempt` (boolean): `true` when the search named an ACD-exempt company of your agency: the fare is priced without ACD. - `reason` (string): Only on some ACD fares: why the rule applied. Informational; the wording can change. - `requiresACD` (boolean): Present only when `true`: the booking needs one passport copy for every traveler: see `upload_acd_documents`. - `payment` (object): The payment state of the order. - `status` (string): `awaiting_payment` for a hold, `paid` once ticketed. - `paidAt` (string, nullable): When the order was paid. - `paymentRequiredBy` (string, nullable): The payment deadline of a hold, when the supplier gives one. - `priceGuaranteeExpiresAt` (string, nullable): Until when the price is guaranteed. - `awaitingPayment` (boolean): `true` for a hold that still has to be paid. - `documents` (array of object): The e-tickets. Empty for a hold. - `type` (string): `electronic_ticket`. - `uniqueIdentifier` (string): The ticket number (Duffel sandbox: the text `1`). - `passengerIds` (array of string): The passengers the document is for. - `documentType` (string): `ETICKET`. - `status` (string): `ISSUED`. - `segmentIds` (array of string): The segments the document covers. - `bookingReferences` (array of object): The record locators. - `pnr` (string): The airline record locator. - `carrier` (object): The airline that issued the record locator. - `iataCode` (string): IATA code of the airline. - `name` (string): Name of the airline. - `logoUrl` (string): Address of the logo. - `logoLockupUrl` (string, nullable): Address of the wide logo. - `passengers` (array of object): The passengers. - `id` (string, deprecated): Legacy name: use `passenger_id`. Id of the passenger on the order: `"1"` (Amadeus) or `pas_...` (Duffel). - `passenger_id` (string): Id of the passenger on the order: `"1"` (Amadeus) or `pas_...` (Duffel). The snake_case twin of `id`, with the same value: use this name. - `type` (string, deprecated): Legacy name: use `passenger_type`. `ADULT`, `CHILD`, `INFANT_WITHOUT_SEAT` ... as the supplier writes it. - `passenger_type` (string): `ADULT`, `CHILD`, `INFANT_WITHOUT_SEAT` ... as the supplier writes it. The snake_case twin of `type`, with the same value: use this name. - `title` (string): `mr`, `mrs` ... derived from the gender. - `givenName` (string, deprecated): Legacy name: use `first_name`. Given name, as stored (Amadeus writes it in upper case). - `first_name` (string): Given name, as stored (Amadeus writes it in upper case). The snake_case twin of `givenName`, with the same value: use this name. - `familyName` (string, deprecated): Legacy name: use `last_name`. Family name, as stored. - `last_name` (string): Family name, as stored. The snake_case twin of `familyName`, with the same value: use this name. - `gender` (string): `male` or `female`. - `bornOn` (string, deprecated): Legacy name: use `date_of_birth`. Date of birth, `YYYY-MM-DD`. - `date_of_birth` (string): Date of birth, `YYYY-MM-DD`. The snake_case twin of `bornOn`, with the same value: use this name. - `email` (string): The e-mail of the contact. - `phoneNumber` (string, deprecated): Legacy name: use `phone_number`. The phone number of the contact. - `phone_number` (string): The phone number of the contact. The snake_case twin of `phoneNumber`, with the same value: use this name. - `infantPassengerId` (string, nullable): The id of the lap infant this adult carries. - `loyaltyProgrammeAccounts` (array of object): Frequent-flyer accounts. - `ticketNumber` (string, nullable): The ticket number of this passenger. `null` until the airline returns it. - `travelerPricing` (array of object): The price of each traveler. - `travelerId` (string, deprecated): Legacy name: use `passenger_id`. `"1"`, `"2"` ... for Amadeus, `pas_...` for Duffel. - `passenger_id` (string): `"1"`, `"2"` ... for Amadeus, `pas_...` for Duffel. The snake_case twin of `travelerId`, with the same value: use this name. - `travelerType` (string, deprecated): Legacy name: use `passenger_type`. `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). - `passenger_type` (string): `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). The snake_case twin of `travelerType`, with the same value: use this name. - `fareOption` (string): The fare option, for example `STANDARD`. - `price` (object): The price of one traveler. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. - `base` (number): The traveler's base fare in the agency currency. - `total` (number): The traveler's final price: what this traveler pays. The totals of all travelers add up to `pricing.finalPrice`. - `b2bPrice` (number): The traveler's share of the amount debited from your balance. - `fareDetailsBySegment` (array of object): The fare of this traveler on every segment. - `segmentId` (string): The `id` of the segment this fare detail is about. - `cabin` (string): Cabin of the fare on this segment. One of `ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS`, `FIRST`. - `fareBasis` (string, nullable): Fare basis code. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `bookingClass` (string, nullable): Booking class letter. - `includedCheckedBags` (object): The checked baggage included in the fare. - `quantity` (integer): Number of checked bags included in the fare. - `includedCabinBags` (object): The cabin baggage included in the fare. - `quantity` (integer): Number of cabin bags included in the fare. - `amenities` (array of object): What else the fare includes or sells (seat, bag, meal, Wi-Fi ...). - `itinerary` (array of object): The flights of the order. - `direction` (string): `outbound`, `return` ... - `duration` (string): ISO 8601 duration. - `stops` (integer): Number of stops. - `fareBrandName` (string): Name of the fare brand. - `origin` (object): Where this direction starts. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `timeZone` (string): IANA time zone. - `destination` (object): Where this direction ends. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `timeZone` (string): IANA time zone. - `segments` (array of object): The flights of this direction. - `id` (string): Id of the segment. - `departure` (object): Where and when a segment leaves or arrives. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `terminal` (string, nullable): Terminal, when the supplier says. - `datetime` (string): Local date and time at the airport, `YYYY-MM-DDTHH:MM:SS`, without a time zone. - `timeZone` (string): IANA time zone of the airport. - `arrival` (object): Where and when a segment leaves or arrives. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `terminal` (string, nullable): Terminal, when the supplier says. - `datetime` (string): Local date and time at the airport, `YYYY-MM-DDTHH:MM:SS`, without a time zone. - `timeZone` (string): IANA time zone of the airport. - `flight` (object): The airline and the number of the flight. - `carrierCode` (string): IATA code of the marketing airline. - `flightNumber` (string): The flight number. - `operatingCarrier` (string): IATA code of the operating airline. - `duration` (string): ISO 8601 duration. - `cabin` (string): The cabin. - `fareBasis` (string, nullable): Fare basis code. - `passengerDetails` (array of object): What each passenger has on this segment. - `conditions` (object, nullable): What the fare allows before departure. Indicative only. A Duffel order can carry `null` for both when the airline does not say. - `refundBeforeDeparture` (object, nullable): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `changeBeforeDeparture` (object, nullable): What the fare says about a change before departure. - `allowed` (boolean): Whether a change is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `conditions` (object): What the fare allows before departure. Indicative only. A Duffel order can carry `null` for both when the airline does not say. - `refundBeforeDeparture` (object, nullable): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `changeBeforeDeparture` (object, nullable): What the fare says about a change before departure. - `allowed` (boolean): Whether a change is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `fareRules` (object): What exchange, refund and revalidation allow. - `owner` (object): The airline that owns the order. - `id` (string, nullable): Duffel's id of the airline. - `iataCode` (string): IATA code of the airline that owns the order. - `name` (string): Name of the airline. - `logoUrl` (string): Address of the logo. - `logoLockupUrl` (string, nullable): Address of the wide logo. - `conditionsOfCarriageUrl` (string, nullable): Address of the conditions of carriage. - `services` (array of object): Extra services of the order (bags, seats). Empty. - `meta` (object): Capabilities of the order and what the supplier reports about it. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. Whether the supplier can hold. - `support_hold` (boolean): Whether the supplier can hold. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. Whether the supplier can void the ticket the same day. - `support_void` (boolean): Whether the supplier can void the ticket the same day. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. End of the same-day void window. - `void_deadline` (string, nullable): End of the same-day void window. The snake_case twin of `voidDeadline`, with the same value: use this name. - `voidWindowEndsAt` (string, nullable): End of the void window, when the supplier gives it. - `airlineInitiatedChanges` (array of object): Schedule changes the airline made. - `changes` (array of object): Changes of the order. - `users` (array of object): Users of the order. - `orderMetadata` (object, nullable): Supplier-specific metadata of the order. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. Only when the supplier's currency is not your agency's. - `exchange_rate` (number): Only when the supplier's currency is not your agency's. The snake_case twin of `exchangeRate`, with the same value: use this name. - `internalBooking` (object): The FX-Port identifiers of the booking. - `bookingId` (string): The PNR. - `internalId` (string): The FX-Port id of the booking (a UUID), the same as `postProcessing.bookingDbId` of the booking call. - `postProcessing` (object): What FX-Port did after the supplier call. - `bookingDbId` (string): The FX-Port id of the booking (a UUID). **The best key to store**: it is accepted as `booking_id` by `get_booking`. - `bucketLink` (string, nullable): Address of the PDF of the ticket, `null` until it is generated. A public link. - `emailSent` (boolean): Whether the confirmation e-mail went out. - `bookingStatusUpdated` (boolean): `cancel_order`: whether the FX-Port booking was updated. - `refundMethod` (string): `confirm_cancel`: where the refund went: `balance`. - `balanceDebited` (boolean): `book_flight` only. Always `false` in the sandbox: nothing is charged. - `transactionCreated` (boolean): `book_flight` only. Always `false` in the sandbox. - `payoutCreated` (boolean): `book_flight` only. Always `false` in the sandbox. - `error` (object): Only when `success` is `false`: the refusal of the supplier for `pay_order`. - `message` (string): The supplier's message. - `code` (string): The supplier's code: a number as text for Amadeus (`4926`), a name for Duffel (`order_not_cancellable`). - `type` (string): The supplier's error type. - `details` (array of object): The supplier's details, in its own format. - `userMessage` (string): A sentence the supplier wrote for the traveler, when there is one. - `errors` (array of object): Only when `success` is `false`: the unified error list. `errors[0].code` is the supplier's code in lower case and `errors[0].type` is `supplier_error`. - `code` (string): Stable, machine-readable code in snake_case. Branch on this, never on the message. - `type` (string): The family of the error: tells you how to react. One of `authentication_error`, `permission_error`, `validation_error`, `not_found`, `conflict`, `payment_error`, `rate_limit_error`, `supplier_error`, `internal_error`. - `title` (string): A short label of the error. - `message` (string): A sentence for a human: what is wrong and how to fix it. Wording can change; the code does not. - `field` (string, nullable): The request field the error is about, when there is one. - `details` (object, nullable): Structured extras of the error (for example `available` and `required` for an insufficient balance). - `documentation_url` (string): The documentation of this error code. ```json { "environment": "sandbox", "success": true, "paymentSucceeded": true, "requestId": "1ad06b56", "orderId": "eJzTd9ePNA9wdA0FAAs8AmE", "processingTime": 2.981, "paymentDetails": { "paymentId": null, "status": "succeeded", "type": "issuance", "paymentAmount": 4925.44, "currency": "DZD", "createdAt": "2026-11-01T15:26:21.896668", "message": "Payment successful! Tickets have been issued." }, "booking": { "reference": "eJzTd9ePNA9wdA0FAAs8AmE", "status": "confirmed", "pnr": "Y7PAEU", "type": "instant", "createdAt": "2026-11-01T15:26:21.896668", "cancelledAt": null, "content": "managed", "syncedAt": "2026-11-01T15:26:21.896668", "availableActions": [], "voidableUntil": "2026-11-01T23:00:00Z", "cancellation": null }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus AQC", "offerId": "1" }, "agency": { "currency": "DZD" }, "pricing": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "basePrice": 3160, "taxesAndFees": 1765.44, "agencyCommission": 4.93, "finalPrice": 4930.37, "b2bPrice": 4925.44, "additionalServices": [], "acd": { "applied": false } }, "payment": { "status": "paid", "paidAt": "2026-11-01T15:26:21.896710", "paymentRequiredBy": null, "priceGuaranteeExpiresAt": null, "awaitingPayment": false }, "documents": [ { "type": "electronic_ticket", "documentType": "ETICKET", "uniqueIdentifier": "124-2446977397", "status": "ISSUED", "passengerIds": [ "1" ], "segmentIds": [ "1" ] } ], "bookingReferences": [ { "pnr": "Y7PAEU", "carrier": { "iataCode": "AH", "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg" } } ], "passengers": [ { "id": "1", "type": "ADULT", "title": "mr", "givenName": "JAMES", "familyName": "SMITH", "gender": "male", "bornOn": "1988-06-15", "email": "traveler@example.com", "phoneNumber": "15555550123", "infantPassengerId": null, "loyaltyProgrammeAccounts": [], "ticketNumber": "124-2446977397" } ], "travelerPricing": [ { "travelerId": "1", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "DZD", "agencyCurrency": "DZD", "base": 3160, "total": 4930.37, "b2bPrice": 4925.44 }, "fareDetailsBySegment": [ { "segmentId": "1", "cabin": "ECONOMY", "fareBasis": "Y", "brandedFare": "STANDARD", "brandedFareLabel": "STANDARD", "bookingClass": "Y", "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [] } ] } ], "itinerary": [ { "direction": "outbound", "duration": "PT1H", "stops": 0, "fareBrandName": "", "origin": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "timeZone": "Africa/Algiers" }, "destination": { "airportCode": "ORN", "airportName": "Oran International Airport", "cityName": "Oran", "countryCode": "DZ", "timeZone": "Africa/Algiers" }, "segments": [ { "id": "1", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "1", "datetime": "2026-12-22T19:45:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "ORN", "airportName": "Oran International Airport", "cityName": "Oran", "countryCode": "DZ", "terminal": null, "datetime": "2026-12-22T20:45:00", "timeZone": "Africa/Algiers" }, "flight": { "carrierCode": "AH", "carrierName": "Air Algerie", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "flightNumber": "6106", "aircraftCode": "73H", "aircraftName": null, "operatingCarrier": "AH", "operatingCarrierName": "Air Algerie", "operatingFlightNumber": "6106" }, "duration": "PT1H", "distance": null, "stops": [], "cabin": "ECONOMY", "cabinMarketingName": "Economy", "fareBasis": "Y", "brandedFare": "", "brandedFareLabel": "", "passengerDetails": [ { "passengerId": "1", "cabin": "ECONOMY", "cabinMarketingName": "Economy", "seat": null, "checkedBags": { "quantity": 1 }, "cabinBags": { "quantity": 1 } } ] } ], "conditions": { "changeBeforeDeparture": { "allowed": true } } } ], "conditions": { "refundBeforeDeparture": { "allowed": true }, "changeBeforeDeparture": { "allowed": true } }, "owner": { "id": null, "iataCode": "AH", "name": "Air Algerie", "logoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/AH.svg", "logoLockupUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-lockup/AH.svg", "conditionsOfCarriageUrl": null }, "services": [], "internalBooking": { "bookingId": "Y7PAEU", "internalId": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7" }, "fareRules": { "refund": { "allowed": true }, "exchange": { "allowed": true }, "agencyCurrency": "DZD" }, "meta": { "paymentStatus": "succeeded", "orderConfirmed": true, "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "voidWindowEndsAt": null, "airlineInitiatedChanges": [], "changes": [], "users": [], "orderMetadata": null }, "postProcessing": { "bucketLink": null, "emailSent": false } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameter: order_id (or booking_id / pnr) | | 400 | `invalid_booking_id` | `order_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 400 | `bad_request` | Do not pass 'currency'. Currency is resolved automatically from booking/supplier context. | | 402 | `insufficient_balance` | Agency balance is insufficient. Available: 120.0 DZD, Required: 29876.81 DZD. | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `not_found` | Booking not found for order_id: ZZZZZZ | | 503 | `balance_lock_timeout` | The agency balance is busy with another operation. Retry in a few seconds. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Pay for a held order](https://fx-port.com/docs/flights/pay-order.md) - [Get an order quote](https://fx-port.com/docs/flights/get-order-quote.md) - [Booking flows](https://fx-port.com/docs/flights/booking-flows.md) --- # Cancel a held reservation > Cancel a held (unpaid) reservation: it is deleted at the supplier and the PNR is released. Endpoint: `POST /flights/cancel_order` Source: https://fx-port.com/docs/api/cancel-order `POST /api/v1/flights/cancel_order` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. This is not a refund and it does not apply to issued tickets. Only Amadeus Algeria and Travelport holds can be cancelled here; a Duffel order is answered `400`. Cancel a hold you abandon: the airline can refuse a new booking for the same passenger on the same route while the old reservation is pending. A booking that is not `awaiting_payment` (it is `confirmed` or already `cancelled`) is `409 invalid_booking_status`. To get money back for an issued Duffel order use `cancel_quote` then `confirm_cancel`; void, exchange and refund of an issued Amadeus Algeria ticket are manual (see Post-booking operations). Send one of `order_id` (preferred), `booking_id` and `pnr`. An unknown order and the order of another agency answer the same `404 not_found`. ## Request ### Body Required, JSON. - `order_id` (string, optional): The supplier's order id (`booking.reference` of the booking response). **Preferred**: a PNR is not unique. - `booking_id` (string, optional): The PNR of the booking. - `pnr` (string, optional): The airline record locator. - `supplier_id` (string, optional): Only used when the booking has no supplier of its own. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `orderId` (string, deprecated, optional): Legacy name: use `order_id`. The previous spelling of `order_id`: The supplier's order id (`booking.reference` of the booking response). - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: The PNR of the booking. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Only used when the booking has no supplier of its own. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/cancel_order \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "eJzTd9ePNA90dHUGAAsuAlA" }' ``` ## Response ### 200: The reservation was deleted and the booking is `cancelled`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the reservation was deleted. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `booking` (object): The order as the supplier knows it. - `reference` (string, deprecated): Legacy name: use `order_id`. The supplier's order id (`ord_...` for Duffel, a base64-like text for Amadeus; for a cancelled hold of Amadeus Algeria the PNR). Send it as `order_id` to `pay_order`, `cancel_order` and `cancel_quote`. - `order_id` (string): The supplier's order id (`ord_...` for Duffel, a base64-like text for Amadeus; for a cancelled hold of Amadeus Algeria the PNR). Send it as `order_id` to `pay_order`, `cancel_order` and `cancel_quote`. The snake_case twin of `reference`, with the same value: use this name. - `status` (string): `confirmed`, `pending` (a hold that is not paid yet), `awaiting_payment`, `cancelled`, `failed` or `partial_refund`. - `pnr` (string): The airline record locator. Not unique across suppliers and airlines. - `type` (string): `instant`: ticketed at once. `hold`: reserved, not paid. One of `instant`, `hold`. - `createdAt` (string): When the order was created. - `cancelledAt` (string, nullable): When the order was cancelled. - `content` (string): Who manages the order: `managed`. - `syncedAt` (string): When FX-Port last synchronised the order with the supplier. - `availableActions` (array of string): What can still be done with the order. - `voidableUntil` (string, nullable): ISO 8601 UTC end of the same-day void window. - `cancellation` (object, nullable): How the order was cancelled: `type`, `deleted`, `deletedAt`. - `supplier` (object): The supplier of the order. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `id`, with the same value: use this name. - `technology` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `offerId` (string, nullable, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. - `supplier_offer_id` (string, nullable): The supplier's own id of the offer. The snake_case twin of `offerId`, with the same value: use this name. - `agency` (object): Your agency. The id of the agency is not returned: you know it. - `currency` (string): ISO 4217 currency of your agency. Every amount of the response is in it. - `cancellation` (object): The result of the cancellation. - `status` (string): `deleted`. - `deleted` (boolean): `true` when the reservation was deleted at the supplier. - `deletedAt` (string): When it was deleted. - `refundAmount` (number): The refund: `0` for a hold (nothing was paid). - `refundCurrency` (string): Currency of the refund. - `refundTo` (string, nullable): Where a refund goes. - `message` (string): A sentence about the result. - `meta` (object): Capabilities of the order and what the supplier reports about it. - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. Whether the supplier can hold. - `support_hold` (boolean): Whether the supplier can hold. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. Whether the supplier can void the ticket the same day. - `support_void` (boolean): Whether the supplier can void the ticket the same day. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. End of the same-day void window. - `void_deadline` (string, nullable): End of the same-day void window. The snake_case twin of `voidDeadline`, with the same value: use this name. - `voidWindowEndsAt` (string, nullable): End of the void window, when the supplier gives it. - `airlineInitiatedChanges` (array of object): Schedule changes the airline made. - `changes` (array of object): Changes of the order. - `users` (array of object): Users of the order. - `orderMetadata` (object, nullable): Supplier-specific metadata of the order. - `postProcessing` (object): What FX-Port did after the supplier call. - `bookingDbId` (string): The FX-Port id of the booking (a UUID). **The best key to store**: it is accepted as `booking_id` by `get_booking`. - `bucketLink` (string, nullable): Address of the PDF of the ticket, `null` until it is generated. A public link. - `emailSent` (boolean): Whether the confirmation e-mail went out. - `bookingStatusUpdated` (boolean): `cancel_order`: whether the FX-Port booking was updated. - `refundMethod` (string): `confirm_cancel`: where the refund went: `balance`. ```json { "environment": "sandbox", "success": true, "requestId": "11487aa1", "orderId": "eJzTd9ePNA90dHUGAAsuAlA", "processingTime": 2.147, "booking": { "reference": "Y7QAEC", "status": "cancelled", "pnr": "Y7QAEC", "type": "hold", "createdAt": "2026-11-01T15:26:27.977426+00:00", "cancelledAt": "2026-11-01T15:26:30.748257", "content": "managed", "syncedAt": "2026-11-01T15:26:30.748257", "availableActions": [], "voidableUntil": null, "cancellation": { "type": "void", "deleted": true, "deletedAt": "2026-11-01T15:26:30.748257" } }, "supplier": { "id": "amadeus_aqc_dz_1", "technology": "amadeus_aqc", "name": "Amadeus Algeria", "offerId": null }, "agency": { "currency": "DZD" }, "cancellation": { "status": "deleted", "deleted": true, "deletedAt": "2026-11-01T15:26:30.748257", "refundAmount": 0, "refundCurrency": "DZD", "refundTo": null, "message": "Reservation deleted successfully." }, "meta": { "supportHold": true, "supportVoid": true, "voidDeadline": "2026-11-01T23:00:00Z", "voidWindowEndsAt": null, "airlineInitiatedChanges": [], "changes": [], "users": [], "orderMetadata": { "supplierStatusCode": 204, "supplierDeleteErrorIgnored": false, "supplierDeleteErrorCode": 204, "supplierDeleteErrorMessage": null } }, "postProcessing": { "bookingStatusUpdated": true } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameter: order_id (or booking_id / pnr) | | 400 | `invalid_booking_id` | `order_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 400 | `bad_request` | Delete reservation is currently only supported for Amadeus Algeria and Travelport hold bookings. Received supplier_id: duffel_1 | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `not_found` | Booking not found for order_id: ZZZZZZ | | 409 | `invalid_booking_status` | Booking is 'confirmed', expected awaiting_payment | | 500 | `booking_update_failed` | Reservation was deleted at the supplier, but local booking status could not be updated. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Cancel a held reservation](https://fx-port.com/docs/flights/cancel-order.md) - [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md) --- # Quote the refund of an issued order > Ask what cancelling an issued Duffel order would refund. Endpoint: `POST /flights/cancel_quote` Source: https://fx-port.com/docs/api/cancel-quote `POST /api/v1/flights/cancel_quote` Requires an API key with **Read** permission; a **Read+Write** key works too. The call creates a pending cancellation at the supplier with the refund amount; nothing is cancelled and no money moves until you confirm it with `confirm_cancel`. A quote is valid for one hour (`cancellation.expiresAt`) and the amount is the supplier's offer for that moment: ask again if you wait. Whether an order can be cancelled at all depends on the airline and the fare: many fares are refused with `order_not_cancellable`. Only Duffel orders are quoted; an order of another supplier is `400`. Held reservations are cancelled with `cancel_order`. Send one of `order_id` (the supplier's order id, `ord_...`), `booking_id` and `pnr`: FX-Port finds the booking of your agency and sends the supplier's own id to Duffel. An unknown order and the order of another agency answer the same `404 booking_not_found`. **Read `success`**: a refusal of the supplier is HTTP `200` with `success: false`. ## Request ### Body Required, JSON. - `order_id` (string, optional): The supplier's order id (`ord_...`). `booking_id` and `pnr` are accepted as other names; FX-Port sends the supplier's own id to Duffel. - `booking_id` (string, optional): Another name of `order_id`: the PNR. - `pnr` (string, optional): Another name of `order_id`: the airline record locator. - `supplier_id` (string, optional): Default `duffel_1`. A value that does not start with `duffel` is `400`. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `orderId` (string, deprecated, optional): Legacy name: use `order_id`. The previous spelling of `order_id`: The supplier's order id (`ord_...`). - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: Another name of `order_id`: the PNR. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Default `duffel_1`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/cancel_quote \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "order_id": "ord_0000BC0VDONwA54LrfoCkm" }' ``` ## Response ### 200: The pending cancellation and its refund. Show the amount to your customer, then confirm with `cancellation.cancellationId`. `success: false` with `error` when the supplier refuses. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the quote was created. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `cancellation` (object): A cancellation of an issued Duffel order. - `cancellationId` (string, deprecated): Legacy name: use `cancellation_id`. The `ore_...` id of the cancellation. Send it to `confirm_cancel`. - `cancellation_id` (string): The `ore_...` id of the cancellation. Send it to `confirm_cancel`. The snake_case twin of `cancellationId`, with the same value: use this name. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `status` (string): `pending` for a quote, `confirmed` once confirmed. - `refund` (object): The refund of a cancellation. - `originalRefundCurrency` (string): Currency of the airline's refund. - `originalRefundAmount` (number): The airline's refund, in its currency. - `refundCurrency` (string): Currency of your balance. - `refundAmount` (number): What is credited to your balance. - `refundTo` (string): Where the refund goes: `FX-Port Balance`. - `refundToRaw` (string): `balance`. - `convertedSupplierRefundAmount` (number): The airline's refund converted to your currency. - `isFullBalanceRefund` (boolean): Whether the whole payment is refunded. - `kioskCommissionRefund` (number): Commission taken back. - `expiresAt` (string): A quote is valid until this moment (one hour). - `confirmedAt` (string, nullable): When it was confirmed. - `createdAt` (string): When the quote was created. - `airlineCredits` (object, nullable): Credits the airline issues instead of a refund. - `liveMode` (boolean): `false` in the sandbox. - `message` (string): A sentence about the result (`confirm_cancel`). - `supplier` (object): The supplier of the order. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `id`, with the same value: use this name. - `technology` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `offerId` (string, nullable, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. - `supplier_offer_id` (string, nullable): The supplier's own id of the offer. The snake_case twin of `offerId`, with the same value: use this name. - `agency` (object): Your agency. The id of the agency is not returned: you know it. - `currency` (string): ISO 4217 currency of your agency. Every amount of the response is in it. - `exchangeRate` (object, deprecated): Legacy name: use `exchange_rate`. The exchange rate used for the refund. - `rate` (number): Units of the target currency for one unit of the source currency. - `marginPercent` (number): The margin included in the rate. - `fromCurrency` (string): Supplier currency. - `toCurrency` (string): Your currency. - `exchange_rate` (object): . The snake_case twin of `exchangeRate`, with the same value: use this name. - `rate` (number): Units of the target currency for one unit of the source currency. - `marginPercent` (number): The margin included in the rate. - `fromCurrency` (string): Supplier currency. - `toCurrency` (string): Your currency. - `meta` (object): `quoteValidUntil`, `isConfirmed`, `canConfirm`. - `error` (object): Only when `success` is `false`: the refusal of the supplier for `cancel_quote`. - `message` (string): The supplier's message. - `code` (string): The supplier's code: a number as text for Amadeus (`4926`), a name for Duffel (`order_not_cancellable`). - `type` (string): The supplier's error type. - `details` (array of object): The supplier's details, in its own format. - `userMessage` (string): A sentence the supplier wrote for the traveler, when there is one. - `errors` (array of object): Only when `success` is `false`: the unified error list. `errors[0].code` is the supplier's code in lower case and `errors[0].type` is `supplier_error`. - `code` (string): Stable, machine-readable code in snake_case. Branch on this, never on the message. - `type` (string): The family of the error: tells you how to react. One of `authentication_error`, `permission_error`, `validation_error`, `not_found`, `conflict`, `payment_error`, `rate_limit_error`, `supplier_error`, `internal_error`. - `title` (string): A short label of the error. - `message` (string): A sentence for a human: what is wrong and how to fix it. Wording can change; the code does not. - `field` (string, nullable): The request field the error is about, when there is one. - `details` (object, nullable): Structured extras of the error (for example `available` and `required` for an insufficient balance). - `documentation_url` (string): The documentation of this error code. ```json { "environment": "sandbox", "success": true, "requestId": "2cb63f69", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "processingTime": 0.128, "cancellation": { "cancellationId": "ore_0000BC0VG9WBaFAZ83u3yy", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "status": "pending", "refund": { "originalRefundCurrency": "USD", "originalRefundAmount": 76.86, "refundCurrency": "DZD", "refundAmount": 19215, "refundTo": "FX-Port Balance", "refundToRaw": "balance", "convertedSupplierRefundAmount": 19215, "isFullBalanceRefund": false, "kioskCommissionRefund": 0 }, "expiresAt": "2026-11-01T15:28:32Z", "confirmedAt": null, "createdAt": "2026-11-01T14:28:32.488592Z", "airlineCredits": null, "liveMode": false }, "supplier": { "id": "duffel_1", "technology": "duffel", "name": "Duffel" }, "agency": { "currency": "DZD" }, "exchangeRate": { "rate": 250, "marginPercent": 0, "fromCurrency": "USD", "toCurrency": "DZD" }, "meta": { "quoteValidUntil": "2026-11-01T15:28:32Z", "isConfirmed": false, "canConfirm": true } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameter: order_id (or booking_id / pnr) | | 400 | `invalid_booking_id` | `order_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 400 | `bad_request` | Cancellation quotes are currently only supported for Duffel. Received supplier_id: amadeus_aqc_dz_1 | | 404 | `booking_not_found` | No booking found for 'ZZZZZZ' | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Quote a cancellation](https://fx-port.com/docs/flights/cancel-quote.md) - [Confirm a cancellation](https://fx-port.com/docs/flights/confirm-cancel.md) - [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md) --- # Confirm the cancellation of an order > Confirm the cancellation you quoted with cancel_quote: the Duffel order is cancelled at the airline and the refund is credited to your FX-Port balance. Endpoint: `POST /flights/confirm_cancel` Source: https://fx-port.com/docs/api/confirm-cancel `POST /api/v1/flights/confirm_cancel` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. **This cannot be undone.** Show the amount of `cancel_quote` to your customer before you call this endpoint, and call it once: a second call answers `already_cancelled`. Send `cancellation_id` (the `ore_...` id of the quote) **and** `order_id` (or `booking_id` or `pnr`): the order id makes FX-Port check that the booking is yours and update the booking and the balance, so a call without it is `400 missing_field` and nothing is cancelled. An unknown order and the order of another agency answer the same `404 booking_not_found`. **Read `success`**: a refusal of the supplier is HTTP `200` with `success: false`. ## Request ### Body Required, JSON. - `cancellation_id` (string, required): The `ore_...` id of `cancel_quote` (`cancellation.cancellationId`). - `order_id` (string, optional): The supplier's order id of the booking (`booking_id` and `pnr` are accepted as other names). **Required**: it makes FX-Port check that the booking is yours and update it. - `booking_id` (string, optional): Another name of `order_id`. - `pnr` (string, optional): Another name of `order_id`. - `supplier_id` (string, optional): Default `duffel_1`. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `cancellationId` (string, deprecated, optional): Legacy name: use `cancellation_id`. The previous spelling of `cancellation_id`: The `ore_...` id of `cancel_quote` (`cancellation.cancellationId`). - `orderId` (string, deprecated, optional): Legacy name: use `order_id`. The previous spelling of `order_id`: The supplier's order id of the booking (`booking_id` and `pnr` are accepted as other names). - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The previous spelling of `booking_id`: Another name of `order_id`. - `supplierId` (string, deprecated, optional): Legacy name: use `supplier_id`. The previous spelling of `supplier_id`: Default `duffel_1`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The previous spelling of `agency_id`: Not needed: the agency of a call is always the one of your API key. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/confirm_cancel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "cancellation_id": "ore_0000BC0VG9WBaFAZ83u3yy", "order_id": "ord_0000BC0VDONwA54LrfoCkm" }' ``` ## Response ### 200: The order is cancelled and the refund is on its way to your balance. `success: false` with `error` when the supplier refuses. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the cancellation was confirmed. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `cancellationId` (string): Only when `success` is `false`: the cancellation you tried to confirm. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds spent. - `processing_time` (number): Seconds spent. The snake_case twin of `processingTime`, with the same value: use this name. - `confirmation` (object): A cancellation of an issued Duffel order. - `cancellationId` (string, deprecated): Legacy name: use `cancellation_id`. The `ore_...` id of the cancellation. Send it to `confirm_cancel`. - `cancellation_id` (string): The `ore_...` id of the cancellation. Send it to `confirm_cancel`. The snake_case twin of `cancellationId`, with the same value: use this name. - `orderId` (string, deprecated): Legacy name: use `order_id`. The supplier's order id. - `order_id` (string): The supplier's order id. The snake_case twin of `orderId`, with the same value: use this name. - `status` (string): `pending` for a quote, `confirmed` once confirmed. - `refund` (object): The refund of a cancellation. - `originalRefundCurrency` (string): Currency of the airline's refund. - `originalRefundAmount` (number): The airline's refund, in its currency. - `refundCurrency` (string): Currency of your balance. - `refundAmount` (number): What is credited to your balance. - `refundTo` (string): Where the refund goes: `FX-Port Balance`. - `refundToRaw` (string): `balance`. - `convertedSupplierRefundAmount` (number): The airline's refund converted to your currency. - `isFullBalanceRefund` (boolean): Whether the whole payment is refunded. - `kioskCommissionRefund` (number): Commission taken back. - `expiresAt` (string): A quote is valid until this moment (one hour). - `confirmedAt` (string, nullable): When it was confirmed. - `createdAt` (string): When the quote was created. - `airlineCredits` (object, nullable): Credits the airline issues instead of a refund. - `liveMode` (boolean): `false` in the sandbox. - `message` (string): A sentence about the result (`confirm_cancel`). - `supplier` (object): The supplier of the order. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier. - `supplier_id` (string): Id of the supplier. The snake_case twin of `id`, with the same value: use this name. - `technology` (string): The technology of the supplier: `amadeus_aqc` or `duffel`. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `offerId` (string, nullable, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. - `supplier_offer_id` (string, nullable): The supplier's own id of the offer. The snake_case twin of `offerId`, with the same value: use this name. - `agency` (object): Your agency. The id of the agency is not returned: you know it. - `currency` (string): ISO 4217 currency of your agency. Every amount of the response is in it. - `exchangeRate` (object, deprecated): Legacy name: use `exchange_rate`. The exchange rate used for the refund. - `rate` (number): Units of the target currency for one unit of the source currency. - `marginPercent` (number): The margin included in the rate. - `fromCurrency` (string): Supplier currency. - `toCurrency` (string): Your currency. - `exchange_rate` (object): . The snake_case twin of `exchangeRate`, with the same value: use this name. - `rate` (number): Units of the target currency for one unit of the source currency. - `marginPercent` (number): The margin included in the rate. - `fromCurrency` (string): Supplier currency. - `toCurrency` (string): Your currency. - `meta` (object): `cancellationConfirmedAt` and `refundProcessing`. - `postProcessing` (object): What FX-Port did after the supplier call. - `bookingDbId` (string): The FX-Port id of the booking (a UUID). **The best key to store**: it is accepted as `booking_id` by `get_booking`. - `bucketLink` (string, nullable): Address of the PDF of the ticket, `null` until it is generated. A public link. - `emailSent` (boolean): Whether the confirmation e-mail went out. - `bookingStatusUpdated` (boolean): `cancel_order`: whether the FX-Port booking was updated. - `refundMethod` (string): `confirm_cancel`: where the refund went: `balance`. - `error` (object): Only when `success` is `false`: the refusal of the supplier for `confirm_cancel`. - `message` (string): The supplier's message. - `code` (string): The supplier's code: a number as text for Amadeus (`4926`), a name for Duffel (`order_not_cancellable`). - `type` (string): The supplier's error type. - `details` (array of object): The supplier's details, in its own format. - `userMessage` (string): A sentence the supplier wrote for the traveler, when there is one. - `errors` (array of object): Only when `success` is `false`: the unified error list. `errors[0].code` is the supplier's code in lower case and `errors[0].type` is `supplier_error`. - `code` (string): Stable, machine-readable code in snake_case. Branch on this, never on the message. - `type` (string): The family of the error: tells you how to react. One of `authentication_error`, `permission_error`, `validation_error`, `not_found`, `conflict`, `payment_error`, `rate_limit_error`, `supplier_error`, `internal_error`. - `title` (string): A short label of the error. - `message` (string): A sentence for a human: what is wrong and how to fix it. Wording can change; the code does not. - `field` (string, nullable): The request field the error is about, when there is one. - `details` (object, nullable): Structured extras of the error (for example `available` and `required` for an insufficient balance). - `documentation_url` (string): The documentation of this error code. ```json { "environment": "sandbox", "success": true, "requestId": "64666d3c", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "processingTime": 0.245, "confirmation": { "cancellationId": "ore_0000BC0VG9WBaFAZ83u3yy", "orderId": "ord_0000BC0VDONwA54LrfoCkm", "status": "confirmed", "refund": { "originalRefundCurrency": "USD", "originalRefundAmount": 76.86, "refundCurrency": "DZD", "refundAmount": 19215, "refundTo": "FX-Port Balance", "refundToRaw": "balance", "convertedSupplierRefundAmount": 19215, "isFullBalanceRefund": false, "kioskCommissionRefund": 0 }, "confirmedAt": "2026-11-01T14:30:04.851325Z", "createdAt": "2026-11-01T14:28:32.488592Z", "airlineCredits": null, "liveMode": false, "message": "Order cancellation confirmed. Refund will be processed." }, "supplier": { "id": "duffel_1", "technology": "duffel", "name": "Duffel" }, "agency": { "currency": "DZD" }, "exchangeRate": { "rate": 250, "marginPercent": 0, "fromCurrency": "USD", "toCurrency": "DZD" }, "meta": { "cancellationConfirmedAt": "2026-11-01T14:30:04.851325Z", "refundProcessing": { "method": "FX-Port Balance", "estimatedDays": "Immediate", "description": "Refund credited to your FX-Port balance" } }, "postProcessing": { "refundMethod": "balance", "emailSent": false } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `bad_request` | Missing required parameter: cancellation_id | | 400 | `missing_field` | `order_id` (or `booking_id` / `pnr`) is required together with `cancellation_id`. | | 400 | `invalid_cancellation_id` | `cancellation_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `booking_not_found` | No booking found for 'ZZZZZZ' | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Confirm a cancellation](https://fx-port.com/docs/flights/confirm-cancel.md) - [Quote a cancellation](https://fx-port.com/docs/flights/cancel-quote.md) - [Post-booking operations](https://fx-port.com/docs/flights/post-booking.md) --- # Get the ACD document state of a booking > Read what a booking needs for ACD, which travelers already have a passport on file, and whether you can upload. Endpoint: `POST /flights/get_acd_documents` Source: https://fx-port.com/docs/api/get-acd-documents `POST /api/v1/flights/get_acd_documents` Requires an API key with **Read** permission; a **Read+Write** key works too. Call it before every upload and use its `traveler_id` values and `missing_traveler_ids`: do not build traveler ids yourself. A booking needs ACD passports when its fare has `pricing.acd.applied: true` (`requiresACD: true` on the offer and the booking): one passport copy for every traveler on the booking. `acd_required` of `get_booking` and `acd.acd_required` here say whether passports are still expected. The endpoint answers `200` for every booking of your agency, ACD or not: for a booking without ACD `acd_required` is `false`, `can_upload` is `false` and `blocked_reason` is `ACD_NOT_REQUIRED`. Travelers are listed either way. `ticket_number` of a traveler is `null` until the airline returns one (always for a hold). `booking_id` takes the PNR, the supplier order id or the FX-Port UUID (`order_id` and `pnr` are other names of the same field). Only bookings of your agency in the environment of your key are found: a booking of another agency, a booking of the other environment and an id that does not exist all answer the same `404 BOOKING_NOT_FOUND`, so an answer never tells whether a booking exists. The `download_url` of a document is a **public** address that needs no key: anybody who has it can open the passport, so treat it as sensitive. ## Request ### Body Required, JSON. - `booking_id` (string, required): The PNR, the supplier order id or the FX-Port booking UUID. Letters, digits and `- _ . : = ~`, up to 128 characters. `order_id` and `pnr` are accepted as other names of the same field. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The older spelling of `booking_id`. - `orderId` (string, deprecated, optional): Legacy name: use `booking_id`. The older spelling of `booking_id`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The older spelling of `agency_id`. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/get_acd_documents \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "Y7IA34" }' ``` ## Response ### 200: The ACD state of the booking. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): Always `true` here: a booking without ACD is a `200` too. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR, whatever identifier you sent. - `pnr` (string): The PNR, whatever identifier you sent. The snake_case twin of `booking_id`, with the same value: use this name. - `acd` (object): The ACD state of a booking: what is needed, what was uploaded, and whether you can upload. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR of the booking. - `pnr` (string): The PNR of the booking. The snake_case twin of `booking_id`, with the same value: use this name. - `acd_required` (boolean): `true` when the booking needs ACD passports and FX-Port did not waive them. - `acd_waived` (boolean): `true` when FX-Port marked ACD as not required for this booking. No upload is accepted then. You cannot set it. - `acd_submitted` (boolean): `true` once every traveler has a passport. Never `null`. Also `true` for a hold that has a complete set. - `acd_paid` (boolean): `true` once FX-Port's team has marked the ACD amount of the booking as paid. You cannot set it. - `complete` (boolean): Every required passport is uploaded. Always `false` when ACD is not required. - `can_upload` (boolean): Whether `upload_acd_documents` accepts files for this booking right now. - `blocked_reason` (string, nullable): Why `can_upload` is `false`. `null` otherwise. One of `ACD_NOT_REQUIRED`, `ACD_WAIVED`, `BOOKING_NOT_ELIGIBLE`, `TRAVELERS_NOT_AVAILABLE`. - `required_documents` (integer): The number of passports needed: one for each traveler of the booking. `0` when ACD is not required. - `uploaded_documents` (integer): How many of the required passports are uploaded. - `missing_documents` (integer): How many are still missing. - `missing_traveler_ids` (array of string): The travelers who still need a passport. Use these values in `traveler_ids`. - `travelers` (array of object): Every traveler of the booking. - `traveler_id` (string): The id to send in `traveler_ids`: the id of the passenger on the booking (`"1"`, `"2"` for Amadeus Algeria, `pas_...` for Duffel). - `traveler_name` (string): The name on the booking. - `traveler_type` (string): The passenger type, for example `ADULT`. - `ticket_number` (string, nullable): The e-ticket number. `null` until the airline returns one: always for a hold, and for an instant booking whose number has not arrived. - `status` (string): Whether this traveler has a passport. One of `uploaded`, `missing`. - `document` (object, nullable): The current passport file of a traveler. - `filename` (string, nullable): The file name you sent. - `content_type` (string): The type FX-Port detected from the content (for example `application/pdf`, `image/jpeg`), not the one you sent. - `size_bytes` (integer): The size of the stored file. - `download_url` (string): A **public** address of the file: anyone who has it can download the passport. Treat it as sensitive. - `uploaded_at` (string): ISO 8601 UTC time of the upload, with `+00:00`. - `replaced_count` (integer): How many times the passport was replaced. - `unassigned_documents` (array of object): Files that do not count as a traveler's passport. - `download_url` (string): A public address of the file. - `revision` (integer): Increases by one with each upload that stored a file. `0` for a booking without any. - `limits` (object): The upload limits. Read them instead of hardcoding them. - `accepted_formats` (array of string): The 11 accepted formats. - `accepted_extensions` (array of string): The 20 extensions of those formats. - `max_file_size_bytes` (integer): The largest accepted file: 10485760 (10 MiB). - `max_request_size_bytes` (integer): The largest total size of the files of one request: 26214400 (25 MiB). - `max_documents_per_request` (integer): The number of travelers of the booking: the most files one request can carry. ```json { "environment": "sandbox", "success": true, "requestId": "8201f6f6", "booking_id": "Y7IA34", "acd": { "booking_id": "Y7IA34", "acd_required": true, "acd_waived": false, "acd_submitted": false, "acd_paid": false, "complete": false, "can_upload": true, "blocked_reason": null, "required_documents": 3, "uploaded_documents": 1, "missing_documents": 2, "missing_traveler_ids": [ "1", "3" ], "travelers": [ { "traveler_id": "1", "traveler_name": "JANE DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null }, { "traveler_id": "2", "traveler_name": "JOHN DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "uploaded", "document": { "filename": "john-passport.pdf", "content_type": "application/pdf", "size_bytes": 482113, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/2/20261101142618_ec9632ab.pdf", "uploaded_at": "2026-11-01T14:26:18.120000+00:00", "replaced_count": 0 } }, { "traveler_id": "3", "traveler_name": "JAMES DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null } ], "unassigned_documents": [], "revision": 1, "limits": { "accepted_formats": [ "avif", "bmp", "gif", "heif", "jp2", "jpeg", "jxl", "pdf", "png", "tiff", "webp" ], "accepted_extensions": [ "avif", "bmp", "dib", "gif", "heic", "heif", "j2k", "jfif", "jp2", "jpeg", "jpg", "jpx", "jxl", "pdf", "pjp", "pjpeg", "png", "tif", "tiff", "webp" ], "max_file_size_bytes": 10485760, "max_request_size_bytes": 26214400, "max_documents_per_request": 3 } } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `MISSING_BOOKING_ID` | Missing required field: booking_id | | 400 | `INVALID_BOOKING_ID` | `booking_id` is not a valid identifier (letters, digits and - _ . : = ~ only, up to 128 characters). | | 404 | `BOOKING_NOT_FOUND` | No flight booking found for 'ZZZZZZ'. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Upload ACD documents](https://fx-port.com/docs/flights/acd-documents.md) --- # Upload ACD passport documents > Add or replace the passport copies of a booking that needs ACD: one file for each traveler, sent as multipart/form-data (not JSON). Endpoint: `POST /flights/upload_acd_documents` Source: https://fx-port.com/docs/api/upload-acd-documents `POST /api/v1/flights/upload_acd_documents` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. **The form.** `booking_id` is the PNR, the supplier order id or the FX-Port UUID of a booking of your agency (any other booking, also one of another agency, answers `404 BOOKING_NOT_FOUND`, like an unknown id). `traveler_ids` and `documents` are matched **by order**: the first id goes with the first file. Send `traveler_ids` once for each file, or as one comma-separated value (`1,2`), or as a JSON array as text (`["1","2"]`); the `documents` field is repeated, one file each. `replace=true` overwrites a traveler who already has a passport; without it that traveler is refused (`DOCUMENT_ALREADY_UPLOADED`) and nothing changes. The older names `bookingId`, `orderId`, `travelerIds`, `files`, `file` and `document` are still read. **The files.** A PDF or an image (JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000, JPEG XL), recognised **by content** (the extension and the `Content-Type` you send are not trusted). At most 10 MiB per file and 25 MiB per request, and no more files than travelers. Several files with problems are all reported in `results[]`: a request with any bad file stores nothing. **Reading the answer.** `200` means every file was saved, `207` means only some were (`status: partial_success`; read `results[]`), `4xx` means nothing changed. A `5xx` after the files were stored cannot be told from the outside: call `get_acd_documents` before you retry. A retry without `replace` is safe; with `replace=true` it replaces the file again. `notification` says whether the ACD team was told: in the sandbox it is never sent (`skipped: true`, `reason: sandbox_environment`). A hold accepts uploads (`reason: hold_booking`). The `download_url` links are public: treat them as sensitive. In the sandbox, a booking of `CDG` to `ALG` on `amadeus_aqc_dz_1` is an ACD booking: use fake files (a PDF or PNG a few bytes long), never a real document. ## Request ### Body Required, multipart/form-data. - `booking_id` (string, required): The PNR, the supplier order id or the FX-Port booking UUID. `order_id` and `pnr` are accepted as other names. When the form holds several spellings with different values, the first of `booking_id`, `bookingId`, `order_id`, `orderId`, `pnr` wins and an `ignored_field` warning names each dropped one. - `traveler_ids` (array of string, required): The travelers the files belong to, **in the same order as `documents`**. Send the field once for each file, or one comma-separated value (`1,2`), or a JSON array as text (`["1","2"]`). A traveler may appear once per request. - `documents` (array of string, required): One passport file for each traveler: PDF or an image, 10 MiB at most each, 25 MiB for all together. The field name must be `documents`. - `replace` (string, optional): `true` to overwrite travelers that already have a passport (`1`, `yes`, `on` and `0`, `no`, `off` are read too). Default `false`. One of `true`, `false`. - `agency_id` (string, optional): Not needed: the agency of a call is always the one of your API key. The field is accepted so that older clients keep working, and it is ignored. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The older spelling of `booking_id`. - `orderId` (string, deprecated, optional): Legacy name: use `booking_id`. The older spelling of `booking_id`. - `agencyId` (string, deprecated, optional): Legacy name: use `agency_id`. The older spelling of `agency_id`. - `travelerIds` (string, deprecated, optional): Legacy name: use `traveler_ids`. The older spelling of `traveler_ids`. - `travelerId` (string, deprecated, optional): Legacy name: use `traveler_ids`. The older spelling of `traveler_ids`. - `traveler_id` (string, deprecated, optional): Legacy name: use `traveler_ids`. The older spelling of `traveler_ids`. - `files` (string, deprecated, optional): Legacy name: use `documents`. The older spelling of `documents`. - `file` (string, deprecated, optional): Legacy name: use `documents`. The older spelling of `documents`. - `document` (string, deprecated, optional): Legacy name: use `documents`. The older spelling of `documents`. ### Example request ```bash curl https://api.fx-port.com/api/v1/flights/upload_acd_documents \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -F 'booking_id=Y7HWMD' \ -F 'traveler_ids=1' \ -F 'documents=@passport.pdf;type=application/pdf' ``` ## Response ### 200: Every file was saved. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Problems that did not stop the upload. - `success` (boolean): `true` for `200` and for `207`. - `status` (string): `success`: every file stored. `partial_success`: some stored, some failed (HTTP `207`). One of `success`, `partial_success`. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR. - `pnr` (string): The PNR. The snake_case twin of `booking_id`, with the same value: use this name. - `message` (string): A sentence: `2 documents saved.` - `summary` (object): The counts of this request. - `received` (integer): Files in the request. - `uploaded` (integer): New passports. - `replaced` (integer): Replaced passports. - `failed` (integer): Files that failed. - `results` (array of object): One entry for each file, in the order you sent them. - `index` (integer): The position of the file in your request, from 0. - `traveler_id` (string): The traveler of this file. - `traveler_name` (string, nullable): The name on the booking, `null` when the id is not on the booking. - `filename` (string): The file name you sent (`document-` when the part had none). - `status` (string): `uploaded`: a new passport. `replaced`: a passport that existed was replaced. `failed`: this file has a problem (see `error`). `skipped`: this file is valid, but another file of the request failed, so it was not processed. One of `uploaded`, `replaced`, `failed`, `skipped`. - `content_type` (string): Only for `uploaded` and `replaced`: the detected type. - `size_bytes` (integer): Only for `uploaded` and `replaced`: the size of the stored file. - `download_url` (string): Only for `uploaded` and `replaced`: the public address of the stored file. - `error` (object): Only for `failed`: what is wrong with this file. - `code` (string): The code of the problem. - `message` (string): A sentence about it. - `expected_traveler_ids` (array of string): `UNKNOWN_TRAVELER_ID`: the ids of the booking. - `accepted_extensions` (array of string): `UNSUPPORTED_FILE_TYPE`: the accepted extensions. - `max_file_size_bytes` (integer): `FILE_TOO_LARGE`: the limit. - `reason` (string): `STORAGE_UPLOAD_FAILED`: always the fixed text `storage_error`. The message of the storage system is never returned. - `acd` (object): The ACD state of a booking: what is needed, what was uploaded, and whether you can upload. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR of the booking. - `pnr` (string): The PNR of the booking. The snake_case twin of `booking_id`, with the same value: use this name. - `acd_required` (boolean): `true` when the booking needs ACD passports and FX-Port did not waive them. - `acd_waived` (boolean): `true` when FX-Port marked ACD as not required for this booking. No upload is accepted then. You cannot set it. - `acd_submitted` (boolean): `true` once every traveler has a passport. Never `null`. Also `true` for a hold that has a complete set. - `acd_paid` (boolean): `true` once FX-Port's team has marked the ACD amount of the booking as paid. You cannot set it. - `complete` (boolean): Every required passport is uploaded. Always `false` when ACD is not required. - `can_upload` (boolean): Whether `upload_acd_documents` accepts files for this booking right now. - `blocked_reason` (string, nullable): Why `can_upload` is `false`. `null` otherwise. One of `ACD_NOT_REQUIRED`, `ACD_WAIVED`, `BOOKING_NOT_ELIGIBLE`, `TRAVELERS_NOT_AVAILABLE`. - `required_documents` (integer): The number of passports needed: one for each traveler of the booking. `0` when ACD is not required. - `uploaded_documents` (integer): How many of the required passports are uploaded. - `missing_documents` (integer): How many are still missing. - `missing_traveler_ids` (array of string): The travelers who still need a passport. Use these values in `traveler_ids`. - `travelers` (array of object): Every traveler of the booking. - `traveler_id` (string): The id to send in `traveler_ids`: the id of the passenger on the booking (`"1"`, `"2"` for Amadeus Algeria, `pas_...` for Duffel). - `traveler_name` (string): The name on the booking. - `traveler_type` (string): The passenger type, for example `ADULT`. - `ticket_number` (string, nullable): The e-ticket number. `null` until the airline returns one: always for a hold, and for an instant booking whose number has not arrived. - `status` (string): Whether this traveler has a passport. One of `uploaded`, `missing`. - `document` (object, nullable): The current passport file of a traveler. - `filename` (string, nullable): The file name you sent. - `content_type` (string): The type FX-Port detected from the content (for example `application/pdf`, `image/jpeg`), not the one you sent. - `size_bytes` (integer): The size of the stored file. - `download_url` (string): A **public** address of the file: anyone who has it can download the passport. Treat it as sensitive. - `uploaded_at` (string): ISO 8601 UTC time of the upload, with `+00:00`. - `replaced_count` (integer): How many times the passport was replaced. - `unassigned_documents` (array of object): Files that do not count as a traveler's passport. - `download_url` (string): A public address of the file. - `revision` (integer): Increases by one with each upload that stored a file. `0` for a booking without any. - `limits` (object): The upload limits. Read them instead of hardcoding them. - `accepted_formats` (array of string): The 11 accepted formats. - `accepted_extensions` (array of string): The 20 extensions of those formats. - `max_file_size_bytes` (integer): The largest accepted file: 10485760 (10 MiB). - `max_request_size_bytes` (integer): The largest total size of the files of one request: 26214400 (25 MiB). - `max_documents_per_request` (integer): The number of travelers of the booking: the most files one request can carry. - `notification` (object): Whether the ACD team was notified. The e-mail goes to FX-Port, not to you or the traveler. - `sent` (boolean): Whether FX-Port's ACD team was e-mailed. - `type` (string): `submitted` when the set became complete, `updated` after a replacement on a complete set. Absent when nothing was due. One of `submitted`, `updated`. - `skipped` (boolean): `true` when no e-mail was due or sent: see `reason`. - `reason` (string): Why no e-mail was sent: `awaiting_remaining_documents`, `hold_booking` or `sandbox_environment`; with `error` it is the fixed text `email_error` (the message of the mail system is never returned). - `error` (string): `EMAIL_SEND_FAILED` when the e-mail failed. The files are saved. ```json { "environment": "sandbox", "success": true, "status": "success", "requestId": "58bfcf4a", "booking_id": "Y7IA34", "message": "2 documents saved.", "summary": { "received": 2, "uploaded": 2, "replaced": 0, "failed": 0 }, "results": [ { "index": 0, "traveler_id": "1", "traveler_name": "JANE DOE", "filename": "passport-1.png", "status": "uploaded", "content_type": "image/png", "size_bytes": 69, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/1/20261101142620_28e115c8.png" }, { "index": 1, "traveler_id": "3", "traveler_name": "JAMES DOE", "filename": "passport-3.pdf", "status": "uploaded", "content_type": "application/pdf", "size_bytes": 191, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/3/20261101142620_4194315e.pdf" } ], "acd": { "booking_id": "Y7IA34", "acd_required": true, "acd_waived": false, "acd_submitted": true, "acd_paid": false, "complete": true, "can_upload": true, "blocked_reason": null, "required_documents": 3, "uploaded_documents": 3, "missing_documents": 0, "missing_traveler_ids": [], "travelers": [ { "traveler_id": "1", "traveler_name": "JANE DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null }, { "traveler_id": "2", "traveler_name": "JOHN DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "uploaded", "document": { "filename": "john-passport.pdf", "content_type": "application/pdf", "size_bytes": 482113, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/2/20261101142618_ec9632ab.pdf", "uploaded_at": "2026-11-01T14:26:18.120000+00:00", "replaced_count": 0 } }, { "traveler_id": "3", "traveler_name": "JAMES DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null } ], "unassigned_documents": [], "revision": 2, "limits": { "accepted_formats": [ "avif", "bmp", "gif", "heif", "jp2", "jpeg", "jxl", "pdf", "png", "tiff", "webp" ], "accepted_extensions": [ "avif", "bmp", "dib", "gif", "heic", "heif", "j2k", "jfif", "jp2", "jpeg", "jpg", "jpx", "jxl", "pdf", "pjp", "pjpeg", "png", "tif", "tiff", "webp" ], "max_file_size_bytes": 10485760, "max_request_size_bytes": 26214400, "max_documents_per_request": 3 } }, "notification": { "sent": false, "type": "submitted", "skipped": true, "reason": "sandbox_environment" }, "warnings": [] } ``` ### 207: Some files were saved and others failed. `results[]` says which, with a code for each; send the failed travelers again. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Problems that did not stop the upload. - `success` (boolean): `true` for `200` and for `207`. - `status` (string): `success`: every file stored. `partial_success`: some stored, some failed (HTTP `207`). One of `success`, `partial_success`. - `requestId` (string, deprecated): Legacy name: use `request_id`. The id of this request, 8 characters, for support. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR. - `pnr` (string): The PNR. The snake_case twin of `booking_id`, with the same value: use this name. - `message` (string): A sentence: `2 documents saved.` - `summary` (object): The counts of this request. - `received` (integer): Files in the request. - `uploaded` (integer): New passports. - `replaced` (integer): Replaced passports. - `failed` (integer): Files that failed. - `results` (array of object): One entry for each file, in the order you sent them. - `index` (integer): The position of the file in your request, from 0. - `traveler_id` (string): The traveler of this file. - `traveler_name` (string, nullable): The name on the booking, `null` when the id is not on the booking. - `filename` (string): The file name you sent (`document-` when the part had none). - `status` (string): `uploaded`: a new passport. `replaced`: a passport that existed was replaced. `failed`: this file has a problem (see `error`). `skipped`: this file is valid, but another file of the request failed, so it was not processed. One of `uploaded`, `replaced`, `failed`, `skipped`. - `content_type` (string): Only for `uploaded` and `replaced`: the detected type. - `size_bytes` (integer): Only for `uploaded` and `replaced`: the size of the stored file. - `download_url` (string): Only for `uploaded` and `replaced`: the public address of the stored file. - `error` (object): Only for `failed`: what is wrong with this file. - `code` (string): The code of the problem. - `message` (string): A sentence about it. - `expected_traveler_ids` (array of string): `UNKNOWN_TRAVELER_ID`: the ids of the booking. - `accepted_extensions` (array of string): `UNSUPPORTED_FILE_TYPE`: the accepted extensions. - `max_file_size_bytes` (integer): `FILE_TOO_LARGE`: the limit. - `reason` (string): `STORAGE_UPLOAD_FAILED`: always the fixed text `storage_error`. The message of the storage system is never returned. - `acd` (object): The ACD state of a booking: what is needed, what was uploaded, and whether you can upload. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR of the booking. - `pnr` (string): The PNR of the booking. The snake_case twin of `booking_id`, with the same value: use this name. - `acd_required` (boolean): `true` when the booking needs ACD passports and FX-Port did not waive them. - `acd_waived` (boolean): `true` when FX-Port marked ACD as not required for this booking. No upload is accepted then. You cannot set it. - `acd_submitted` (boolean): `true` once every traveler has a passport. Never `null`. Also `true` for a hold that has a complete set. - `acd_paid` (boolean): `true` once FX-Port's team has marked the ACD amount of the booking as paid. You cannot set it. - `complete` (boolean): Every required passport is uploaded. Always `false` when ACD is not required. - `can_upload` (boolean): Whether `upload_acd_documents` accepts files for this booking right now. - `blocked_reason` (string, nullable): Why `can_upload` is `false`. `null` otherwise. One of `ACD_NOT_REQUIRED`, `ACD_WAIVED`, `BOOKING_NOT_ELIGIBLE`, `TRAVELERS_NOT_AVAILABLE`. - `required_documents` (integer): The number of passports needed: one for each traveler of the booking. `0` when ACD is not required. - `uploaded_documents` (integer): How many of the required passports are uploaded. - `missing_documents` (integer): How many are still missing. - `missing_traveler_ids` (array of string): The travelers who still need a passport. Use these values in `traveler_ids`. - `travelers` (array of object): Every traveler of the booking. - `traveler_id` (string): The id to send in `traveler_ids`: the id of the passenger on the booking (`"1"`, `"2"` for Amadeus Algeria, `pas_...` for Duffel). - `traveler_name` (string): The name on the booking. - `traveler_type` (string): The passenger type, for example `ADULT`. - `ticket_number` (string, nullable): The e-ticket number. `null` until the airline returns one: always for a hold, and for an instant booking whose number has not arrived. - `status` (string): Whether this traveler has a passport. One of `uploaded`, `missing`. - `document` (object, nullable): The current passport file of a traveler. - `filename` (string, nullable): The file name you sent. - `content_type` (string): The type FX-Port detected from the content (for example `application/pdf`, `image/jpeg`), not the one you sent. - `size_bytes` (integer): The size of the stored file. - `download_url` (string): A **public** address of the file: anyone who has it can download the passport. Treat it as sensitive. - `uploaded_at` (string): ISO 8601 UTC time of the upload, with `+00:00`. - `replaced_count` (integer): How many times the passport was replaced. - `unassigned_documents` (array of object): Files that do not count as a traveler's passport. - `download_url` (string): A public address of the file. - `revision` (integer): Increases by one with each upload that stored a file. `0` for a booking without any. - `limits` (object): The upload limits. Read them instead of hardcoding them. - `accepted_formats` (array of string): The 11 accepted formats. - `accepted_extensions` (array of string): The 20 extensions of those formats. - `max_file_size_bytes` (integer): The largest accepted file: 10485760 (10 MiB). - `max_request_size_bytes` (integer): The largest total size of the files of one request: 26214400 (25 MiB). - `max_documents_per_request` (integer): The number of travelers of the booking: the most files one request can carry. - `notification` (object): Whether the ACD team was notified. The e-mail goes to FX-Port, not to you or the traveler. - `sent` (boolean): Whether FX-Port's ACD team was e-mailed. - `type` (string): `submitted` when the set became complete, `updated` after a replacement on a complete set. Absent when nothing was due. One of `submitted`, `updated`. - `skipped` (boolean): `true` when no e-mail was due or sent: see `reason`. - `reason` (string): Why no e-mail was sent: `awaiting_remaining_documents`, `hold_booking` or `sandbox_environment`; with `error` it is the fixed text `email_error` (the message of the mail system is never returned). - `error` (string): `EMAIL_SEND_FAILED` when the e-mail failed. The files are saved. ```json { "environment": "sandbox", "success": true, "status": "partial_success", "requestId": "3d7a91c0", "booking_id": "Y7IA34", "message": "1 of 2 documents saved; 1 failed — retry the failed travelers.", "summary": { "received": 2, "uploaded": 1, "replaced": 0, "failed": 1 }, "results": [ { "index": 0, "traveler_id": "1", "traveler_name": "JANE DOE", "filename": "passport-1.png", "status": "uploaded", "content_type": "image/png", "size_bytes": 69, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/1/20261101142620_28e115c8.png" }, { "index": 1, "traveler_id": "3", "traveler_name": "JAMES DOE", "filename": "passport-3.pdf", "status": "failed", "error": { "code": "STORAGE_UPLOAD_FAILED", "message": "The document could not be stored. Retry this traveler.", "reason": "storage_error" } } ], "acd": { "booking_id": "Y7IA34", "acd_required": true, "acd_waived": false, "acd_submitted": false, "acd_paid": false, "complete": false, "can_upload": true, "blocked_reason": null, "required_documents": 3, "uploaded_documents": 2, "missing_documents": 1, "missing_traveler_ids": [ "3" ], "travelers": [ { "traveler_id": "1", "traveler_name": "JANE DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null }, { "traveler_id": "2", "traveler_name": "JOHN DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "uploaded", "document": { "filename": "john-passport.pdf", "content_type": "application/pdf", "size_bytes": 482113, "download_url": "https://s3.example.com/fx-port/acd_passports/Y7IA34/2/20261101142618_ec9632ab.pdf", "uploaded_at": "2026-11-01T14:26:18.120000+00:00", "replaced_count": 0 } }, { "traveler_id": "3", "traveler_name": "JAMES DOE", "traveler_type": "ADULT", "ticket_number": null, "status": "missing", "document": null } ], "unassigned_documents": [], "revision": 2, "limits": { "accepted_formats": [ "avif", "bmp", "gif", "heif", "jp2", "jpeg", "jxl", "pdf", "png", "tiff", "webp" ], "accepted_extensions": [ "avif", "bmp", "dib", "gif", "heic", "heif", "j2k", "jfif", "jp2", "jpeg", "jpg", "jpx", "jxl", "pdf", "pjp", "pjpeg", "png", "tif", "tiff", "webp" ], "max_file_size_bytes": 10485760, "max_request_size_bytes": 26214400, "max_documents_per_request": 3 } }, "notification": { "sent": false, "skipped": true, "reason": "awaiting_remaining_documents" }, "warnings": [] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `DOCUMENT_COUNT_MISMATCH` | traveler_ids and documents must have the same number of entries (they are matched by order). | | 400 | `VALIDATION_FAILED` | 2 of 2 documents were rejected. Nothing was uploaded. | | 400 | `MISSING_BOOKING_ID` | Missing required form field: booking_id | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `BOOKING_NOT_FOUND` | No flight booking found for 'ZZZZZZ'. | | 409 | `DOCUMENT_ALREADY_UPLOADED` | A passport is already uploaded for traveler '2'. Send replace=true to replace it. | | 409 | `ACD_NOT_REQUIRED` | This booking does not require ACD documents. | | 413 | `FILE_TOO_LARGE` | Uploaded document 'big.pdf' exceeds the 10 MB limit. | | 415 | `UNSUPPORTED_FILE_TYPE` | 'bad.pdf' is not a supported passport document. Upload a PDF or a JPEG, PNG, WebP, HEIC/HEIF, AVIF, TIFF, BMP, GIF, JPEG 2000 or JPEG XL image. | | 415 | `UNSUPPORTED_MEDIA_TYPE` | Send multipart/form-data with the fields booking_id, traveler_ids and documents. | | 502 | `STORAGE_UPLOAD_FAILED` | None of the documents could be stored. Nothing was changed. Please retry. | | 503 | `STORAGE_UNAVAILABLE` | Document storage is temporarily unavailable. Please retry. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Upload ACD documents](https://fx-port.com/docs/flights/acd-documents.md) --- # Search destinations and get coordinates > Turns the text a user typed (a city, an area, an airport, a hotel) into places with coordinates. Endpoint: `GET /hotels/locations/search` Source: https://fx-port.com/docs/api/search-hotel-locations `GET /api/v1/hotels/locations/search` Requires an API key with **Read** permission; a **Read+Write** key works too. Pass the `latitude` and `longitude` of the place the user picks to `search_advanced`, with a `radius`. The results mix cities, airports and hotels of a location provider: the `id` is the provider's, not a FX-Port hotel id, and `type` is always `location` (the kind of place is `raw.loctype`). Places without coordinates are dropped. The answer is the same in the sandbox and on live. Cache the answers for a user's session and debounce typeahead calls (about 300 ms). ## Request ### Query parameters - `query` (string, required): The text to look for: a city, an area, an airport or a hotel name, 1 to 64 characters once spaces and control characters are cleaned. - `locale` (string, optional): Language of the names: `en`, `fr`, `ar`, `es`, `de`, `it`, `ru`, `tr`, `ja`, `ko` ... Written `ll` or `ll-RR` (`es-AR`); any other value answers in English. Default `en`. - `limit` (integer, optional): Most places to return, 1 to 50. Between 1 and 50. Default `10`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/hotels/locations/search?query=Tegucigalpa&limit=3' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The places that match, best first. `locations` is empty when nothing matches. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the lookup worked, also with no result. - `query` (string): The text you searched for. - `resultsCount` (integer): How many places are in `locations`. - `locations` (array of object): The places that match, best first. Places without coordinates are dropped before `limit` is applied. - `id` (string): The id of the place at the location provider (a city id, an airport code or a hotel id of the provider). It is not a FX-Port hotel id. - `name` (string): Name of the place. - `displayName` (string): Name with its context, for a list. - `city` (string): City of the place. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `latitude` (number): Latitude: send it to `search_advanced`. - `longitude` (number): Longitude: send it to `search_advanced`. - `type` (string): Always `location`. The kind of place is `raw.loctype`: `city`, `ap` (airport), `hotel` ... - `raw` (object): The record of the location provider, about 40 keys (`loctype`, `lat`, `lng`, `cc`, `cityname`, `timezone`, `box_*`, `destination_images` ...). Best effort: unlisted keys can change. ```json { "environment": "sandbox", "success": true, "query": "Tegucigalpa", "resultsCount": 2, "locations": [ { "id": "20119", "name": "Tegucigalpa", "displayName": "Tegucigalpa, Honduras", "city": "Tegucigalpa", "countryCode": "HN", "latitude": 14.094204124743296, "longitude": -87.19410705810549, "type": "location", "raw": { "id": "20119", "displayname": "Tegucigalpa, Honduras", "loctype": "city", "cid": 107, "lat": 14.094204124743296, "lng": -87.19410705810549, "cc": "HN", "country": "Honduras", "timezone": "America/Tegucigalpa", "utc": "-06:00", "ap": "TGU" } }, { "id": "TGU", "name": "Toncontin", "displayName": "Toncontin, Tegucigalpa, Honduras, (TGU)", "city": "Tegucigalpa", "countryCode": "HN", "latitude": 14.06088, "longitude": -87.2172, "type": "location", "raw": { "id": "TGU", "displayname": "Toncontin, Tegucigalpa, Honduras, (TGU)", "loctype": "ap", "cid": 107, "lat": 14.06088, "lng": -87.2172, "cc": "HN", "country": "Honduras", "airportname": "Toncontin", "ap": "TGU" } } ] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 422 | `validation_error` | Input should be less than or equal to 50 | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Search locations](https://fx-port.com/docs/hotels/locations.md) --- # Find a hotel by name > Fuzzy search over the names of every hotel FX-Port knows. Endpoint: `GET /hotels/name-search` Source: https://fx-port.com/docs/api/get-hotel-name-search `GET /api/v1/hotels/name-search` Requires an API key with **Read** permission; a **Read+Write** key works too. It tolerates typos, partial names and accents. Each hit carries the FX-Port hotel id to send as `hotel_id` to `get_rates` and `search_advanced`. Hits with a `match_score` below about 80 are often other hotels (a search for `Sofitel Algiers` also finds a hotel called `Algiers` in Las Vegas): show the score or keep the best few. The ids are the same in the sandbox and on live, and a hit is not necessarily bookable in the sandbox. `POST /hotels/search-by-name` takes the same fields as a JSON body. ## Request ### Query parameters - `query` (string, required): The hotel name or a part of it, 2 to 100 characters. - `limit` (integer, optional): Most hits to return, 1 to 100. A number outside that range, or text that is not a number, answers `400 invalid_limit`. Between 1 and 100. Default `10`. - `min_score` (number, optional): Lowest match score to return, 0 to 100. A number outside that range, or text that is not a number, answers `400 invalid_min_score`. Between 0 and 100. Default `60`. - `include_location` (boolean, optional): Add the address, city, country code and postal code to each hit. Default `true`. - `include_details` (boolean, optional): Return the whole cached hotel document of each hit (about 26 KB each) instead of the summary. Default `false`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/hotels/name-search?query=Sofitel%20Algiers&limit=3' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The hotels that match, best match first. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `query` (string): The text you searched for. - `results_count` (integer): How many hotels are in `hotels`. - `search_time` (string): Time the search took, as text with its unit, such as `0.061s`. - `hotels` (array of object): The hotels that match, best match first. - `id` (string): The FX-Port hotel id: send it as `hotel_id`. - `name` (string): Name of the hotel. - `match_score` (number): How well the name matches your text, 0 to 100, two decimals. Below about 80 the hit is often another hotel. Between 0 and 100. - `match_type` (string): How the name matched. One of `exact`, `partial`, `fuzzy`. - `address` (string): Street address. - `city` (string): City. - `country_code` (string): ISO 3166-1 alpha-2 country code. - `postal_code` (string): Postal code; an empty string when unknown. ```json { "environment": "sandbox", "query": "Sofitel Algiers", "results_count": 2, "search_time": "0.060s", "hotels": [ { "id": "sofitel_algiers_hamma_garden", "name": "Sofitel Algiers Hamma Garden", "match_score": 83.39, "match_type": "partial", "address": "172, rue Hassiba Benbouali, Algiers", "city": "Algiers", "country_code": "DZ", "postal_code": "16014" }, { "id": "algiers_hotel", "name": "Algiers", "match_score": 76.67, "match_type": "partial", "address": "2845 South Las Vegas Boulevard Las Vegas, South Las Vegas Boulevard, Las Vegas", "city": "Las Vegas", "country_code": "US", "postal_code": "89109" } ] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_query` | 'query' must be 2 to 100 characters long. | | 400 | `invalid_limit` | 'limit' must be at most 100. | | 400 | `invalid_min_score` | 'min_score' must be a number between 0 and 100. | | 400 | `bad_request` | Query cannot be empty | | 422 | `validation_error` | Field required | | 503 | `service_busy` | Hotel name search is busy right now. Retry in a couple of seconds. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Hotel name search](https://fx-port.com/docs/hotels/name-search.md) --- # Find a hotel by name (JSON body) > The same search as GET /hotels/name-search, with the parameters in a JSON body. Endpoint: `POST /hotels/search-by-name` Source: https://fx-port.com/docs/api/post-hotel-name-search `POST /api/v1/hotels/search-by-name` Requires an API key with **Read** permission; a **Read+Write** key works too. Use whichever your client prefers. A body error answers `400`: `missing_field` for a missing `query`, `invalid_query` for the text, `invalid_limit` and `invalid_min_score` for the numbers, and `bad_request` with a message that names the field for the two booleans; the GET form answers `422` for a query parameter that is missing. A `limit` above 100 is capped at 100 and the answer carries a `coerced_value` warning; the GET form refuses it with `invalid_limit`. ## Request ### Body Required, JSON. - `query` (string, required): The hotel name or a part of it, 2 to 100 characters. Typos and accents are tolerated. - `limit` (integer, optional): Most hits to return, 1 to 100. A larger number is capped at 100, with a `coerced_value` warning. A whole number written as text (`"10"`) is read as the number, with the same warning. Between 1 and 100. Default `10`. - `min_score` (number, optional): Lowest match score to return, 0 to 100. A number written as text (`"60"`) is read as the number, with a `coerced_value` warning. Between 0 and 100. Default `60`. - `include_location` (boolean, optional): Add the address, city, country code and postal code to each hit. Ignored with `include_details`. The text `true` or `false` (also `1`, `0`, `yes`, `no`, `on`, `off`) is read as the boolean, with a `coerced_value` warning; `null` means the default. Default `true`. - `include_details` (boolean, optional): Return the whole cached hotel document for each hit instead of the summary. About 26 KB per hit. Text is read as in `include_location`. Default `false`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/search-by-name \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "Sofitel Algiers", "limit": 3, "min_score": 60, "include_location": true, "include_details": false }' ``` ## Response ### 200: The hotels that match, best match first. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `query` (string): The text you searched for. - `results_count` (integer): How many hotels are in `hotels`. - `search_time` (string): Time the search took, as text with its unit, such as `0.061s`. - `hotels` (array of object): The hotels that match, best match first. - `id` (string): The FX-Port hotel id: send it as `hotel_id`. - `name` (string): Name of the hotel. - `match_score` (number): How well the name matches your text, 0 to 100, two decimals. Below about 80 the hit is often another hotel. Between 0 and 100. - `match_type` (string): How the name matched. One of `exact`, `partial`, `fuzzy`. - `address` (string): Street address. - `city` (string): City. - `country_code` (string): ISO 3166-1 alpha-2 country code. - `postal_code` (string): Postal code; an empty string when unknown. ```json { "environment": "sandbox", "query": "Sofitel Algiers", "results_count": 2, "search_time": "0.060s", "hotels": [ { "id": "sofitel_algiers_hamma_garden", "name": "Sofitel Algiers Hamma Garden", "match_score": 83.39, "match_type": "partial", "address": "172, rue Hassiba Benbouali, Algiers", "city": "Algiers", "country_code": "DZ", "postal_code": "16014" }, { "id": "algiers_hotel", "name": "Algiers", "match_score": 76.67, "match_type": "partial", "address": "2845 South Las Vegas Boulevard Las Vegas, South Las Vegas Boulevard, Las Vegas", "city": "Las Vegas", "country_code": "US", "postal_code": "89109" } ] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_field` | Missing required field: 'query' | | 400 | `invalid_query` | 'query' must be 2 to 100 characters long. | | 400 | `invalid_limit` | 'limit' must be a positive integer. | | 400 | `invalid_min_score` | 'min_score' must be a number between 0 and 100. | | 400 | `bad_request` | 'include_details' must be a boolean | | 503 | `service_busy` | Hotel name search is busy right now. Retry in a couple of seconds. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Hotel name search](https://fx-port.com/docs/hotels/name-search.md) --- # Geo search: hotels around a point > Geo search: finds and prices the hotels around a point for a stay. Endpoint: `POST /hotels/search_advanced` Source: https://fx-port.com/docs/api/search-hotels `POST /api/v1/hotels/search_advanced` Requires an API key with **Read** permission; a **Read+Write** key works too. Send `latitude`, `longitude` and `radius` (meters, required) with the dates, the guests and the residency; the coordinates of a place come from the location search. The same call searches one hotel when you send `hotel_id` instead. The answer has two parts that you join by hotel id: `hotels_details[]` holds the MINI card of each hotel (identity, place, photos, amenities, no rates) and `hotels_pricing` holds the price of the cheapest rate of each hotel for the whole stay. Call `get_rates` for the rooms, meals and cancellation terms of the hotel the user picks. Hotels that are priced but whose content FX-Port has not cached yet are in `missing_hotel_ids`: read each one with `get_hotel_info`. The answer is always one JSON body: `stream=true` and `Accept: text/event-stream` are ignored for API keys. There is no pagination: a 5 km radius around a big city can return 1,800 hotels and 8 MB, so keep the radius to what the screen needs. A search answers at most 5,000 hotels: when there are more the answer says `truncated` and how many were found. A geo search takes 4 to 20 seconds, a search of one hotel less than a second. Identical searches of your agency that arrive at the same moment are merged into one supplier call; each caller still gets its own `request_id` and headers. Do not send `currency`. A value written in another well-defined way is read and reported in `warnings` (`coerced_value`): a number as text, a date with a midnight time, a sub-territory residency, a radius outside 100 to 70,000 meters, which is moved to the nearest bound. What cannot be read keeps its error code. In the sandbox the search runs on real hotels and prices, but only the supplier test hotels can be booked. ## Request ### Body Required, JSON. - `latitude` (number, optional): Geo search: the latitude of the center of the search, -90 to 90. Send it with `longitude` and `radius`; the coordinates of a place come from the locations search. A number written as text (`"48.8566"`) is read as the number, with a `coerced_value` warning. Between -90 and 90. - `longitude` (number, optional): Geo search: the longitude of the center, -180 to 180. A number written as text (`"2.3522"`) is read as the number, with a `coerced_value` warning. Between -180 and 180. - `radius` (integer, optional): Geo search: the radius around the center, in meters, 100 to 70,000. Required for a geo search; there is no default. A value outside the range is moved to the nearest bound and the answer carries a `coerced_value` warning. A number written as text (`"3000"`) is read as the number, with a `coerced_value` warning. Between 100 and 70000. - `hotel_id` (string, optional): Search one hotel instead of the hotels around a point: its FX-Port id (1 to 128 characters: letters, digits and `_ . : -`; spaces around it are trimmed). A JSON integer is read as its text, but it is not looked up as the supplier's numeric `hid` here: send the FX-Port id. When it is sent, `latitude`, `longitude` and `radius` are ignored; a `null` `hotel_id` next to `latitude` and `longitude` is a geo search. - `checkin` (string, required): Check-in date, `YYYY-MM-DD`: from yesterday (for time zones) to 400 days ahead. A date written with a midnight time (`2026-12-01T00:00:00Z`) is read as that day, with a `coerced_value` warning; any other time of day is `invalid_date`. - `checkout` (string, required): Check-out date, `YYYY-MM-DD`, after `checkin`. A stay is 1 to 30 nights. A midnight time is read as in `checkin`. - `guests` (array of object, required): The rooms: one `{adults, children}` object per room, 1 to 9 rooms. - `adults` (integer, required): Number of adults in the room, 1 to 6. A number written as text (`"2"`) is read as the number, with a `coerced_value` warning. Between 1 and 6. - `children` (array of integer, optional): One entry per child of the room: the age at check-in. `[]` or omitted when there is none. At most 4 children per room. - `residency` (string, optional): Residency of the guests, a two-letter ISO 3166-1 country code (`gb`, `dz`, `fr`). A sub-territory code of the website's nationality list (`GB-SCT`, `USPR`, `ES-XCI`, `PT-MA`, `BQ-SA` ...) is read as its country, with a `coerced_value` warning: send the 2-letter country. Some rates exist for some residencies only. Not defaulted: always send it. - `language` (string, optional): Language of the supplier's answer, such as `en`, `fr` or `zh_CN`. A longer tag (`zh-Hans-CN`, `es-419`, `sr-Latn-RS`) is read as the supplier's code, with a `coerced_value` warning. The hotel content in the answer is English whatever you send. Default `en`. - `id` (string, deprecated, optional): Legacy name: use `hotel_id`. The hotel id, in its older spelling. Use `hotel_id`. - `hotelId` (string, deprecated, optional): Legacy name: use `hotel_id`. The hotel id, in its older spelling. Use `hotel_id`. - `checkinDate` (string, deprecated, optional): Legacy name: use `checkin`. The check-in date, in an older spelling. Use `checkin`. - `checkin_date` (string, deprecated, optional): Legacy name: use `checkin`. The check-in date, in an older spelling. Use `checkin`. - `check_in` (string, deprecated, optional): Legacy name: use `checkin`. The check-in date, in an older spelling. Use `checkin`. - `checkoutDate` (string, deprecated, optional): Legacy name: use `checkout`. The check-out date, in an older spelling. Use `checkout`. - `checkout_date` (string, deprecated, optional): Legacy name: use `checkout`. The check-out date, in an older spelling. Use `checkout`. - `check_out` (string, deprecated, optional): Legacy name: use `checkout`. The check-out date, in an older spelling. Use `checkout`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/search_advanced \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "latitude": 48.8566, "longitude": 2.3522, "radius": 1000, "checkin": "2026-12-01", "checkout": "2026-12-04", "residency": "gb", "guests": [ { "adults": 2, "children": [] } ] }' ``` ## Response ### 200: The hotels that are available, with their mini cards and prices. `status` tells how many have content; an empty `hotels_details` with `no_hotels_found` means no hotel is available for the request (also the case when a hotel exists but not for this residency). The examples are real answers of the sandbox: a geo search around Nuuk, a search of one hotel, a geo search around Tegucigalpa where one priced hotel has no card yet, and an empty answer. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `search_id` (string): The id of the search, also in the `X-Search-Id` header. Quote it to support. - `status` (string): `complete`: every priced hotel has content. `partial`: some do not (see `missing_hotel_ids`). `cache_miss`: none has. `no_hotels_found`: no hotel is available for the request. One of `complete`, `partial`, `cache_miss`, `no_hotels_found`. - `hotels_details` (array of HotelMini): The mini card of each hotel that has cached content. No rates here: the prices are in `hotels_pricing`. - `processing_status` (object): How many hotels of the search have content. - `total_hotels` (integer): Hotels the supplier priced for the search. - `cached_hotels` (integer): Of them, the hotels whose content FX-Port holds: they are in `hotels_details`. - `queued_hotels` (integer): Of them, the hotels without cached content that were queued for loading: their ids are in `missing_hotel_ids`. - `queue_info` (object): The hotels without cached content that FX-Port loads in the background. - `queued_hotel_ids` (array of string): The ids of the hotels whose content was queued for loading (at most 50 per search). - `estimated_completion` (string, nullable): A rough delay until the content is loaded, such as `about 9 minutes`. `null` when nothing was queued. - `estimated_completion_seconds` (integer): The same delay in seconds. Present only when hotels were queued. - `timing` (object): Where the time of a search went, in seconds. - `total_duration` (number): Seconds the whole search took, in FX-Port. - `search_duration` (number): Seconds the supplier took to price the hotels. - `cache_check_duration` (number): Seconds FX-Port took to read the hotel content. - `hotels_pricing` (object): The price of the cheapest rate of every priced hotel, by hotel id. Absent when `status` is `no_hotels_found`. - `missing_hotel_ids` (array of string): The ids of the hotels that are priced but have no cached content yet. Get one with `get_hotel_info`. Absent when `status` is `no_hotels_found`. - `missing_hotels_pricing` (object): The price entry of each hotel of `missing_hotel_ids`, with the same fields as `hotels_pricing`. - `truncated` (boolean): `true` when the supplier found more hotels than one search answers and the rest was cut off. Present only then: narrow the search. - `max_hotels` (integer): The most hotels one search answers. Present with `truncated`. - `total_hotels_found` (integer): How many hotels the supplier found before the cut. Present with `truncated`. - `note` (string): A hint about an empty answer, for example that `test_hotel` has no availability for the residency you sent. Present only when there is one. ```json { "environment": "sandbox", "search_id": "71498fa9-d3a7-4b6c-81bd-76693be726d1", "status": "complete", "hotels_details": [ { "id": "hotel_nordbo_2", "hid": 10048834, "name": "Hotel Nordbo", "kind": "Hotel", "address": "Vandsovej 13, Nuuk, 3900, Greenland, Nuuk", "latitude": 64.17923, "longitude": -51.73019, "postal_code": null, "region": { "id": 1359, "country_code": "GL", "iata": "GOH", "name": "Nuuk", "type": "City", "type_v2": "city" }, "star_rating": 0, "images": [ "https://cdn.worldota.net/t/{size}/content/16/be/16be5aea51c0060686286cf8c886e4e07e1e5dce.jpeg", "https://cdn.worldota.net/t/{size}/content/b1/98/b198dca0393650833ee449c3e460b2af85a04dda.jpeg" ], "serp_filters": [ "has_parking", "has_meal", "has_pets" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Smoke-free property", "Ticket assistance" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Breakfast", "Restaurant" ], "non_free_amenities": [], "group_name": "Meals" } ] }, { "id": "hhe_express", "hid": 9168363, "name": "HHE Express Hotel", "kind": "Hotel", "address": "Aqqusinersuaq 14B, Nuuk", "latitude": 64.17208, "longitude": -51.73649, "postal_code": "3900", "region": { "id": 1359, "country_code": "GL", "iata": "GOH", "name": "Nuuk", "type": "City", "type_v2": "city" }, "star_rating": 3, "images": [ "https://cdn.worldota.net/t/{size}/content/b5/39/b539a20fd8349ef419e73802ced9a04215155205.JPEG", "https://cdn.worldota.net/t/{size}/content/41/97/4197c95a76a3ada2539a0fe169e7e3d7df3e80f9.jpeg" ], "serp_filters": [ "has_internet", "has_parking" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Elevator/lift", "Smoke-free property" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Free Wi-Fi" ], "non_free_amenities": [], "group_name": "Internet" } ] } ], "processing_status": { "total_hotels": 5, "cached_hotels": 5, "queued_hotels": 0 }, "queue_info": { "queued_hotel_ids": [], "estimated_completion": null }, "timing": { "total_duration": 0.84, "search_duration": 0.82, "cache_check_duration": 0.02 }, "hotels_pricing": { "hotel_nordbo_2": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 438.68, "final_price": 219774.68, "b2b_price": 219336 }, "hhe_express": { "exchange_rate": 285, "agency_currency": "DZD", "agency_commission": 228.83, "final_price": 114639.23, "b2b_price": 114410.4 } }, "missing_hotel_ids": [], "missing_hotels_pricing": {} } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `stay_too_long` | A stay can be at most 30 nights ('checkout' is 35 nights after 'checkin'). | | 400 | `missing_field` | Missing required fields for hotel_id search: ['guests'] | | 400 | `missing_field` | Missing required fields for hotel_id search: ['checkin', 'guests'] | | 400 | `bad_request` | Do not pass 'currency'. Currency is resolved automatically from the authenticated agency. | | 400 | `missing_field` | 'radius' (meters) is required for a geo search. | | 400 | `invalid_radius` | 'radius' must be a whole number of meters. | | 422 | `supplier_rejected_request` | The hotel supplier rejected the request parameters. Check dates, guests, residency and radius. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Geo search](https://fx-port.com/docs/hotels/search.md) - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) - [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md) --- # Read the content of many hotels by id > Reads the content FX-Port holds for up to 200 hotels at once, by id. Endpoint: `POST /hotels/search` Source: https://fx-port.com/docs/api/get-hotel-content `POST /api/v1/hotels/search` Requires an API key with **Read** permission; a **Read+Write** key works too. With `detail_level=minimal` (alias `mini`) each hotel is the MINI card of `search_advanced`: 13 fields, about 5 KB. Without it each hotel is the FULL document: 31 fields with every room type, about 35 KB. Use the mini form for lists and the full form (or `get_hotel_info`) for one hotel the user opened. The answer is a bare JSON array of snake_case hotels, not in the order you asked; an id that has no cached content is left out and queued for loading, it is not reported (use `missing_hotel_ids` of the search, or `get_hotel_info`, for those). An id made of digits (a JSON integer or text) is also looked up as the supplier's numeric `hid`; a hotel reached by both comes back once, and a digits-only id that matches nothing is not queued. `fields` returns only the top-level fields you list (`id` is always added). `images_limit` keeps the first N photos of each mini card. The call reads FX-Port's cache only: it never asks the supplier. ## Request ### Query parameters - `detail_level` (string, optional): `minimal` (alias `mini`): the compact card. `full` (default): the whole document. One of `minimal`, `mini`, `full`. Default `full`. - `fields` (string, optional): A comma separated list of top-level fields to return instead of a detail level, at most 40 (`id,name,star_rating`). Nested paths are not supported. - `images_limit` (integer, optional): With `detail_level=minimal` and no `fields`: keep only the first N photos of each hotel, 0 to 30. In any other case it is ignored and the answer carries a `coerced_value` warning. A number outside 0 to 30 answers `400 invalid_images_limit`. Between 0 and 30. - `detailLevel` (string, deprecated, optional): Legacy name: use `detail_level`. The detail level, in its older spelling. Use `detail_level`. - `imagesLimit` (integer, deprecated, optional): Legacy name: use `images_limit`. The photo limit, in its older spelling. Use `images_limit`. ### Body Required, JSON. - `hotel_ids` (array of string, required): The ids of the hotels, at most 200. An id made of digits is also looked up as the supplier's numeric `hid`: `[8473727]` finds the hotel. Spaces around an id are trimmed. Duplicates are dropped; the answer does not follow your order. At most 200 items. - `hotelIds` (array of string, deprecated, optional): Legacy name: use `hotel_ids`. The hotel ids, in their older spelling. Use `hotel_ids`. - `ids` (array of string, deprecated, optional): Legacy name: use `hotel_ids`. The hotel ids, in their older spelling. Use `hotel_ids`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/hotels/search?detail_level=minimal' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_ids": [ "test_hotel_do_not_book", "hotel_honduras_maya_6" ] }' ``` ## Response ### 200: The hotels found: mini cards with `detail_level=minimal`, full documents otherwise. The `X-Results-Count` header counts them. ```json [ { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "latitude": 14.079872, "longitude": -87.21677, "postal_code": null, "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" }, "star_rating": 0, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg", "https://cdn.worldota.net/t/{size}/extranet/15/44/15440558c952ec4c3488730d1563dc9e8d25832c.JPEG" ], "serp_filters": [ "has_internet", "has_parking", "has_spa", "has_pets", "has_jacuzzi", "kitchen" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Early check-in", "Late check-out" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Concierge services", "Dry-cleaning", "Shoe shine" ], "non_free_amenities": [ "Dry-cleaning", "Shoe shine" ], "group_name": "Services and amenities" } ] } ] ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_detail_level` | 'detail_level' must be 'minimal' (alias 'mini') or 'full' (got 'tiny'). | | 400 | `invalid_body` | 'body' can hold at most 200 hotel ids per request. | | 400 | `invalid_images_limit` | 'images_limit' must be an integer from 0 to 30. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) --- # Read the raw hotel documents by id > Reads the cached hotel documents of up to 200 hotels, by id, exactly as FX-Port stores them: the FULL fields plus images_ext (the photos with their category) and policy_struct. Endpoint: `POST /hotels/details` Source: https://fx-port.com/docs/api/get-hotel-details `POST /api/v1/hotels/details` Requires an API key with **Read** permission; a **Read+Write** key works too. The default `detail_level=full` is about 27% larger than `POST /hotels/search` for the same hotel and adds nothing most clients need: prefer `POST /hotels/search` (or `get_hotel_info` for one hotel). With `detail_level=minimal` the answer is the mini card, and `images_limit` keeps the first N photos of each card. Like `POST /hotels/search` it reads the cache only, returns a bare array in no particular order, leaves out ids it does not hold and also looks an id made of digits up as the supplier's numeric `hid`. ## Request ### Query parameters - `detail_level` (string, optional): `full` (default): the raw document. `minimal` (alias `mini`): the compact card. One of `minimal`, `mini`, `full`. Default `full`. - `images_limit` (integer, optional): With `detail_level=minimal` (alias `mini`): keep only the first N photos of each card, 0 to 30. With `detail_level=full` it is ignored and the answer carries a `coerced_value` warning. A number outside 0 to 30 answers `400 invalid_images_limit`. Between 0 and 30. - `detailLevel` (string, deprecated, optional): Legacy name: use `detail_level`. The detail level, in its older spelling. Use `detail_level`. - `imagesLimit` (integer, deprecated, optional): Legacy name: use `images_limit`. The photo limit, in its older spelling. Use `images_limit`. ### Body Required, JSON. - `hotel_ids` (array of string, required): The ids of the hotels, at most 200. An id made of digits is also looked up as the supplier's numeric `hid`: `[8473727]` finds the hotel. Spaces around an id are trimmed. Duplicates are dropped; the answer does not follow your order. At most 200 items. - `hotelIds` (array of string, deprecated, optional): Legacy name: use `hotel_ids`. The hotel ids, in their older spelling. Use `hotel_ids`. - `ids` (array of string, deprecated, optional): Legacy name: use `hotel_ids`. The hotel ids, in their older spelling. Use `hotel_ids`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/hotels/details?detail_level=minimal&images_limit=1' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_ids": [ "test_hotel_do_not_book" ] }' ``` ## Response ### 200: The documents found, as a bare JSON array. ```json [ { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "hotel_chain": "No chain", "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "postal_code": null, "latitude": 14.079872, "longitude": -87.21677, "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" }, "star_rating": 0, "star_certificate": null, "email": "test@example.com", "phone": "+15555550123", "check_in_time": "14:00:00", "check_out_time": "12:00:00", "front_desk_time_start": "00:00:00", "front_desk_time_end": "00:00:00", "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Early check-in", "Late check-out" ], "non_free_amenities": [], "group_name": "General" } ], "serp_filters": [ "has_internet", "has_parking", "kitchen" ], "description_struct": [ { "title": "Location", "paragraphs": [ "A perfect fit for a big group of travellers: apartment «Test Hotel (Do Not Book)» ..." ] } ], "metapolicy_struct": { "internet": [ { "inclusion": "included", "price": "0.00", "currency": null, "price_unit": "unspecified", "internet_type": "unspecified", "work_area": "hotel" } ], "meal": [ { "inclusion": "not_included", "price": "300.00", "currency": "USD", "meal_type": "breakfast" } ], "children_meal": [ { "inclusion": "not_included", "price": "200.00", "currency": "USD", "meal_type": "breakfast", "age_start": 0, "age_end": 5 } ], "extra_bed": [ { "inclusion": "not_included", "price": "100.00", "currency": "ALL", "price_unit": "per_guest_per_night", "amount": 1 } ], "cot": [ { "inclusion": "not_included", "price": "0.00", "currency": "USD", "price_unit": "per_room_per_night", "amount": 1 } ], "pets": [ { "inclusion": "not_included", "price": "560.00", "currency": "USD", "price_unit": "per_room_per_stay", "pets_type": "unspecified" } ], "shuttle": [ { "inclusion": "not_included", "price": "100.00", "currency": "EUR", "shuttle_type": "one_way", "destination_type": "train" } ], "parking": [ { "inclusion": "not_included", "price": "100.00", "currency": "USD", "price_unit": "per_car_per_night", "territory_type": "unspecified" } ], "children": [ { "age_start": 0, "age_end": 3, "price": "500.00", "currency": "CHF", "extra_bed": "available" } ], "visa": { "visa_support": "support_enable" }, "deposit": [ { "availability": "available", "price": "123.00", "currency": "HNL", "price_unit": "per_room_per_stay", "pricing_method": "fixed", "payment_type": "cash", "deposit_type": "unspecified" } ], "no_show": { "availability": "available", "time": "09:00:00", "day_period": "after_midday" }, "add_fee": [], "check_in_check_out": [] }, "metapolicy_extra_info": "Front desk is open 24/7.", "room_groups": [ { "room_group_id": 95, "name": "Standard Double room full double bed", "name_struct": { "main_name": "Standard Double room", "bedding_type": "full double bed", "bathroom": "shared bathroom" }, "rg_ext": { "class": 3, "quality": 2, "sex": 0, "bathroom": 1, "bedding": 3, "family": 0, "capacity": 2, "club": 0, "bedrooms": 0, "balcony": 0, "floor": 0, "view": 0 }, "room_amenities": [ "bath", "heating" ], "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ] } ], "facts": { "floors_number": null, "rooms_number": null, "year_built": null, "year_renovated": null, "electricity": { "frequency": [ 60 ], "voltage": null, "sockets": [ "a", "b" ] }, "register": null }, "payment_methods": [ "visa", "maestro", "cash" ], "keys_pickup": { "type": "lockbox", "phone": "+15555550123", "email": "test@example.com", "apartment_office_address": null, "apartment_extra_information": "The keys are in the box by the front door.", "is_contactless": true }, "is_closed": false, "is_gender_specification_required": false, "deleted": false, "images_ext": [ { "url": "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg", "category_slug": "others" }, { "url": "https://cdn.worldota.net/t/{size}/extranet/15/44/15440558c952ec4c3488730d1563dc9e8d25832c.JPEG", "category_slug": "outside" } ], "policy_struct": [ { "title": "Meals", "paragraphs": [ "Price of an additional breakfast: 300.00 USD per person." ] } ] } ] ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_body` | The request body must be a JSON list of hotel id strings. | | 400 | `invalid_images_limit` | 'images_limit' must be an integer from 0 to 30. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) --- # Read one hotel for the hotel page > Returns the content of a hotel for the page the user opens before checkout: description, photos, every room type, policies, facts and contact details, in camelCase (HotelInfo). Endpoint: `POST /hotels/get_hotel_info` Source: https://fx-port.com/docs/api/get-hotel-info `POST /api/v1/hotels/get_hotel_info` Requires an API key with **Read** permission; a **Read+Write** key works too. It is the only content call that asks the supplier when FX-Port does not hold the hotel yet, and only for a single id (`hotel_id`): use it for the ids of `missing_hotel_ids`. With `hotel_ids` it returns only the hotels already cached and leaves out the others; an unknown single id answers `404 hotel_not_found`. With `detail_level=minimal` the answer is the compact card instead, which is what a result list needs. A hotel is looked up by its FX-Port id, then by its numeric supplier id (`hid`). Cached content is English whatever `language` you send; `language` only applies to the live fetch. The full document is 5 to 143 KB per hotel (26 KB in the middle, 38 KB on average): cache it by hotel id. ## Request ### Body Required, JSON. - `hotel_id` (string, optional): One hotel: its FX-Port id, or its numeric supplier id (`hid`, a JSON integer or digits). Only one id gets a live fetch when the hotel is not cached. - `hotel_ids` (array of string, optional): Several hotels, at most 200. Only hotels already cached are returned (no live fetch). Wins over `hotel_id`. At most 200 items. - `detail_level` (string, optional): `full` (default) returns the whole document, `minimal` (alias `mini`) the compact card for result lists. One of `full`, `minimal`, `mini`. Default `full`. - `images_limit` (integer, optional): With `detail_level` `minimal`: keep only the first N photos of each hotel, 0 to 30. A whole number written as text is read as the number. Between 0 and 30. - `language` (string, optional): Language of the live fetch of an uncached hotel. A cached hotel is English whatever you send. Default `en`. - `hotelId` (string, deprecated, optional): Legacy name: use `hotel_id`. One hotel id, in its older spelling. Use `hotel_id`. - `id` (string, deprecated, optional): Legacy name: use `hotel_id`. One hotel id, in an older spelling. Use `hotel_id`. - `hid` (string, deprecated, optional): Legacy name: use `hotel_id`. The supplier's numeric hotel id, in an older spelling. Use `hotel_id`, which takes it as well. - `hotelIds` (array of string, deprecated, optional): Legacy name: use `hotel_ids`. Several hotel ids, in their older spelling. Use `hotel_ids`. - `ids` (array of string, deprecated, optional): Legacy name: use `hotel_ids`. Several hotel ids, in an older spelling. Use `hotel_ids`. - `detailLevel` (string, deprecated, optional): Legacy name: use `detail_level`. The detail level, in its older spelling. Use `detail_level`. - `imagesLimit` (integer, deprecated, optional): Legacy name: use `images_limit`. The photo limit, in its older spelling. Use `images_limit`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/get_hotel_info \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "language": "en" }' ``` ## Response ### 200: The hotels that were found, in `data`. An empty list when none of several ids is cached. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `data` (array of object): The hotels that were found, in no particular order: full camelCase documents (`HotelInfo`), or compact cards (`HotelMini`, snake_case) with `detail_level` `minimal`. Hotels that are not found are left out: the list is empty when none is. ```json { "environment": "sandbox", "data": [ { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "hotelChain": "No chain", "isClosed": false, "isGenderSpecificationRequired": false, "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "postalCode": null, "latitude": 14.079872, "longitude": -87.21677, "region": { "id": 3421, "name": "Tegucigalpa", "countryCode": "HN", "iata": "TGU", "type": "City" }, "starRating": 0, "starCertificate": null, "contact": { "email": "test@example.com", "phone": "+15555550123" }, "checkIn": "14:00:00", "checkOut": "12:00:00", "frontDeskHours": { "start": "00:00:00", "end": "00:00:00" }, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ], "amenityGroups": [ { "groupName": "General", "amenities": [ "24-hour reception", "Early check-in", "Late check-out" ], "paidAmenities": [] } ], "serpFilters": [ "has_internet", "has_parking", "kitchen" ], "descriptionStruct": [ { "title": "Location", "paragraphs": [ "A perfect fit for a big group of travellers: apartment «Test Hotel (Do Not Book)» ..." ], "html": "

Location

A perfect fit for a big group of travellers ...

" } ], "metapolicyExtraInfo": "Front desk is open 24/7.", "metapolicyStruct": { "addFee": [], "checkInCheckOut": [], "children": [ { "age_start": 0, "age_end": 3, "price": "500.00", "currency": "CHF", "extra_bed": "available" } ], "childrenMeal": [ { "inclusion": "not_included", "price": "200.00", "currency": "USD", "meal_type": "breakfast", "age_start": 0, "age_end": 5 } ], "cot": [ { "inclusion": "not_included", "price": "0.00", "currency": "USD", "price_unit": "per_room_per_night", "amount": 1 } ], "deposit": [ { "availability": "available", "price": "123.00", "currency": "HNL", "price_unit": "per_room_per_stay", "pricing_method": "fixed", "payment_type": "cash", "deposit_type": "unspecified" } ], "extraBed": [ { "inclusion": "not_included", "price": "100.00", "currency": "ALL", "price_unit": "per_guest_per_night", "amount": 1 } ], "internet": [ { "inclusion": "included", "price": "0.00", "currency": null, "price_unit": "unspecified", "internet_type": "unspecified", "work_area": "hotel" } ], "meal": [ { "inclusion": "not_included", "price": "300.00", "currency": "USD", "meal_type": "breakfast" } ], "noShow": { "availability": "available", "time": "09:00:00", "day_period": "after_midday" }, "parking": [ { "inclusion": "not_included", "price": "100.00", "currency": "USD", "price_unit": "per_car_per_night", "territory_type": "unspecified" } ], "pets": [ { "inclusion": "not_included", "price": "560.00", "currency": "USD", "price_unit": "per_room_per_stay", "pets_type": "unspecified" } ], "shuttle": [ { "inclusion": "not_included", "price": "100.00", "currency": "EUR", "shuttle_type": "one_way", "destination_type": "train" } ], "visa": { "visa_support": "support_enable" } }, "roomGroups": [ { "roomGroupId": 95, "name": "Standard Double room full double bed", "nameStruct": { "mainName": "Standard Double room", "beddingType": "full double bed", "bathroom": "shared bathroom" }, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ], "roomAmenities": [ "bath", "heating" ], "roomClass": "room", "quality": "standard", "bathroom": "private-bathroom", "hasPrivateBathroom": true, "capacity": 2, "balcony": false, "view": null, "floor": null, "bedrooms": null, "beddingType": "double-bed", "isFamily": false, "isClub": false, "genderRestriction": null } ], "facts": { "floorsNumber": null, "roomsNumber": null, "yearBuilt": null, "yearRenovated": null, "electricity": { "voltage": [], "frequency": [ 60 ], "sockets": [ "a", "b" ] } }, "paymentMethods": [ "visa", "maestro", "cash" ], "keysPickup": { "type": "lockbox", "isContactless": true, "email": "test@example.com", "phone": "+15555550123", "officeAddress": null, "extraInfo": "The keys are in the box by the front door." } } ] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_field` | Hotel IDs are required (hotelId or hotelIds) | | 400 | `invalid_hotel_ids` | 'hotel_ids' must be a list of hotel ids. | | 400 | `too_many_ids` | At most 200 hotel ids per request. | | 400 | `invalid_images_limit` | 'images_limit' must be an integer from 0 to 30. | | 404 | `hotel_not_found` | No hotel with this id exists at the supplier. | | 429 | `supplier_rate_limited` | The hotel supplier is receiving too many requests. Retry in a minute. | | 502 | `supplier_error` | The hotel supplier could not process the request right now. Retry in a few minutes. | | 502 | `supplier_invalid_response` | The hotel supplier returned an answer that could not be used. Retry in a few minutes. | | 503 | `supplier_unavailable` | The hotel supplier could not be reached. Retry in a few minutes. | | 503 | `supplier_timeout` | The hotel supplier did not answer in time, so the request may or may not have been processed: read the booking's status before you retry. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Hotel details](https://fx-port.com/docs/hotels/hotel-details.md) - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) --- # Get the rooms and rates of one hotel > Returns every room and rate of one hotel for a stay and an occupancy, each with its meal plan, cancellation schedule and price. Endpoint: `POST /hotels/get_rates` Source: https://fx-port.com/docs/api/get-hotel-rates `POST /api/v1/hotels/get_rates` Requires an API key with **Read** permission; a **Read+Write** key works too. Take the `bookHash` of the rate the user chose and send it to `pre_book`. Only prepaid (deposit) rates are returned, in no particular order: sort by `pricing.totalPrice` yourself. Prices are for the whole stay and all rooms, in the currency of your agency. The hashes are valid for 30 minutes. A hotel that is unknown, closed, not available for the dates or not available for the residency answers `200` with `data.hotelId` `null` and an empty `rates`, not an error: check `rates.length`. The call takes 0.5 to 5 seconds. Identical calls of your agency that arrive at the same moment are merged into one supplier call; each caller still gets its own `request_id` and headers. Do not send `currency`. A value written in another well-defined way is read and reported in `warnings` (`coerced_value`), as in `search_advanced`; what cannot be read keeps its error code. In the sandbox the rates of any hotel are real; only the test hotels can be booked, and `test_hotel` has rates for the residencies `dz`, `ru` and `us` only. ## Request ### Body Required, JSON. - `hotel_id` (string, required): The FX-Port id of the hotel (1 to 128 characters: letters, digits and `_ . : -`; spaces around it are trimmed): `hotels_details[].id` of a search, a hit of the name search. A JSON integer is read as its text, but it is not looked up as the supplier's numeric `hid` here: send the FX-Port id. - `checkin` (string, required): Check-in date, `YYYY-MM-DD`: from yesterday (for time zones) to 400 days ahead. A date written with a midnight time (`2026-12-01T00:00:00Z`) is read as that day, with a `coerced_value` warning; any other time of day is `invalid_date`. - `checkout` (string, required): Check-out date, `YYYY-MM-DD`, after `checkin`. A stay is 1 to 30 nights. A midnight time is read as in `checkin`. - `guests` (array of object, required): The rooms: one `{adults, children}` object per room, 1 to 9 rooms. Rates are for exactly this occupancy. - `adults` (integer, required): Number of adults in the room, 1 to 6. A number written as text (`"2"`) is read as the number, with a `coerced_value` warning. Between 1 and 6. - `children` (array of integer, optional): One entry per child of the room: the age at check-in. `[]` or omitted when there is none. At most 4 children per room. - `residency` (string, optional): Residency of the guests, a two-letter ISO 3166-1 country code. A sub-territory code of the website's nationality list (`GB-SCT`, `USPR`, `PT-MA` ...) is read as its country, with a `coerced_value` warning: send the 2-letter country. Some rates exist for some residencies only. Default `gb`. - `language` (string, optional): Language of the room names, such as `en`, `fr` or `zh_CN`. A longer tag (`zh-Hans-CN`, `es-419`) is read as the supplier's code, with a `coerced_value` warning. Default `en`. - `id` (string, deprecated, optional): Legacy name: use `hotel_id`. The hotel id, in its older spelling. Use `hotel_id`. - `hotelId` (string, deprecated, optional): Legacy name: use `hotel_id`. The hotel id, in its older spelling. Use `hotel_id`. - `checkinDate` (string, deprecated, optional): Legacy name: use `checkin`. The check-in date, in an older spelling. Use `checkin`. - `checkin_date` (string, deprecated, optional): Legacy name: use `checkin`. The check-in date, in an older spelling. Use `checkin`. - `check_in` (string, deprecated, optional): Legacy name: use `checkin`. The check-in date, in an older spelling. Use `checkin`. - `checkoutDate` (string, deprecated, optional): Legacy name: use `checkout`. The check-out date, in an older spelling. Use `checkout`. - `checkout_date` (string, deprecated, optional): Legacy name: use `checkout`. The check-out date, in an older spelling. Use `checkout`. - `check_out` (string, deprecated, optional): Legacy name: use `checkout`. The check-out date, in an older spelling. Use `checkout`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/get_rates \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "hotel_id": "test_hotel_do_not_book", "checkin": "2026-12-01", "checkout": "2026-12-04", "residency": "gb", "language": "en", "guests": [ { "adults": 2, "children": [] } ] }' ``` ## Response ### 200: The rates of the hotel. `rates` is empty when there is none. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the call worked, also when the hotel has no rates. - `data` (object): The rates of one hotel. - `hotelId` (string, nullable, deprecated): Legacy name: use `hotel_id`. The id of the hotel; `null` when the hotel is unknown or has no availability for the request. - `hotel_id` (string, nullable): The id of the hotel; `null` when the hotel is unknown or has no availability for the request. The snake_case twin of `hotelId`, with the same value: use this name. - `hid` (integer, nullable): The supplier's numeric hotel id; `null` with `hotelId`. - `rates` (array of object): Every rate of the hotel for the stay and occupancy, not sorted by price. Empty when there is none. - `bookHash` (string, deprecated): Legacy name: use `book_hash`. The token of this rate. Send it as `book_hash` to `pre_book`. It stays valid for 30 minutes. - `book_hash` (string): The token of this rate. Send it as `book_hash` to `pre_book`. It stays valid for 30 minutes. The snake_case twin of `bookHash`, with the same value: use this name. - `matchHash` (string, deprecated): Legacy name: use `match_hash`. A second identifier of the rate. You do not need it to book: send `bookHash`. - `match_hash` (string): A second identifier of the rate. You do not need it to book: send `bookHash`. The snake_case twin of `matchHash`, with the same value: use this name. - `room` (object): The room of a rate. Match it with the hotel content by room name; the document of `get_hotel_info` lists every room type with photos and amenities. - `name` (string): Name of the room as the supplier sells it, in the `language` of the request. - `beddingType` (string, nullable): The bedding as a phrase: `single bed`, `full double bed`, `twin beds` ... - `miscRoomType` (string, nullable): Another description of the bed or room, when the supplier gives one (`king size bed`). - `capacity` (integer, nullable): Number of guests the room is made for; `null` when unknown. - `bathroom` (string, nullable): `private-bathroom` or `shared-bathroom`. - `hasPrivateBathroom` (boolean): `true` when the bathroom is private. - `balcony` (boolean): `true` when the room has a balcony. - `roomClass` (string, nullable): Class of the room: `room`, `suite`, `junior-suite`, `apartment`, `studio`, `cottage`, `dorm` ... - `quality` (string, nullable): Quality of the room: `economy`, `standard`, `classic`, `superior`, `deluxe`, `executive`, `business` ... - `view` (string, nullable): View of the room (`city-view`, `garden-view` ...); `null` when there is none. - `floor` (string, nullable): Floor of the room, when it is special (`attic`). - `bedrooms` (integer, nullable): Number of bedrooms; `null` when unknown. - `isFamily` (boolean): `true` for a family room. - `isClub` (boolean): `true` for a club room. - `genderRestriction` (string, nullable): Gender the room is reserved for (hostel dorms); `null` when there is no restriction. - `nonSmoking` (boolean): `true` when the room is non-smoking. - `meal` (object): The meal plan of a rate. - `type` (string): The meal included in the rate. `nomeal` is room only. One of `nomeal`, `breakfast`, `lunch`, `dinner`, `half-board`, `half-board-lunch`, `half-board-dinner`, `full-board`, `all-inclusive`, `ultra-all-inclusive`. - `included` (boolean): `true` when a meal is included (`type` is not `nomeal`). - `childMealIncluded` (boolean): `true` when the meal of the children is included too. - `dailyPrices` (array of number, deprecated): Legacy name: use `daily_prices`. The supplier's price of each night of the stay (first room), in `supplierCurrency`. - `daily_prices` (array of number): The supplier's price of each night of the stay (first room), in `supplierCurrency`. The snake_case twin of `dailyPrices`, with the same value: use this name. - `allotment` (integer): How many rooms of this type are left at this rate. - `isPackage` (boolean): `true` when the rate is part of a package. - `anyResidency` (boolean): `false` when the rate is reserved for some residencies: it exists for the `residency` of your request only. - `deposit` (object, nullable): Reserved for deposit terms of the rate. `null` today. - `noShow` (object, nullable): Reserved for no-show terms of the rate. `null` today. - `cancellationPolicy` (object): When and at what price a booking of the rate can be cancelled. The amounts are the supplier's (`originalAmount`, in `originalCurrency`); what is refunded to your agency is in the answer of `cancel_hotel`. - `freeCancellationBefore` (string, nullable, deprecated): Legacy name: use `free_cancellation_before`. UTC date and time (no zone suffix) until which the booking can be cancelled for free. `null` means the rate is non-refundable. - `free_cancellation_before` (string, nullable): UTC date and time (no zone suffix) until which the booking can be cancelled for free. `null` means the rate is non-refundable. The snake_case twin of `freeCancellationBefore`, with the same value: use this name. - `policies` (array of object): The schedule of penalties from now to check-in: the free window first, then the windows with a penalty. - `from` (string, nullable): Start of the window, a UTC date and time without zone suffix; `null` for the first window (from now). - `until` (string, nullable): End of the window, a UTC date and time without zone suffix; `null` for the last window (to check-in and after). - `originalAmount` (number): The penalty charged by the supplier in this window, in `originalCurrency`. `0` in the free window. - `originalCurrency` (string): Currency of `originalAmount`, the supplier's: `EUR`. - `currency` (string): Your agency currency, as a label. `originalAmount` is NOT in this currency. - `pricing` (object): The prices of a rate or of an offer. There is no `b2bPrice` in this block: the amount debited from your agency is `totalPrice - agencyCommission` (see Hotel pricing). - `supplierCurrency` (string, deprecated): Legacy name: use `supplier_currency`. Currency the supplier prices in: `EUR`. - `supplier_currency` (string): Currency the supplier prices in: `EUR`. The snake_case twin of `supplierCurrency`, with the same value: use this name. - `agencyCurrency` (string, deprecated): Legacy name: use `agency_currency`. The currency of your agency. Every amount below except `originalPrice` and `taxes` is in it. - `agency_currency` (string): The currency of your agency. Every amount below except `originalPrice` and `taxes` is in it. The snake_case twin of `agencyCurrency`, with the same value: use this name. - `originalPrice` (number): The supplier's price for the whole stay and all rooms, in `supplierCurrency`. For information: it is not the base of what you pay. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. Your commission, in `agencyCurrency`. The agency price is `totalPrice` minus this amount. - `agency_commission` (number): Your commission, in `agencyCurrency`. The agency price is `totalPrice` minus this amount. The snake_case twin of `agencyCommission`, with the same value: use this name. - `totalPrice` (number, deprecated): Legacy name: use `total_price`. What the traveler pays: the whole stay, all rooms, in `agencyCurrency`. You are debited `totalPrice - agencyCommission`. - `total_price` (number): What the traveler pays: the whole stay, all rooms, in `agencyCurrency`. You are debited `totalPrice - agencyCommission`. The snake_case twin of `totalPrice`, with the same value: use this name. - `taxes` (object): Taxes and fees by name (`cityTax`, `cleaningFee`, `electricityFee`, `occupancyTax`, `resortFee`, `serviceFee`, `vat` ...), only the ones the rate has. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. The rate of exchange from `supplierCurrency` to `agencyCurrency` that was used (`285.0` in the sandbox). - `exchange_rate` (number): The rate of exchange from `supplierCurrency` to `agencyCurrency` that was used (`285.0` in the sandbox). The snake_case twin of `exchangeRate`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds the call took, as a number. - `processing_time` (number): Seconds the call took, as a number. The snake_case twin of `processingTime`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "data": { "hotelId": "test_hotel_do_not_book", "hid": 8473727, "rates": [ { "bookHash": "h-14574656-7ce9-5a21-b673-8654c5ebd416", "matchHash": "m-a1f40b22-c086-57c9-b2b5-70c8e59cb27c", "room": { "name": "Standard room", "beddingType": "single bed", "miscRoomType": null, "capacity": null, "bathroom": "private-bathroom", "hasPrivateBathroom": true, "balcony": false, "roomClass": "room", "quality": "standard", "view": null, "floor": null, "bedrooms": null, "isFamily": false, "isClub": false, "genderRestriction": null, "nonSmoking": true }, "meal": { "type": "nomeal", "included": false, "childMealIncluded": false }, "dailyPrices": [ 1, 1, 1 ], "allotment": 77, "isPackage": false, "anyResidency": true, "deposit": null, "noShow": null, "cancellationPolicy": { "freeCancellationBefore": "2026-12-01T21:00:00", "policies": [ { "from": null, "until": "2026-12-01T21:00:00", "originalAmount": 0, "originalCurrency": "EUR", "currency": "DZD" }, { "from": "2026-12-01T21:00:00", "until": null, "originalAmount": 3, "originalCurrency": "EUR", "currency": "DZD" } ] }, "pricing": { "supplierCurrency": "EUR", "agencyCurrency": "DZD", "originalPrice": 3, "agencyCommission": 2.97, "totalPrice": 1484.97, "taxes": { "cityTax": { "includedInPrice": false, "amount": 23.1, "currency": "HNL" }, "electricityFee": { "includedInPrice": true, "amount": 0.6, "currency": "EUR" }, "resortFee": { "includedInPrice": true, "amount": 0.68, "currency": "EUR" } } }, "exchangeRate": 285 } ] }, "processingTime": 0.335 } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `stay_too_long` | A stay can be at most 30 nights ('checkout' is 35 nights after 'checkin'). | | 400 | `missing_field` | Missing required fields: ['guests'] | | 400 | `missing_field` | Missing required fields: ['checkin', 'guests'] | | 400 | `bad_request` | Do not pass 'currency'. Currency is resolved automatically from the authenticated agency. | | 502 | `supplier_invalid_response` | The hotel supplier returned an answer that could not be used. Retry in a few minutes. | | 503 | `supplier_unavailable` | The hotel supplier could not be reached. Retry in a few minutes. | | 503 | `supplier_timeout` | The hotel supplier did not answer in time, so the request may or may not have been processed: read the booking's status before you retry. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Get hotel rates](https://fx-port.com/docs/hotels/rates.md) - [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md) --- # Lock a rate before booking > Locks the rate you chose: the supplier re-checks the price and opens an order, and FX-Port keeps the priced offer for one hour under the partnerOrderId of the answer. Endpoint: `POST /hotels/pre_book` Source: https://fx-port.com/docs/api/pre-book-hotel `POST /api/v1/hotels/pre_book` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. Book it with `book_hotel`, passing that id as `booking_id`. If the price rose since `get_rates` by more than `price_increase_percent` the call fails with `rate_not_found`: call `get_rates` again. If it rose by less, `priceChanged` is `true` and `data` holds the new price. Calling `pre_book` again with the same rate and the same `price_increase_percent` within a minute returns the same answer (the same `partnerOrderId`, no second order at the supplier); another percentage is a new request. Take 4 to 5 seconds. Always check `formData.orderId`: `null` means the supplier opened no order. In the sandbox a hotel other than a supplier test hotel answers `409 hotel_not_bookable_in_sandbox` with the list of test hotels. ## Request ### Body Required, JSON. - `book_hash` (string, required): The `bookHash` of the rate you chose, from `get_rates` (it starts with `h-`). Valid for 30 minutes. The `matchHash` of a rate is not a booking token and is not read as one: a call that sends only a `match_hash` fails with `missing_field`. - `price_increase_percent` (number, optional): The price rise, in percent, you accept if the supplier raised the price since `get_rates`. `0` means none: the call fails if the price moved; `null` means not set (0). A repeated call within a minute with the same `book_hash` and the same percentage gets the same answer; another percentage is a new request. A number written as text (`"5"`) is read as the number, with a `coerced_value` warning. Between 0 and 100. Default `0`. - `language` (string, optional): Language of the hotel name in the answer. Default `en`. - `hash` (string, deprecated, optional): Legacy name: use `book_hash`. The rate token, in its older spelling. Use `book_hash`. - `bookHash` (string, deprecated, optional): Legacy name: use `book_hash`. The rate token, in its older spelling. Use `book_hash`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/pre_book \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "book_hash": "h-14574656-7ce9-5a21-b673-8654c5ebd416", "price_increase_percent": 0 }' ``` ## Response ### 200: The rate is locked. Keep `partnerOrderId`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the rate was locked. - `status` (string): Always `ok`. Always `ok`. - `partnerOrderId` (string, deprecated): Legacy name: use `booking_id`. The id of the booking you are about to make. Send it as `booking_id` to `book_hotel`, then to `GET /bookings/{id}`, `get_voucher` and `cancel_hotel`. - `booking_id` (string): The id of the booking you are about to make. Send it as `booking_id` to `book_hotel`, then to `GET /bookings/{id}`, `get_voucher` and `cancel_hotel`. The snake_case twin of `partnerOrderId`, with the same value: use this name. - `partner_order_id` (string): The id of the booking you are about to make. Send it as `booking_id` to `book_hotel`, then to `GET /bookings/{id}`, `get_voucher` and `cancel_hotel`. The snake_case twin of `partnerOrderId`, with the same value: use this name. - `priceChanged` (boolean, deprecated): Legacy name: use `price_changed`. `true` when the supplier's price moved since `get_rates` (within `price_increase_percent`). `data` holds the new price. - `price_changed` (boolean): `true` when the supplier's price moved since `get_rates` (within `price_increase_percent`). `data` holds the new price. The snake_case twin of `priceChanged`, with the same value: use this name. - `data` (object): The locked rate. - `hotelId` (string, deprecated): Legacy name: use `hotel_id`. The id of the hotel. - `hotel_id` (string): The id of the hotel. The snake_case twin of `hotelId`, with the same value: use this name. - `hid` (integer): The supplier's numeric hotel id. - `rates` (array of object): The one rate you locked, with a new `bookHash` that starts with `p-`. Do not reuse it. - `bookHash` (string, deprecated): Legacy name: use `book_hash`. The token of this rate. Send it as `book_hash` to `pre_book`. It stays valid for 30 minutes. - `book_hash` (string): The token of this rate. Send it as `book_hash` to `pre_book`. It stays valid for 30 minutes. The snake_case twin of `bookHash`, with the same value: use this name. - `matchHash` (string, deprecated): Legacy name: use `match_hash`. A second identifier of the rate. You do not need it to book: send `bookHash`. - `match_hash` (string): A second identifier of the rate. You do not need it to book: send `bookHash`. The snake_case twin of `matchHash`, with the same value: use this name. - `room` (object): The room of a rate. Match it with the hotel content by room name; the document of `get_hotel_info` lists every room type with photos and amenities. - `name` (string): Name of the room as the supplier sells it, in the `language` of the request. - `beddingType` (string, nullable): The bedding as a phrase: `single bed`, `full double bed`, `twin beds` ... - `miscRoomType` (string, nullable): Another description of the bed or room, when the supplier gives one (`king size bed`). - `capacity` (integer, nullable): Number of guests the room is made for; `null` when unknown. - `bathroom` (string, nullable): `private-bathroom` or `shared-bathroom`. - `hasPrivateBathroom` (boolean): `true` when the bathroom is private. - `balcony` (boolean): `true` when the room has a balcony. - `roomClass` (string, nullable): Class of the room: `room`, `suite`, `junior-suite`, `apartment`, `studio`, `cottage`, `dorm` ... - `quality` (string, nullable): Quality of the room: `economy`, `standard`, `classic`, `superior`, `deluxe`, `executive`, `business` ... - `view` (string, nullable): View of the room (`city-view`, `garden-view` ...); `null` when there is none. - `floor` (string, nullable): Floor of the room, when it is special (`attic`). - `bedrooms` (integer, nullable): Number of bedrooms; `null` when unknown. - `isFamily` (boolean): `true` for a family room. - `isClub` (boolean): `true` for a club room. - `genderRestriction` (string, nullable): Gender the room is reserved for (hostel dorms); `null` when there is no restriction. - `nonSmoking` (boolean): `true` when the room is non-smoking. - `meal` (object): The meal plan of a rate. - `type` (string): The meal included in the rate. `nomeal` is room only. One of `nomeal`, `breakfast`, `lunch`, `dinner`, `half-board`, `half-board-lunch`, `half-board-dinner`, `full-board`, `all-inclusive`, `ultra-all-inclusive`. - `included` (boolean): `true` when a meal is included (`type` is not `nomeal`). - `childMealIncluded` (boolean): `true` when the meal of the children is included too. - `dailyPrices` (array of number, deprecated): Legacy name: use `daily_prices`. The supplier's price of each night of the stay (first room), in `supplierCurrency`. - `daily_prices` (array of number): The supplier's price of each night of the stay (first room), in `supplierCurrency`. The snake_case twin of `dailyPrices`, with the same value: use this name. - `allotment` (integer): How many rooms of this type are left at this rate. - `isPackage` (boolean): `true` when the rate is part of a package. - `anyResidency` (boolean): `false` when the rate is reserved for some residencies: it exists for the `residency` of your request only. - `deposit` (object, nullable): Reserved for deposit terms of the rate. `null` today. - `noShow` (object, nullable): Reserved for no-show terms of the rate. `null` today. - `cancellationPolicy` (object): When and at what price a booking of the rate can be cancelled. The amounts are the supplier's (`originalAmount`, in `originalCurrency`); what is refunded to your agency is in the answer of `cancel_hotel`. - `freeCancellationBefore` (string, nullable, deprecated): Legacy name: use `free_cancellation_before`. UTC date and time (no zone suffix) until which the booking can be cancelled for free. `null` means the rate is non-refundable. - `free_cancellation_before` (string, nullable): UTC date and time (no zone suffix) until which the booking can be cancelled for free. `null` means the rate is non-refundable. The snake_case twin of `freeCancellationBefore`, with the same value: use this name. - `policies` (array of object): The schedule of penalties from now to check-in: the free window first, then the windows with a penalty. - `from` (string, nullable): Start of the window, a UTC date and time without zone suffix; `null` for the first window (from now). - `until` (string, nullable): End of the window, a UTC date and time without zone suffix; `null` for the last window (to check-in and after). - `originalAmount` (number): The penalty charged by the supplier in this window, in `originalCurrency`. `0` in the free window. - `originalCurrency` (string): Currency of `originalAmount`, the supplier's: `EUR`. - `currency` (string): Your agency currency, as a label. `originalAmount` is NOT in this currency. - `pricing` (object): The prices of a rate or of an offer. There is no `b2bPrice` in this block: the amount debited from your agency is `totalPrice - agencyCommission` (see Hotel pricing). - `supplierCurrency` (string, deprecated): Legacy name: use `supplier_currency`. Currency the supplier prices in: `EUR`. - `supplier_currency` (string): Currency the supplier prices in: `EUR`. The snake_case twin of `supplierCurrency`, with the same value: use this name. - `agencyCurrency` (string, deprecated): Legacy name: use `agency_currency`. The currency of your agency. Every amount below except `originalPrice` and `taxes` is in it. - `agency_currency` (string): The currency of your agency. Every amount below except `originalPrice` and `taxes` is in it. The snake_case twin of `agencyCurrency`, with the same value: use this name. - `originalPrice` (number): The supplier's price for the whole stay and all rooms, in `supplierCurrency`. For information: it is not the base of what you pay. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. Your commission, in `agencyCurrency`. The agency price is `totalPrice` minus this amount. - `agency_commission` (number): Your commission, in `agencyCurrency`. The agency price is `totalPrice` minus this amount. The snake_case twin of `agencyCommission`, with the same value: use this name. - `totalPrice` (number, deprecated): Legacy name: use `total_price`. What the traveler pays: the whole stay, all rooms, in `agencyCurrency`. You are debited `totalPrice - agencyCommission`. - `total_price` (number): What the traveler pays: the whole stay, all rooms, in `agencyCurrency`. You are debited `totalPrice - agencyCommission`. The snake_case twin of `totalPrice`, with the same value: use this name. - `taxes` (object): Taxes and fees by name (`cityTax`, `cleaningFee`, `electricityFee`, `occupancyTax`, `resortFee`, `serviceFee`, `vat` ...), only the ones the rate has. - `exchangeRate` (number, deprecated): Legacy name: use `exchange_rate`. The rate of exchange from `supplierCurrency` to `agencyCurrency` that was used (`285.0` in the sandbox). - `exchange_rate` (number): The rate of exchange from `supplierCurrency` to `agencyCurrency` that was used (`285.0` in the sandbox). The snake_case twin of `exchangeRate`, with the same value: use this name. - `formData` (object): The supplier's booking form for the order that `book_hotel` finishes. - `partnerOrderId` (string, nullable, deprecated): Legacy name: use `partner_order_id`. The order id again. `null` when the supplier opened no order. - `partner_order_id` (string, nullable): The order id again. `null` when the supplier opened no order. The snake_case twin of `partnerOrderId`, with the same value: use this name. - `orderId` (integer, nullable): The supplier's order number. `null` means the supplier opened no order and the hotel cannot be booked. - `itemId` (integer, nullable): The supplier's item number inside the order. - `paymentTypes` (array of object): The ways to pay the supplier offers: one `deposit` and one `now` option per card currency (about 40 entries). - `amount` (string): The amount as a decimal string, in `currencyCode`. - `currencyCode` (string): ISO 4217 currency of `amount`. - `isNeedCreditCard` (boolean): `true` when the option needs a card. FX-Port never does: it pays with the `deposit` option. - `isNeedCvc` (boolean): `true` when the option needs a card security code. - `type` (string): `deposit` pays from the supplier deposit of FX-Port (the one FX-Port uses); `now` is a card payment. One of `deposit`, `now`. ```json { "environment": "sandbox", "success": true, "status": "ok", "partnerOrderId": "HMVXX4ZQ3", "priceChanged": false, "data": { "hotelId": "test_hotel_do_not_book", "hid": 8473727, "rates": [ { "bookHash": "p-58bfbfa0-fd1c-4be9-8a2a-54942cae6627", "matchHash": "m-a1f40b22-c086-57c9-b2b5-70c8e59cb27c", "room": { "name": "Standard room", "beddingType": "single bed", "miscRoomType": null, "capacity": null, "bathroom": "private-bathroom", "hasPrivateBathroom": true, "balcony": false, "roomClass": "room", "quality": "standard", "view": null, "floor": null, "bedrooms": null, "isFamily": false, "isClub": false, "genderRestriction": null, "nonSmoking": true }, "meal": { "type": "nomeal", "included": false, "childMealIncluded": false }, "dailyPrices": [ 1, 1, 1 ], "allotment": 77, "isPackage": false, "anyResidency": true, "deposit": null, "noShow": null, "cancellationPolicy": { "freeCancellationBefore": "2026-12-01T21:00:00", "policies": [ { "from": null, "until": "2026-12-01T21:00:00", "originalAmount": 0, "originalCurrency": "EUR", "currency": "DZD" }, { "from": "2026-12-01T21:00:00", "until": null, "originalAmount": 3, "originalCurrency": "EUR", "currency": "DZD" } ] }, "pricing": { "supplierCurrency": "EUR", "agencyCurrency": "DZD", "originalPrice": 3, "agencyCommission": 2.97, "totalPrice": 1484.97, "taxes": { "cityTax": { "includedInPrice": false, "amount": 23.1, "currency": "HNL" }, "electricityFee": { "includedInPrice": true, "amount": 0.6, "currency": "EUR" }, "resortFee": { "includedInPrice": true, "amount": 0.68, "currency": "EUR" } } }, "exchangeRate": 285 } ] }, "formData": { "partnerOrderId": "HMVXX4ZQ3", "orderId": 757581298, "itemId": 232430616, "paymentTypes": [ { "amount": "3.00", "currencyCode": "EUR", "isNeedCreditCard": false, "isNeedCvc": false, "type": "deposit" }, { "amount": "3.00", "currencyCode": "USD", "isNeedCreditCard": true, "isNeedCvc": true, "type": "now" } ] } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_field` | Missing required field: hash | | 400 | `invalid_rate_hash` | 'book_hash' must be a rate hash taken from get_rates (got 'abc'). | | 400 | `rate_not_found` | Rate not found | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 409 | `hotel_not_bookable_in_sandbox` | Only the supplier test hotels can be booked in sandbox: test_hotel_do_not_book (any residency) or test_hotel (residency dz, ru or us). | | 502 | `supplier_invalid_response` | The hotel supplier returned an answer that could not be used. Retry in a few minutes. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Pre-book a rate](https://fx-port.com/docs/hotels/pre-book.md) - [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md) --- # Book a locked rate > Books the offer locked by pre_book and confirms it: there is no hold or pay-later option for API keys, and your agency is debited as part of the booking, when it is confirmed (the sandbox debits nothing). Endpoint: `POST /hotels/book_hotel` Source: https://fx-port.com/docs/api/book-hotel `POST /api/v1/hotels/book_hotel` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. Send the `booking_id` (the `partnerOrderId` of `pre_book`), the guests of each room and the contact of the booker; the price, the hotel and the dates come from the locked offer, and the pre-booked rate and hotel are always the ones booked: a `book_hash` or `hotel_id` in the body is optional and never an error (a value that is not the offer's is ignored, with an `ignored_field` warning). The answer is `pending`: the supplier confirms in the background, usually 4 to 14 seconds later. Read `GET /bookings/{booking_id}?product=hotels` every 2 seconds until `status` is `confirmed` (or `failed`), then fetch the voucher. FX-Port sends no webhooks. Call `book_hotel` once per `booking_id`: a second call answers `409 duplicate_booking` and does not reach the supplier. After a timeout or a `5xx`, read the booking before you do anything else. A child must be a guest with `is_child` true and an `age`. `commission_override` sets your commission for this booking. In the sandbox every booking is a real order on the supplier's test contract: cancel it. ## Request ### Body Required, JSON. - `booking_id` (string, required): The id of the booking: the `partnerOrderId` that `pre_book` returned. Book it once. - `rooms` (array of object, required): The guests of each room, in the same order and with the same occupancy as the search: one object per room, one entry per adult and per child. - `guests` (array of object, required): Every guest of the room, adults and children, as many as you searched (one object per adult and per child). - `first_name` (string, required): Given name of the guest. Letters of any script, spaces, `-`, `,`, `.` and `'` are kept; digits and other symbols are replaced by a space. - `last_name` (string, required): Family name of the guest, same rules as `first_name`. - `is_child` (boolean, optional): `true` for a child. A child must carry `is_child` and `age`, or the supplier refuses the booking with `incorrect_guests_number`. The numbers `1` and `0`, and the texts `true`, `false`, `yes`, `no`, `on`, `off`, `1` and `0`, are read as the boolean (a text with a `coerced_value` warning); `null` or an empty text means not set (an adult); any other value is `400 invalid_is_child`. - `age` (integer, optional): Age of the child in years at check-in, 0 to 17. Required with `is_child`. A number written as text (`"8"`) is read as the number, with a `coerced_value` warning. Between 0 and 17. - `user` (object, required): The person who makes the booking. - `email` (string, required): E-mail address of the booker. FX-Port sends its confirmation here (unless `send_email` is `false`); the supplier gets an address of FX-Port's own. - `phone` (string, optional): Phone number of the booker, with country code. - `comment` (string, optional): Free text for the hotel, forwarded to the supplier. - `language` (string, optional): Language of the confirmation e-mail and of the voucher. Default `en`. - `send_email` (boolean, optional): `false` to stop FX-Port from sending its confirmation e-mail to `user.email`. Use it when you test. The numbers `1` and `0`, and the texts `true`, `false`, `yes`, `no`, `on`, `off`, `1` and `0`, are read as the boolean (a text with a `coerced_value` warning); `null` means the default; any other value is `400 invalid_send_email`. Default `true`. - `commission_override` (object, optional): Sets your commission for this booking instead of your default one. The traveler price becomes the price you pay plus this commission; what you are debited does not change. - `type` (string, required): `fixed`: `value` is your commission per room, in your agency currency. `percentage`: `value` is a percentage of the price you pay. One of `fixed`, `percentage`. - `value` (number, required): The amount or the percentage, `0` or more. A negative value is ignored. At least 0. - `book_hash` (string, optional): Do not send it: FX-Port keeps the rate you locked with `pre_book` under `booking_id` and always books that rate. A hash the offer was made from (the `bookHash` of `pre_book`, the `bookHash` of `get_rates`, a match hash) is accepted silently; any other value is ignored, with an `ignored_field` warning, and the pre-booked rate is booked. It is never an error. - `hotel_id` (string, optional): Do not send it: FX-Port keeps the hotel you locked with `pre_book` under `booking_id` and always books that hotel. The same hotel (in another case, its numeric supplier id, the id `get_rates` was called with) is accepted silently; any other value is ignored, with an `ignored_field` warning, and the pre-booked hotel is booked. It is never an error. - `partner_order_id` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `partnerOrderId` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `bookHash` (string, deprecated, optional): Legacy name: use `book_hash`. The rate token, in its older spelling. Do not send it: see `book_hash`. - `hotelId` (string, deprecated, optional): Legacy name: use `hotel_id`. The hotel id, in its older spelling. Do not send it: see `hotel_id`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/book_hotel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "HMVXX4ZQ3", "rooms": [ { "guests": [ { "first_name": "Jane", "last_name": "Smith" }, { "first_name": "John", "last_name": "Smith" } ] } ], "user": { "email": "traveler@example.com", "phone": "+15555550123" }, "send_email": false }' ``` ## Response ### 200: The supplier accepted the booking: it is `pending`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the supplier accepted the booking. - `status` (string): Always `pending`: the supplier confirms in the background. Poll the booking until it is `confirmed`. Always `pending`. - `bookingId` (string, deprecated): Legacy name: use `booking_id`. The id of the booking. The same value as `partnerOrderId` and as the `booking_id` you sent. - `booking_id` (string): The id of the booking. The same value as `partnerOrderId` and as the `booking_id` you sent. The snake_case twin of `bookingId`, with the same value: use this name. - `partnerOrderId` (string, deprecated): Legacy name: use `partner_order_id`. The id of the booking, as the supplier knows it. - `partner_order_id` (string): The id of the booking, as the supplier knows it. The snake_case twin of `partnerOrderId`, with the same value: use this name. - `hotelId` (string, deprecated): Legacy name: use `hotel_id`. The id of the hotel that was booked. - `hotel_id` (string): The id of the hotel that was booked. The snake_case twin of `hotelId`, with the same value: use this name. - `checkinDate` (string, deprecated): Legacy name: use `checkin`. Check-in date, `YYYY-MM-DD`, taken from the locked offer. - `checkin` (string): Check-in date, `YYYY-MM-DD`, taken from the locked offer. The snake_case twin of `checkinDate`, with the same value: use this name. - `checkoutDate` (string, deprecated): Legacy name: use `checkout`. Check-out date, `YYYY-MM-DD`. - `checkout` (string): Check-out date, `YYYY-MM-DD`. The snake_case twin of `checkoutDate`, with the same value: use this name. - `pricing` (object): The price of the booking as it was locked by `pre_book`. - `totalPrice` (number, deprecated): Legacy name: use `total_price`. What the traveler pays, in `currency`. - `total_price` (number): What the traveler pays, in `currency`. The snake_case twin of `totalPrice`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. Your commission, in `currency`. You are debited `totalPrice - agencyCommission`. - `agency_commission` (number): Your commission, in `currency`. You are debited `totalPrice - agencyCommission`. The snake_case twin of `agencyCommission`, with the same value: use this name. - `currency` (string, deprecated): Legacy name: use `agency_currency`. The currency of your agency. - `agency_currency` (string): The currency of your agency. The snake_case twin of `currency`, with the same value: use this name. - `supplierCurrency` (string, deprecated): Legacy name: use `supplier_currency`. The currency the supplier priced in: `EUR`. - `supplier_currency` (string): The currency the supplier priced in: `EUR`. The snake_case twin of `supplierCurrency`, with the same value: use this name. - `originalPrice` (number): The supplier's price in `supplierCurrency`, for information. - `postProcessing` (object): Internal progress of the booking. Poll the booking instead of reading it. - `bookingDbId` (string): The internal id of the booking row (a UUID). The booking detail calls it `id`. - `webhookSent` (boolean): Describes an internal step of FX-Port (the voucher rendering), not a message to you: FX-Port sends no webhooks to customers. - `bucketLink` (string, nullable, deprecated): Legacy name: use `voucher_url`. The voucher link. Always `null` here: the voucher is made after the call; read it with `get_voucher`. - `voucher_url` (string, nullable): The voucher link. Always `null` here: the voucher is made after the call; read it with `get_voucher`. The snake_case twin of `bucketLink`, with the same value: use this name. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds the call took. - `processing_time` (number): Seconds the call took. The snake_case twin of `processingTime`, with the same value: use this name. - `balanceDebited` (boolean, deprecated): Legacy name: use `balance_debited`. Sandbox only: `false`, nothing was debited. The key is absent on live. - `balance_debited` (boolean): Sandbox only: `false`, nothing was debited. The key is absent on live. The snake_case twin of `balanceDebited`, with the same value: use this name. - `transactionCreated` (boolean, deprecated): Legacy name: use `transaction_created`. Sandbox only: `false`. Absent on live. - `transaction_created` (boolean): Sandbox only: `false`. Absent on live. The snake_case twin of `transactionCreated`, with the same value: use this name. - `payoutCreated` (boolean, deprecated): Legacy name: use `payout_created`. Sandbox only: `false`. Absent on live. - `payout_created` (boolean): Sandbox only: `false`. Absent on live. The snake_case twin of `payoutCreated`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "status": "pending", "bookingId": "HMVXX4ZQ3", "partnerOrderId": "HMVXX4ZQ3", "hotelId": "test_hotel_do_not_book", "checkinDate": "2026-12-01", "checkoutDate": "2026-12-04", "pricing": { "totalPrice": 1484.97, "agencyCommission": 2.97, "currency": "DZD", "supplierCurrency": "EUR", "originalPrice": 3 }, "postProcessing": { "bookingDbId": "24fec1e5-a09f-48ec-8050-1adaab0a8de1", "webhookSent": false, "bucketLink": null }, "processingTime": 5.449, "balanceDebited": false, "transactionCreated": false, "payoutCreated": false } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_field` | Missing required field: rooms | | 400 | `invalid_rooms` | 'rooms' must be a non-empty list: one {guests: [...]} object per room. | | 400 | `invalid_send_email` | 'send_email' must be true or false (got 'maybe'). | | 400 | `invalid_is_child` | 'rooms[0].guests[1].is_child' must be true or false (got 'maybe'). | | 400 | `invalid_request` | 'partner.partner_order_id' differs from 'booking_id': send the booking id once, or the same value twice. | | 400 | `incorrect_guests_number` | Incorrect guests number | | 400 | `order_not_found` | Order not found | | 402 | `insufficient_balance` | The agency balance is lower than the amount to debit. | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 409 | `duplicate_booking` | A booking already exists for this partner_order_id: do not call book_hotel again, read its status with GET /api/v1/bookings/{booking_id}?product=hotels. | | 409 | `cached_offer_required` | Trusted cached hotel offer not found. Re-run pre_book before booking so the backend can verify the exact debit amount. | | 500 | `post_booking_failed` | The booking was accepted but its post-processing failed; read its status with GET /api/v1/bookings/{booking_id}?product=hotels. | | 502 | `supplier_invalid_response` | The hotel supplier returned an unreadable answer to the booking request, so the booking may or may not have been made: read its status with GET /api/v1/bookings/{booking_id}?product=hotels before you retry. | | 503 | `balance_lock_timeout` | Another booking of this agency is still being processed; retry in a few seconds. | | 503 | `supplier_busy` | The supplier is temporarily at capacity; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Book a hotel](https://fx-port.com/docs/hotels/book.md) - [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md) - [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md) --- # Cancel a confirmed hotel booking > Cancels a booking of your agency and refunds it according to the cancellation schedule of the rate. Endpoint: `POST /hotels/cancel_hotel` Source: https://fx-port.com/docs/api/cancel-hotel `POST /api/v1/hotels/cancel_hotel` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. Only a booking whose `status` is `confirmed` can be cancelled: right after `book_hotel` it is `pending`, so wait for `confirmed`. The booking becomes `refunded` (free cancellation), `partial_refund` (a penalty applied) or `cancelled` (nothing refunded). The call takes 1 to 4 seconds. The supplier occasionally takes 30 seconds and the call then fails with `503 supplier_timeout`: the cancellation may or may not have happened, so read the booking, and call again only if it is still `confirmed`. In the sandbox cancel every test booking: it is free until 21:00 UTC on the check-in day. ## Request ### Body Required, JSON. - `booking_id` (string, required): The id of the booking to cancel: the `bookingId` of `book_hotel`. - `partner_order_id` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `partnerOrderId` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. ### Example request ```bash curl https://api.fx-port.com/api/v1/hotels/cancel_hotel \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "booking_id": "HMVXX4ZQ3" }' ``` ## Response ### 200: The booking is cancelled. `bookingStatus` and `refund` say what was given back. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the supplier cancelled the booking. - `status` (string): Always `ok`: the call worked. The state of the booking is `bookingStatus`. Always `ok`. - `bookingId` (string, deprecated): Legacy name: use `booking_id`. The id of the booking that was cancelled. - `booking_id` (string): The id of the booking that was cancelled. The snake_case twin of `bookingId`, with the same value: use this name. - `bookingStatus` (string, deprecated): Legacy name: use `booking_status`. The new status of the booking: `refunded` (free cancellation), `partial_refund` (a penalty applied) or `cancelled` (no refund). One of `refunded`, `partial_refund`, `cancelled`. - `booking_status` (string): The new status of the booking: `refunded` (free cancellation), `partial_refund` (a penalty applied) or `cancelled` (no refund). The snake_case twin of `bookingStatus`, with the same value: use this name. One of `refunded`, `partial_refund`, `cancelled`. - `adminCancel` (boolean): `true` when FX-Port staff cancelled the booking. Always `false` for a call of yours. - `refund` (object): What the cancellation gave back. - `cancellationType` (string, deprecated): Legacy name: use `cancellation_type`. `free`: the supplier refunded everything; `partial`: a penalty applied; `none`: no refund. One of `free`, `partial`, `none`. - `cancellation_type` (string): `free`: the supplier refunded everything; `partial`: a penalty applied; `none`: no refund. The snake_case twin of `cancellationType`, with the same value: use this name. One of `free`, `partial`, `none`. - `type` (string): The same as a label for people. One of `Free cancellation`, `Partial cancellation`, `No refund`. - `agencyAmount` (number, deprecated): Legacy name: use `agency_refund_amount`. What is credited back to your agency, in `currency`. `0` for `none`. - `agency_refund_amount` (number): What is credited back to your agency, in `currency`. `0` for `none`. The snake_case twin of `agencyAmount`, with the same value: use this name. - `travelerAmount` (number, deprecated): Legacy name: use `traveler_refund_amount`. What the traveler gets back, in `currency`. - `traveler_refund_amount` (number): What the traveler gets back, in `currency`. The snake_case twin of `travelerAmount`, with the same value: use this name. - `currency` (string, deprecated): Legacy name: use `agency_currency`. The currency of your agency. - `agency_currency` (string): The currency of your agency. The snake_case twin of `currency`, with the same value: use this name. - `providerAmount` (number, deprecated): Legacy name: use `supplier_refund_amount`. What the supplier refunded, in `providerCurrency`. - `supplier_refund_amount` (number): What the supplier refunded, in `providerCurrency`. The snake_case twin of `providerAmount`, with the same value: use this name. - `providerCurrency` (string, deprecated): Legacy name: use `supplier_currency`. The supplier's currency: `EUR`. - `supplier_currency` (string): The supplier's currency: `EUR`. The snake_case twin of `providerCurrency`, with the same value: use this name. - `refundProcessed` (boolean, deprecated): Legacy name: use `refund_processed`. `true` when the refund was handled. In the sandbox nothing moves, and the flag is still `true`. - `refund_processed` (boolean): `true` when the refund was handled. In the sandbox nothing moves, and the flag is still `true`. The snake_case twin of `refundProcessed`, with the same value: use this name. - `webhookSent` (boolean): Describes an internal step of FX-Port, not a message to you: FX-Port sends no webhooks to customers. - `processingTime` (number, deprecated): Legacy name: use `processing_time`. Seconds the call took. - `processing_time` (number): Seconds the call took. The snake_case twin of `processingTime`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "status": "ok", "bookingId": "HMVXX4ZQ3", "bookingStatus": "refunded", "adminCancel": false, "refund": { "cancellationType": "free", "type": "Free cancellation", "agencyAmount": 1482, "travelerAmount": 1484.97, "currency": "DZD", "providerAmount": 3, "providerCurrency": "EUR" }, "refundProcessed": true, "webhookSent": true, "processingTime": 3.488 } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_status` | Booking is 'pending', can only cancel 'confirmed' bookings | | 400 | `missing_field` | Missing required field: booking_id | | 400 | `invalid_booking_id` | 'booking_id' must be 1 to 256 characters: letters, digits and '_ . : -' (got 'abc def'). | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 403 | `forbidden` | This booking does not belong to your agency. Only the agency that made the booking can cancel it. | | 404 | `booking_not_found` | Booking HZZZZZZZZ not found | | 502 | `supplier_invalid_response` | The hotel supplier returned an answer that could not be used. Retry in a few minutes. | | 503 | `supplier_timeout` | The hotel supplier did not answer in time, so the request may or may not have been processed: read the booking's status before you retry. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md) --- # Get the voucher PDF links of a booking > Returns the links of the voucher PDFs of a booking of your agency: one with the price and one without it, to hand to the traveler. Endpoint: `GET /hotels/get_voucher` Source: https://fx-port.com/docs/api/get-hotel-voucher `GET /api/v1/hotels/get_voucher` Requires an API key with **Read** permission; a **Read+Write** key works too. The links are permanent public URLs without a signature: anyone who has one can open the voucher, so do not post it where it should not be read. The voucher is made after the booking is `confirmed`, usually at the same moment and sometimes up to 10 seconds later: until then the call answers `404` with the code `pdf_not_found`; try again in 2 seconds. The links stay available after a cancellation. The same links are in `voucherUrl` and `voucherUrlNoPrice` of the booking detail. ## Request ### Query parameters - `booking_id` (string, required): The id of the booking: the `bookingId` of `book_hotel`. - `partner_order_id` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `partnerOrderId` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. - `bookingId` (string, deprecated, optional): Legacy name: use `booking_id`. The booking id, in its older spelling. Use `booking_id`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/hotels/get_voucher?booking_id=HMVXX4ZQ3' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The voucher links. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. - `warnings` (array of object): Present only when the request used a previous spelling of a field or something the endpoint ignored. - `code` (string): What the warning is about. One of `deprecated_field`, `ignored_field`, `coerced_value`, `deprecated_value`, `deprecated_endpoint`. - `field` (string): The request field the warning is about. - `replacement` (string): The name to use instead, for `deprecated_*` warnings. - `message` (string): A sentence that says what happened and what to do. - `documentation_url` (string): Where the naming rules are explained. - `success` (boolean): `true` when the voucher exists. - `data` (object): The voucher links of a booking. - `bookingId` (string, deprecated): Legacy name: use `booking_id`. The id of the booking. - `booking_id` (string): The id of the booking. The snake_case twin of `bookingId`, with the same value: use this name. - `voucherUrl` (string, deprecated): Legacy name: use `voucher_url`. The voucher PDF, with the price. A permanent public link: anyone who has it can open it. - `voucher_url` (string): The voucher PDF, with the price. A permanent public link: anyone who has it can open it. The snake_case twin of `voucherUrl`, with the same value: use this name. - `voucherUrlWithPrice` (string, deprecated): Legacy name: use `voucher_url_with_price`. The same link as `voucherUrl`. - `voucher_url_with_price` (string): The same link as `voucherUrl`. The snake_case twin of `voucherUrlWithPrice`, with the same value: use this name. - `voucherUrlNoPrice` (string, nullable, deprecated): Legacy name: use `voucher_url_no_price`. The voucher PDF without the price, to hand to the traveler. `null` when it was not made. - `voucher_url_no_price` (string, nullable): The voucher PDF without the price, to hand to the traveler. `null` when it was not made. The snake_case twin of `voucherUrlNoPrice`, with the same value: use this name. - `status` (string): The status of the booking. A voucher stays available after a cancellation. One of `pending`, `confirmed`, `failed`, `refunded`, `partial_refund`, `cancelled`. ```json { "environment": "sandbox", "success": true, "data": { "bookingId": "HMVXX4ZQ3", "voucherUrl": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlWithPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith.pdf", "voucherUrlNoPrice": "https://files.example.com/hotel-bookings/HMVXX4ZQ3-Jane-Smith-no-price.pdf", "status": "confirmed" } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 403 | `forbidden` | This booking does not belong to your agency. Only the agency that made the booking can retrieve its voucher. | | 404 | `booking_not_found` | Booking not found | | 404 | `pdf_not_found` | The voucher is not available yet: it is made shortly after the booking is confirmed. Retry in a few seconds. | | 422 | `validation_error` | Field required | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Get the hotel voucher](https://fx-port.com/docs/hotels/voucher.md) --- # List eSIM packages > List the eSIM packages your agency can sell, with prices in your agency currency. Endpoint: `GET /esims/packages` Source: https://fx-port.com/docs/api/list-esim-packages `GET /api/v1/esims/packages` Requires an API key with **Read** permission; a **Read+Write** key works too. Filter by `country` (a slug such as `france`). The unfiltered catalog is about 4 MB, has no pagination and is the slowest call of the eSIM API, so send `country` whenever you can and accept compressed responses. English text is served from a cache that is refreshed about every 50 minutes. Another `locale` is fetched from the supplier on its first call (about 10 seconds) and then cached for an hour. Regional and worldwide bundles are `global` packages whose `country_slug` is the region (for example `europe`). A `202` means the catalog is still loading after a restart: wait a few seconds and send the call again. Four amounts describe each package: `base_price`, `retail_price`, `balance_debit_amount` and `agency_commission`. Read the pricing guide before you compute anything from them. ## Request ### Query parameters - `country` (string, optional): Country slug, for example `france` or `turkey`. Regions have their own slug, for example `europe`. A country name as people write it is read as its slug (`United States` is `united-states`, `Bosnia & Herzegovina` is `bosnia-and-herzegovina`); case and accents do not matter. An unknown slug or a name that matches no slug returns an empty list. At most 64 characters. - `type` (string, optional): Scope of the package: `local` is one country, `global` is every regional and worldwide bundle. Any other well-formed value returns an empty list: there is no `regional` value. At most 32 characters. - `filter[type]` (string, deprecated, optional): Legacy name: use `type`. Previous name of `type`. It still works and the response carries a `deprecated_field` warning. At most 32 characters. - `locale` (string, optional): Language of the text fields. Without it the `Accept-Language` header is used, then `en`. Any spelling of a locale is read and only its language counts: `fr`, `fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9` (the first language is used). Supported: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi`, `zh`. Another well-formed language is answered in English and the response carries a `coerced_value` warning. At most 80 characters. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/esims/packages?country=france&type=local' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The packages that match the filters. - `success` (boolean): `true` when the catalog was returned. - `endpoint` (string): The route that answered, `/packages`. - `data` (array of EsimPackage): The packages that match the filters. Not paginated: filter by `country`. - `meta` (object): Facts about the catalog response. - `total_packages` (integer): Number of packages in `data`, after the filters. - `filters_applied` (object): The filters the request applied. - `country` (string, nullable): The `country` filter, or null. - `type` (string, nullable): The `type` filter, or null. - `locale` (string): Language used for the text fields of this response. - `agency_currency` (string, deprecated): Legacy name: use `currency`. Currency of every amount: the settlement currency of your agency. The older name of `currency`: same value, use `currency`. - `currency` (string): Currency of every amount: the settlement currency of your agency. The snake_case twin of `agency_currency`, with the same value: use this name. - `supplier_currency` (string): Currency the supplier prices packages in. - `exchange_rate` (number): Rate used to convert `supplier_currency` to your agency currency. - `commission_percentage` (string): Your commission rate as text, for example `12 %`. - `commission_rate` (number): Your commission rate as a number: `12` for a standard agency, `0` for a contracted corporate agency. ```json { "environment": "sandbox", "success": true, "endpoint": "/packages", "data": [ { "id": "france-in-7days-1gb", "package_id": "france-in-7days-1gb", "type": "sim", "price": 402.5, "base_price": 402.5, "amount": 1024, "day": 7, "is_unlimited": false, "title": "1 GB - 7 days", "is_fair_usage_policy": false, "fair_usage_policy": null, "data": "1 GB", "voice": null, "text": null, "country_name": "France", "country_code": "FR", "country_slug": "france", "country_image_url": "https://cdn-revamp.airalo.com/images/9753dedb-d495-47cf-b6e4-82e555564743.png", "operator_name": "KPN France Data only", "operator_id": 1704, "operator_style": "dark", "operator_gradient_start": "#F1D4FB", "operator_gradient_end": "#EEC0FF", "operator_type": "local", "operator_is_prepaid": false, "operator_esim_type": "Prepaid", "operator_warning": null, "operator_apn_type": "automatic", "operator_is_roaming": true, "operator_info": [ "Data-only eSIM.", "Rechargeable online with no expiry." ], "operator_image_url": "https://cdn-revamp.airalo.com/images/01b47d41-fbb4-4032-8f5a-d1ca6a0f3688.png", "operator_plan_type": "data", "operator_activation_policy": "first-usage", "operator_is_kyc_verify": false, "operator_rechargeability": true, "operator_other_info": "Covers metropolitan (mainland) France and Corsica. Does not include overseas territories (Guadeloupe, Martinique, Reunion, ...", "operator_coverages": [ { "name": "FR", "code": "FR", "networks": [ { "name": "Orange", "types": [ "5G" ] } ] } ], "operator_apn": { "ios": { "apn_type": "automatic", "apn_value": null }, "android": { "apn_type": "automatic", "apn_value": null } }, "is_regional": false, "is_global": false, "retail_price": 468.84, "total_price": 468.84, "balance_debit_amount": 418.6, "b2b_price": 418.6, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250 }, { "id": "france-in-15days-2gb", "package_id": "france-in-15days-2gb", "type": "sim", "price": 547.5, "base_price": 547.5, "amount": 2048, "day": 15, "is_unlimited": false, "title": "2 GB - 15 days", "is_fair_usage_policy": false, "fair_usage_policy": null, "data": "2 GB", "voice": null, "text": null, "country_name": "France", "country_code": "FR", "country_slug": "france", "country_image_url": "https://cdn-revamp.airalo.com/images/9753dedb-d495-47cf-b6e4-82e555564743.png", "operator_name": "KPN France Data only", "operator_id": 1704, "operator_style": "dark", "operator_gradient_start": "#F1D4FB", "operator_gradient_end": "#EEC0FF", "operator_type": "local", "operator_is_prepaid": false, "operator_esim_type": "Prepaid", "operator_warning": null, "operator_apn_type": "automatic", "operator_is_roaming": true, "operator_info": [ "Data-only eSIM.", "Rechargeable online with no expiry." ], "operator_image_url": "https://cdn-revamp.airalo.com/images/01b47d41-fbb4-4032-8f5a-d1ca6a0f3688.png", "operator_plan_type": "data", "operator_activation_policy": "first-usage", "operator_is_kyc_verify": false, "operator_rechargeability": true, "operator_other_info": "Covers metropolitan (mainland) France and Corsica. Does not include overseas territories (Guadeloupe, Martinique, Reunion, ...", "operator_coverages": [ { "name": "FR", "code": "FR", "networks": [ { "name": "Orange", "types": [ "5G" ] } ] } ], "operator_apn": { "ios": { "apn_type": "automatic", "apn_value": null }, "android": { "apn_type": "automatic", "apn_value": null } }, "is_regional": false, "is_global": false, "retail_price": 637.73, "total_price": 637.73, "balance_debit_amount": 569.4, "b2b_price": 569.4, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250 } ], "meta": { "total_packages": 12, "filters_applied": { "country": "france", "type": "local" }, "locale": "en", "agency_currency": "DZD", "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250, "commission_percentage": "12 %", "commission_rate": 12 } } ``` ### 202: The catalog is still loading after a restart. `success` is `false` and `data` is empty: retry in a few seconds. - `success` (boolean): Always `false`: the data is not available yet. - `message` (string): What is missing and that it is still loading. - `data` (array of object): Always empty. - `meta` (object): Empty for a catalog that is loading. ```json { "environment": "sandbox", "success": false, "message": "No cached packages available. Package fetcher may still be loading initial data.", "data": [], "meta": {} } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_country` | country must be a country slug such as france (received 'France!'). | | 400 | `invalid_locale` | locale must be a language code such as en, fr or ar (received '12'). | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Package catalog](https://fx-port.com/docs/esims/packages.md) - [eSIM pricing](https://fx-port.com/docs/esims/pricing.md) --- # List eSIM compatible devices > List the devices known to support eSIM, to check a traveler's phone before the sale. Endpoint: `GET /esims/compatible-devices` Source: https://fx-port.com/docs/api/list-esim-compatible-devices `GET /api/v1/esims/compatible-devices` Requires an API key with **Read** permission; a **Read+Write** key works too. The list has about 630 entries and is not paginated. It is cached and refreshed about once a week; `meta.cached_at` tells when. Match on `model` (the manufacturer's identifier) or `name`; the case of `brand` differs between entries. A `202` means the list is still loading after a restart and a `503` that the device fetcher is not running yet: wait a few seconds and retry. ## Request ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/compatible-devices \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The devices. - `success` (boolean): `true` when the list was returned. - `data` (array of object): The devices. About 630 entries, not paginated. - `model` (string): The manufacturer's model identifier. - `os` (string): Operating system family. One of `android`, `ios`. - `brand` (string): Manufacturer, as the supplier spells it (the case differs between entries). - `name` (string): Marketing name of the device. - `meta` (object): Facts about the list. - `total_devices` (integer): Number of devices in `data`. - `cached_at` (string): When the list was fetched from the supplier: the server's clock without a zone, for example `2026-11-01T15:07:43.957236`. The list is refreshed about once a week. - `cache_age_days` (number): Age of the list in days. ```json { "environment": "sandbox", "success": true, "data": [ { "model": "T803D", "os": "android", "brand": "Alcatel", "name": "V3 Ultra" }, { "model": "iPhone15,2", "os": "ios", "brand": "Apple", "name": "iPhone 14 Pro" } ], "meta": { "total_devices": 631, "cached_at": "2026-11-01T15:07:43.957236", "cache_age_days": 0.01 } } ``` ### 202: The list is still loading. `success` is `false` and `data` is empty: retry in a few seconds. - `success` (boolean): Always `false`: the data is not available yet. - `message` (string): What is missing and that it is still loading. - `data` (array of object): Always empty. - `meta` (object): Empty for a catalog that is loading. ```json { "environment": "sandbox", "success": false, "message": "No cached devices available. Device fetcher may still be loading initial data.", "data": [] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Package catalog](https://fx-port.com/docs/esims/packages.md) --- # Create an eSIM order > Create one eSIM, debit your agency balance (live only) and return the eSIM with its activation data. Endpoint: `POST /esims/orders` Source: https://fx-port.com/docs/api/create-esim-order `POST /api/v1/esims/orders` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. Prices are computed on the server. `quantity` and every price field of the body are ignored (the response lists them in `warnings`), and every order creates exactly one eSIM: call the endpoint once per eSIM. The names of the traveler are optional and are not restricted to ASCII. Send `Content-Type: application/json` (in any case). A form-encoded body also works, but a JSON body sent without the JSON content type is read as an empty form and answered with `missing_field`. There is no idempotency key. While an order for a package is being processed, another order for the same package is refused with `duplicate_request`; on live so is an order for a package when the most recent confirmed order of your agency is for that same package and is less than 60 seconds old, whoever the traveler is. If a call times out, look for the booking with `GET /esims/bookings?search=` before you send the order again. If the supplier refuses the order the response has the supplier's status, nothing is debited and a `failed` booking is saved. In the sandbox the supplier creates a real test eSIM with a dummy activation code, no balance is debited, and the response (including its error bodies) carries `balanceDebited`, `transactionCreated` and `payoutCreated`, all `false`, with a snake_case twin each. ## Request ### Body Required, JSON. - `package_id` (string, required): The `id` of a package from the catalog (its `package_id`). At most 128 characters. - `email` (string, required): The traveler's email address. FX-Port asks the supplier to send the QR code and a sharing link to it. At most 254 characters. - `phone` (string, required): The traveler's phone number: at least 3 digits. Kept on the booking: it is not sent to the supplier. At most 32 characters. - `first_name` (string, optional): The traveler's first name. Any characters are accepted. Kept on the booking only. At most 100 characters. - `last_name` (string, optional): The traveler's last name. Any characters are accepted. Kept on the booking only. At most 100 characters. - `description` (string, optional): A note for your own records. It is sent to the supplier with the order and returned as `order.description`. At most 500 characters. - `agent_id` (string (uuid), optional): The id of the user of your agency who places the order. Optional: it appears on the booking as `agent_id`. A user of another agency is refused. - `user_id` (string (uuid), deprecated, optional): Legacy name: use `agent_id`. Previous name of `agent_id`. - `customer_email` (string, deprecated, optional): Legacy name: use `email`. The name the booking rows use for `email`. Still accepted. - `customer_phone` (string, deprecated, optional): Legacy name: use `phone`. The name the booking rows use for `phone`. Still accepted. - `customer_first_name` (string, deprecated, optional): Legacy name: use `first_name`. The name the booking rows use for `first_name`. Still accepted. - `customer_last_name` (string, deprecated, optional): Legacy name: use `last_name`. The name the booking rows use for `last_name`. Still accepted. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/orders \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "package_id": "france-in-7days-1gb", "first_name": "John", "last_name": "Smith", "email": "traveler@example.com", "phone": "+15555550123", "description": "Order for John Smith" }' ``` ## Response ### 200: The order was placed. The response has status 200, not 201. - `success` (boolean): `true` when the order was placed. - `data` (object): The order and its eSIM. `sim` is null when the supplier has not returned the eSIM yet: ask for the setup instructions later. - `order` (object): The order, with the amounts in your agency currency. - `code` (string, deprecated): Legacy name: use `order_id`. The supplier's order code, for example `20261101-106852`. Booking rows call it `order_id`. The older name of `order_id`: same value, use `order_id`. - `order_id` (string): The supplier's order code, for example `20261101-106852`. Booking rows call it `order_id`. The snake_case twin of `code`, with the same value: use this name. - `package_id` (string): The package that was ordered. - `type` (string): Always `sim`. - `description` (string, nullable): The `description` you sent. - `esim_type` (string): Kind of eSIM plan, for example `local`. - `created_at` (string): When the supplier created the order: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone. - `package` (string): Name of the package: operator, data allowance and validity. - `data` (string): Data allowance as text, for example `1 GB`. - `validity` (integer): Validity in days. - `price` (number): What the traveler pays, in your agency currency. Equal to `total_price` here (in the catalog, `price` is a different amount: see the pricing guide). - `currency` (string): Currency of the amounts: the settlement currency of your agency. - `total_price` (number): The price to charge the traveler, in your agency currency. Your commission is `total_price` minus the amount debited from your balance. - `retail_price` (number): The price to charge the traveler, in your agency currency. Your commission is `total_price` minus the amount debited from your balance. Another name of `total_price`, with the same value. - `agency_currency` (string): Same as `currency`. - `supplier_currency` (string): Currency the supplier prices the package in. - `exchange_rate` (number): Rate used to convert `supplier_currency` to your agency currency. - `voice` (integer, nullable): Voice minutes included, or null. - `text` (integer, nullable): SMS included, or null. - `coverage` (string): Coverage text taken from the installation instructions. Can be an empty string. - `sim` (object, nullable): The eSIM that was created. Treat the activation data as a secret of the traveler. - `iccid` (string): The ICCID of the eSIM. Keep it: usage, topups and instructions are asked by ICCID. - `created_at` (string, nullable): When the supplier created the eSIM: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone. - `lpa` (string, nullable): Host of the SM-DP+ server (the LPA address). - `imsis` (string, nullable): IMSI of the profile, when the supplier returns it. - `qrcode` (string, nullable): The activation string the QR code encodes: `LPA:1$$`. Sensitive: show it only to the traveler. - `qrcode_url` (string, nullable): Link to the QR code image. - `direct_apple_installation_url` (string, nullable): Link that starts the installation on an iPhone. - `apn_type` (string, nullable): `automatic` or `manual`. - `apn_value` (string, nullable): The APN to enter on a manual setup, or `N/A`. - `apn` (object, nullable): APN settings per operating system. - `ios` (object): Access point name (APN) settings for one operating system. - `apn_type` (string): `automatic` when the device configures the access point name by itself, `manual` when the traveler has to enter it. - `apn_value` (string, nullable): The access point name to enter on a manual setup; null or `N/A` when none is needed. - `android` (object): Access point name (APN) settings for one operating system. - `apn_type` (string): `automatic` when the device configures the access point name by itself, `manual` when the traveler has to enter it. - `apn_value` (string, nullable): The access point name to enter on a manual setup; null or `N/A` when none is needed. - `is_roaming` (boolean, nullable): Whether the eSIM works through roaming. - `confirmation_code` (string, nullable): Confirmation code to enter at installation, when the operator requires one. - `msisdn` (string, nullable): Phone number of the eSIM, for the rare plans that include one. - `sharing` (object, nullable): A shareable installation page for the eSIM. - `access_code` (number): Code the traveler enters on the sharing page. - `link` (string): Page where the traveler can open the eSIM details and QR code. - `iccid` (string): The ICCID of the eSIM: the same value as `sim.iccid`. When the supplier has not returned the eSIM yet it is the placeholder `PENDING-` followed by the order code. - `booking_id` (string): The FX-Port booking reference, `FX-` followed by a number. Keep it: it is the key of the booking in every other endpoint. - `message` (string): A confirmation sentence. - `balanceDebited` (boolean, deprecated): Legacy name: use `balance_debited`. Sandbox only, always `false`: no balance was debited. The older name of `balance_debited`: same value, use `balance_debited`. - `balance_debited` (boolean): Sandbox only, always `false`: no balance was debited. The snake_case twin of `balanceDebited`, with the same value: use this name. - `transactionCreated` (boolean, deprecated): Legacy name: use `transaction_created`. Sandbox only, always `false`: no balance transaction was created. The older name of `transaction_created`: same value, use `transaction_created`. - `transaction_created` (boolean): Sandbox only, always `false`: no balance transaction was created. The snake_case twin of `transactionCreated`, with the same value: use this name. - `payoutCreated` (boolean, deprecated): Legacy name: use `payout_created`. Sandbox only, always `false`: no supplier payout was created. The older name of `payout_created`: same value, use `payout_created`. - `payout_created` (boolean): Sandbox only, always `false`: no supplier payout was created. The snake_case twin of `payoutCreated`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "data": { "order": { "code": "20261101-106852", "order_id": "20261101-106852", "package_id": "france-in-7days-1gb", "type": "sim", "description": "Order for John Smith", "esim_type": "local", "created_at": "2026-11-01 15:23:22", "package": "KPN France Data only-1 GB - 7 days", "data": "1 GB", "validity": 7, "price": 468.84, "currency": "DZD", "total_price": 468.84, "retail_price": 468.84, "agency_currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250, "voice": null, "text": null, "coverage": "" }, "sim": { "iccid": "8900000540463077957", "created_at": "2026-11-01 15:23:22", "lpa": "lpa.airalo.com", "imsis": null, "qrcode": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qrcode_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=...", "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "apn_type": "automatic", "apn_value": "N/A", "apn": { "ios": { "apn_type": "automatic", "apn_value": "N/A" }, "android": { "apn_type": "automatic", "apn_value": "N/A" } }, "is_roaming": true, "confirmation_code": null, "msisdn": null, "sharing": { "access_code": 8803, "link": "https://esims.cloud/v8hy7-usgkq6wg" } }, "iccid": "8900000540463077957" }, "booking_id": "FX-1793546602916", "message": "eSIM order placed successfully", "balanceDebited": false, "balance_debited": false, "transactionCreated": false, "transaction_created": false, "payoutCreated": false, "payout_created": false } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_field` | package_id is required | | 400 | `invalid_email` | email is not a valid email address | | 400 | `missing_field` | phone is required | | 400 | `invalid_agent_id` | agent_id is not a user of your agency. | | 402 | `insufficient_balance` | Agency balance is insufficient. Available: 300.0 DZD, Required: 418.6 DZD. | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `not_found` | Package no-such-package-1gb not found in cache. Packages may still be loading — retry in a moment. | | 409 | `duplicate_request` | An identical order is already being processed. Please wait before retrying. | | 429 | `rate_limit_exceeded` | Too many requests hit the API too quickly. Please retry your request after the time specified in the `ratelimit-reset` header. | | 429 | `concurrency_limit_exceeded` | Too many requests of this kind are already running for this API key; wait for one to finish and retry. | | 503 | `service_unavailable` | Airalo token unavailable | | 503 | `balance_lock_timeout` | Another operation on this balance is in progress. Please retry in a few seconds. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Create an eSIM order](https://fx-port.com/docs/esims/orders.md) - [eSIM pricing](https://fx-port.com/docs/esims/pricing.md) --- # List topup packages of an eSIM > List the packages that can top up one eSIM of your agency, with prices in your agency currency. Endpoint: `GET /esims/topup-packages/{iccid}` Source: https://fx-port.com/docs/api/list-esim-topup-packages `GET /api/v1/esims/topup-packages/{iccid}` Requires an API key with **Read** permission; a **Read+Write** key works too. Always call this before you create a topup: the packages depend on the eSIM, and the `package_id` you send to `POST /esims/topups` must come from this list. An empty `data.data` means the eSIM cannot be topped up. The list is cached for five minutes per eSIM and language (`cached` and `cache_age_seconds` say so). `force=true` skips the cache. In the topup list `price` is the retail amount; the amount that is debited is `balance_debit_amount`. ## Request ### Path parameters - `iccid` (string, required): The eSIM to top up. It must be an eSIM that your agency ordered. ### Query parameters - `force` (boolean, optional): Skip the five-minute cache and ask the supplier again. An empty value means not sent. Default `false`. - `locale` (string, optional): Language of the text fields. Without it the `Accept-Language` header is used, then `en`. Any spelling of a locale is read and only its language counts: `fr`, `fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9` (the first language is used). Supported: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi`, `zh`. Another well-formed language is answered in English and the response carries a `coerced_value` warning. At most 80 characters. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/topup-packages/8900000540463077957 \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The topup packages of the eSIM. - `success` (boolean): `true` when the list was returned. - `data` (object): Wrapper object: the list is `data.data`. - `data` (array of object): The packages that can top up this eSIM. Empty when it cannot be topped up. - `id` (string, deprecated): Legacy name: use `package_id`. Topup package id. Send it as `package_id` when you create the topup. The older name of `package_id`: same value, use `package_id`. - `package_id` (string): Topup package id. Send it as `package_id` when you create the topup. The snake_case twin of `id`, with the same value: use this name. - `type` (string): Always `topup`. - `price` (number): The price to charge the traveler, in your agency currency. Same as `total_price`. Here `price` is the retail amount, unlike the catalog. - `amount` (integer): Data allowance in megabytes. `0` means unlimited. - `day` (integer): Validity in days. - `is_unlimited` (boolean): `true` for unlimited-data packages. - `title` (string): Label of the package, in the language of the request. - `data` (string): Data allowance as text, for example `1 GB`. - `short_info` (string, nullable): A short note about the package. Usually null. - `voice` (integer, nullable): Voice minutes included, or null. - `text` (integer, nullable): SMS included, or null. - `prices` (object): Reference prices published by the supplier. - `recommended_retail_price` (object): The supplier's recommended retail price in many currencies, by currency code. For reference only: it is not what you are charged. - `base_price` (number): Base price in your agency currency, before your commission. Same meaning as `price` in the catalog. - `commission` (number): Your commission as an amount, in your agency currency. - `total_price` (number): The price to charge the traveler, in your agency currency. - `retail_price` (number): The price to charge the traveler, in your agency currency. Another name of `total_price`, with the same value. - `balance_debit_amount` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance when you create this topup. The older name of `b2b_price`: same value, use `b2b_price`. - `b2b_price` (number): The amount debited from your agency balance when you create this topup. The snake_case twin of `balance_debit_amount`, with the same value: use this name. - `agency_commission` (string): Your commission rate as text, for example `12 %`. - `commission_rate` (number): Your commission rate as a number: `12` for a standard agency, `0` for a contracted corporate agency. - `currency` (string): Currency of every amount: the settlement currency of your agency. - `supplier_currency` (string): Currency the supplier prices the package in. - `cached` (boolean): `true` when the list comes from the five-minute cache. Absent on a fresh answer. - `cache_age_seconds` (integer): Age of the cached list in seconds. Present with `cached`. ```json { "environment": "sandbox", "success": true, "data": { "data": [ { "id": "france-in-7days-1gb-topup", "package_id": "france-in-7days-1gb-topup", "type": "topup", "price": 468.84, "amount": 1024, "day": 7, "is_unlimited": false, "title": "1 GB - 7 days", "data": "1 GB", "short_info": null, "voice": null, "text": null, "prices": { "recommended_retail_price": { "EUR": 3.56, "GBP": 3, "USD": 4 } }, "base_price": 402.5, "commission": 50.24, "total_price": 468.84, "retail_price": 468.84, "balance_debit_amount": 418.6, "b2b_price": 418.6, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD" }, { "id": "france-in-15days-2gb-topup", "package_id": "france-in-15days-2gb-topup", "type": "topup", "price": 637.73, "amount": 2048, "day": 15, "is_unlimited": false, "title": "2 GB - 15 days", "data": "2 GB", "short_info": null, "voice": null, "text": null, "prices": { "recommended_retail_price": { "EUR": 5.34, "GBP": 4.5, "USD": 6 } }, "base_price": 547.5, "commission": 68.33, "total_price": 637.73, "retail_price": 637.73, "balance_debit_amount": 569.4, "b2b_price": 569.4, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD" } ] } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_iccid` | iccid must be 18-22 digits (received '123'). | | 404 | `iccid_not_found` | No eSIM with this ICCID was found for your account. | | 503 | `service_unavailable` | Airalo token unavailable | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Top up an eSIM](https://fx-port.com/docs/esims/topups.md) - [Supplier continuity](https://fx-port.com/docs/esims/supplier-continuity.md) --- # Top up an eSIM > Add a topup package to an eSIM of your agency and debit your agency balance (live only). Endpoint: `POST /esims/topups` Source: https://fx-port.com/docs/api/create-esim-topup `POST /api/v1/esims/topups` Requires an API key with **Read+Write** permission: a Read key is refused with `read_write_permission_required`. Call `GET /esims/topup-packages/{iccid}` first and send one of its `package_id` values: a package that was not returned for this eSIM is refused. `email` and `phone` are required and kept on the booking; the email is not sent to the supplier on a topup. The ICCID must be an eSIM that your agency ordered. The same rules as for an order apply: JSON body, server-side prices, no idempotency key, `duplicate_request` while the same topup is running or (live) when a topup of the same ICCID was confirmed in the last 60 seconds. If the supplier refuses the topup nothing is debited and a `failed` booking is saved. The sandbox debits nothing, returns a `balance` of `0.0`, and adds the `balanceDebited`, `transactionCreated` and `payoutCreated` flags. The booking of a topup is listed by `GET /esims/bookings/{iccid}/topup-history`, not by `GET /esims/bookings`. ## Request ### Body Required, JSON. - `package_id` (string, required): The `id` of a topup package returned for this ICCID by the topup packages endpoint (its `package_id`). At most 128 characters. - `iccid` (string, required): The ICCID of the eSIM to top up: 18 to 22 digits, which may be written in groups of spaces or hyphens (`8944 4654 0000 0123 456`). It must be an eSIM that your agency ordered. - `email` (string, required): The traveler's email address. Kept on the booking: it is not sent to the supplier on a topup. At most 254 characters. - `phone` (string, required): The traveler's phone number: at least 3 digits. Kept on the booking. At most 32 characters. - `first_name` (string, optional): The traveler's first name. Any characters are accepted. At most 100 characters. - `last_name` (string, optional): The traveler's last name. Any characters are accepted. At most 100 characters. - `agent_id` (string (uuid), optional): The id of the user of your agency who places the topup. Optional. A user of another agency is refused. - `user_id` (string (uuid), deprecated, optional): Legacy name: use `agent_id`. Previous name of `agent_id`. - `customer_email` (string, deprecated, optional): Legacy name: use `email`. The name the booking rows use for `email`. Still accepted. - `customer_phone` (string, deprecated, optional): Legacy name: use `phone`. The name the booking rows use for `phone`. Still accepted. - `customer_first_name` (string, deprecated, optional): Legacy name: use `first_name`. The name the booking rows use for `first_name`. Still accepted. - `customer_last_name` (string, deprecated, optional): Legacy name: use `last_name`. The name the booking rows use for `last_name`. Still accepted. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/topups \ -H "Authorization: Bearer fxp_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "package_id": "france-in-7days-1gb-topup", "iccid": "8900000540463077957", "first_name": "John", "last_name": "Smith", "email": "traveler@example.com", "phone": "+15555550123" }' ``` ## Response ### 200: The topup was applied. The response has status 200, not 201. - `success` (boolean): `true` when the topup was applied. - `data` (object): The topup, the ICCID and the balance change. - `topup` (object): The topup, with the amounts in your agency currency. - `code` (string, deprecated): Legacy name: use `order_id`. The supplier's order code of the topup. Booking rows call it `order_id`. The older name of `order_id`: same value, use `order_id`. - `order_id` (string): The supplier's order code of the topup. Booking rows call it `order_id`. The snake_case twin of `code`, with the same value: use this name. - `package_id` (string): The topup package that was bought. - `package` (string): Name of the topup package. - `data` (string): Data allowance added, as text. - `validity` (integer): Validity of the topup in days. - `price` (number): What the traveler pays, in your agency currency. Equal to `total_price` here (in the catalog, `price` is a different amount: see the pricing guide). - `currency` (string): Currency of the amounts: the settlement currency of your agency. - `total_price` (number): The price to charge the traveler, in your agency currency. Your commission is `total_price` minus the amount debited from your balance. - `retail_price` (number): The price to charge the traveler, in your agency currency. Your commission is `total_price` minus the amount debited from your balance. Another name of `total_price`, with the same value. - `agency_currency` (string): Same as `currency`. - `supplier_currency` (string): Currency the supplier prices the package in. - `exchange_rate` (number): Rate used to convert `supplier_currency` to your agency currency. - `iccid` (string): The ICCID that was topped up. - `balance` (object): The agency balance around the topup. - `previous` (number): Agency balance in this currency before the topup. Always `0.0` in the sandbox. - `current` (number): Agency balance after the topup. Always `0.0` in the sandbox. - `currency` (string): Currency of the balance. - `booking_id` (string): The FX-Port booking reference of the topup, `FX-` followed by a number. - `message` (string): A confirmation sentence. - `balanceDebited` (boolean, deprecated): Legacy name: use `balance_debited`. Sandbox only, always `false`: no balance was debited. The older name of `balance_debited`: same value, use `balance_debited`. - `balance_debited` (boolean): Sandbox only, always `false`: no balance was debited. The snake_case twin of `balanceDebited`, with the same value: use this name. - `transactionCreated` (boolean, deprecated): Legacy name: use `transaction_created`. Sandbox only, always `false`: no balance transaction was created. The older name of `transaction_created`: same value, use `transaction_created`. - `transaction_created` (boolean): Sandbox only, always `false`: no balance transaction was created. The snake_case twin of `transactionCreated`, with the same value: use this name. - `payoutCreated` (boolean, deprecated): Legacy name: use `payout_created`. Sandbox only, always `false`: no supplier payout was created. The older name of `payout_created`: same value, use `payout_created`. - `payout_created` (boolean): Sandbox only, always `false`: no supplier payout was created. The snake_case twin of `payoutCreated`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "data": { "topup": { "code": "20261101-106853", "order_id": "20261101-106853", "package_id": "france-in-7days-1gb-topup", "package": "KPN France Data only-1 GB - 7 days", "data": "1 GB", "validity": 7, "price": 468.84, "currency": "DZD", "total_price": 468.84, "retail_price": 468.84, "agency_currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250 }, "iccid": "8900000540463077957", "balance": { "previous": 0, "current": 0, "currency": "DZD" } }, "booking_id": "FX-1793546625391", "message": "eSIM topup completed successfully", "balanceDebited": false, "balance_debited": false, "transactionCreated": false, "transaction_created": false, "payoutCreated": false, "payout_created": false } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `missing_field` | package_id and iccid are required | | 400 | `invalid_iccid` | iccid must be 18-22 digits (received '123'). | | 402 | `insufficient_balance` | Agency balance is insufficient. Available: 300.0 DZD, Required: 418.6 DZD. | | 403 | `read_write_permission_required` | This endpoint requires an API key with read_write permission. | | 404 | `iccid_not_found` | No eSIM with this ICCID was found for your account. | | 404 | `not_found` | Package no-such-topup not found or has no valid price | | 409 | `duplicate_request` | A duplicate topup was detected for this eSIM. If intentional, please wait 60 seconds before retrying. | | 429 | `rate_limit_exceeded` | Too many requests hit the API too quickly. Please retry your request after the time specified in the `ratelimit-reset` header. | | 429 | `concurrency_limit_exceeded` | Too many requests of this kind are already running for this API key; wait for one to finish and retry. | | 503 | `service_unavailable` | Airalo token unavailable | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Top up an eSIM](https://fx-port.com/docs/esims/topups.md) - [Supplier continuity](https://fx-port.com/docs/esims/supplier-continuity.md) --- # Get the usage of an eSIM > Get the remaining and the total data, voice and SMS of one eSIM of your agency, with its status and expiry. Endpoint: `GET /esims/usage/{iccid}` Source: https://fx-port.com/docs/api/get-esim-usage `GET /api/v1/esims/usage/{iccid}` Requires an API key with **Read** permission; a **Read+Write** key works too. Usage is not real time. The last answer is kept for 15 minutes per eSIM (`cached` and `cache_age_seconds`), and `force=true` asks the supplier again. Use `force` after a topup or when a traveler is waiting, not to poll: the supplier limits how often usage can be read. If the supplier cannot answer, the last known usage is returned with `stale: true` and a `stale_reason`; without a last known value the call fails with the supplier's status, and `retry_after` says how long to wait when the supplier was rate limited. `remaining` and `total` are in megabytes. In the sandbox the usage of a new eSIM is the full allowance and does not change after a topup. ## Request ### Path parameters - `iccid` (string, required): The eSIM to read. It must be an eSIM that your agency ordered. ### Query parameters - `force` (boolean, optional): Skip the 15-minute cache and ask the supplier again. An empty value means not sent. Default `false`. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/usage/8900000540463077957 \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The usage of the eSIM. - `success` (boolean): `true` when usage is returned. - `data` (object): The usage and the supplier's status line. - `data` (object): The usage of one eSIM. - `remaining` (integer): Data left, in megabytes. - `total` (integer): Data allowance of the eSIM, in megabytes. - `expired_at` (string): When the eSIM expires: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone. - `is_unlimited` (boolean): `true` for an unlimited eSIM. - `status` (string): State of the eSIM, as the supplier reports it. One of `ACTIVE`, `EXPIRED`, `NOT_ACTIVE`, `FINISHED`. - `remaining_voice` (integer): Voice minutes left. - `remaining_text` (integer): SMS left. - `total_voice` (integer): Voice minutes of the allowance. - `total_text` (integer): SMS of the allowance. - `meta` (object): The supplier's status line. - `message` (string): `success` when the supplier answered. - `cached` (boolean): `true` when the usage comes from the 15-minute cache or is a last known value. Absent on a fresh answer. - `cache_age_seconds` (integer): Age of the cached usage in seconds. Present with `cached` when the value is within the cache window. - `stale` (boolean): `true` when the supplier could not answer and the last known usage is returned instead. - `stale_reason` (string): Why the supplier could not answer. Present with `stale`. - `retry_after` (integer): Seconds to wait before asking the supplier again. Present with `stale` when the supplier was rate limited. ```json { "environment": "sandbox", "success": true, "data": { "data": { "remaining": 1024, "total": 1024, "expired_at": "2026-11-08 15:23:29", "is_unlimited": false, "status": "ACTIVE", "remaining_voice": 0, "remaining_text": 0, "total_voice": 0, "total_text": 0 }, "meta": { "message": "success" } } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_iccid` | iccid must be 18-22 digits (received '123'). | | 404 | `iccid_not_found` | No eSIM with this ICCID was found for your account. | | 429 | `rate_limit_exceeded` | Rate limited | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [eSIM usage](https://fx-port.com/docs/esims/usage.md) --- # Get the usage of several eSIMs > Get the usage of several eSIMs of your agency in one call. Endpoint: `GET /esims/usage/bulk` Source: https://fx-port.com/docs/api/get-esim-usage-bulk `GET /api/v1/esims/usage/bulk` Requires an API key with **Read** permission; a **Read+Write** key works too. Always send `iccids` (comma separated, at most 50 for an API key). Without it the call reads the 50 newest eSIM orders of your agency in this environment and the supplier is asked one eSIM after another, which can take several seconds. ICCIDs that are not yours are left out of the answer, and each ICCID appears once. Each entry has its own `status`: read it per entry. The same 15-minute cache, `force` and stale values as the single call apply per eSIM. An order whose eSIM the supplier has not returned yet has an ICCID that starts with `PENDING-` and the status `PENDING`. ## Request ### Query parameters - `iccids` (string, optional): ICCIDs separated by commas (semicolons and line breaks work too), at most 50 for an API key. The parameter may also be repeated (`iccids=a&iccids=b`) or written `iccids[]=a&iccids[]=b`, and an ICCID may be written in groups of spaces or hyphens. Without it the 50 newest eSIM orders of your agency are read. - `force` (boolean, optional): Skip the 15-minute cache for every eSIM and ask the supplier again. An empty value means not sent. Default `false`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/esims/usage/bulk?iccids=8900000540463077957' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: One entry per ICCID. - `success` (boolean): `true` when the list was built, even if single entries failed: read `status` per entry. - `data` (array of object): One entry per eSIM of your agency. - `iccid` (string): The ICCID. Each ICCID appears once. - `data` (object, nullable): The usage and the supplier's status line. - `data` (object): The usage of one eSIM. - `remaining` (integer): Data left, in megabytes. - `total` (integer): Data allowance of the eSIM, in megabytes. - `expired_at` (string): When the eSIM expires: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone. - `is_unlimited` (boolean): `true` for an unlimited eSIM. - `status` (string): State of the eSIM, as the supplier reports it. One of `ACTIVE`, `EXPIRED`, `NOT_ACTIVE`, `FINISHED`. - `remaining_voice` (integer): Voice minutes left. - `remaining_text` (integer): SMS left. - `total_voice` (integer): Voice minutes of the allowance. - `total_text` (integer): SMS of the allowance. - `meta` (object): The supplier's status line. - `message` (string): `success` when the supplier answered. - `status` (string): `success` fresh or cached, `stale` last known value, `rate_limited` the supplier refused (see `retry_after`), `error` (see `error`), or `PENDING` when the order has no eSIM yet (`data` is null). One of `success`, `stale`, `rate_limited`, `error`, `PENDING`. - `retry_after` (integer): Seconds to wait before asking again. Present when `status` is `rate_limited`. - `error` (string): A short reason code. Present when `status` is `error` after an unexpected failure. ```json { "environment": "sandbox", "success": true, "data": [ { "iccid": "8900000540463077957", "data": { "data": { "remaining": 1024, "total": 1024, "expired_at": "2026-11-08 15:23:29", "is_unlimited": false, "status": "ACTIVE", "remaining_voice": 0, "remaining_text": 0, "total_voice": 0, "total_text": 0 }, "meta": { "message": "success" } }, "status": "success" } ] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_iccid` | iccids must be 18-22 digits (received '123'). | | 400 | `too_many_iccids` | iccids accepts at most 50 ICCIDs per request. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [eSIM usage](https://fx-port.com/docs/esims/usage.md) --- # List eSIM bookings > List the eSIM orders of your agency in this environment, newest first, with search and pagination. Endpoint: `GET /esims/bookings` Source: https://fx-port.com/docs/api/list-esim-bookings `GET /api/v1/esims/bookings` Requires an API key with **Read** permission; a **Read+Write** key works too. Only orders (`type` `esim`) are listed: topups are returned by `GET /esims/bookings/{iccid}/topup-history`, and both are listed by the account history. A failed attempt is listed with the status `failed`. `search` matches part of the booking id, the traveler's first or last name, the email, the package name or the ICCID (not the supplier order code or the phone). Characters that are not letters, digits or `@.-_+':/&` are treated as spaces, so any text is safe. `pagination.total` counts the bookings that match, `search` included. An API key gets at most 200 bookings per request: `all=true` returns pages of 200 (follow `pagination.has_more` and `page`) and the response carries a `coerced_value` warning. ## Request ### Query parameters - `search` (string, optional): Part of a booking id, name, email, package name or ICCID. At most 100 characters. At most 100 characters. - `page` (integer, optional): The page, starting at 1. An empty value means not sent. Between 1 and 10000. Default `1`. - `limit` (integer, optional): Bookings per page, from 1 to 200. An empty value means not sent. Between 1 and 200. Default `50`. - `all` (boolean, optional): `true` for pages of 200 bookings (the default `limit` becomes 200). An empty value means not sent. Default `false`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/esims/bookings?limit=1' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: A page of eSIM bookings. - `success` (boolean): `true` when the list was returned. - `data` (array of EsimBooking): The eSIM orders of this page, newest first. Topups are not listed here. - `pagination` (object): Where this page sits in the list. - `total` (integer): Number of eSIM bookings of your agency in this environment that match the request (`search` included). - `page` (integer): The page returned. - `limit` (integer): The page size used. With `all=true` for the dashboard it equals `total`. - `pages` (integer, deprecated): Legacy name: use `total_pages`. Number of pages: `total` divided by `limit`, rounded up. `0` when there are no bookings. - `total_pages` (integer): Number of pages: `total` divided by `limit`, rounded up. `0` when there are no bookings. - `has_more` (boolean): `true` when another page follows. ```json { "environment": "sandbox", "success": true, "data": [ { "id": "639417b4-b103-4ece-bd5d-850315322278", "booking_id": "FX-1793546602916", "order_id": "20261101-106852", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "esim", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "environment": "sandbox" } ], "pagination": { "total": 61, "page": 1, "limit": 1, "pages": 61, "total_pages": 61, "has_more": true } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_search` | search must be at most 100 characters. | | 422 | `validation_error` | Input should be less than or equal to 200 | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [eSIM bookings](https://fx-port.com/docs/esims/bookings.md) --- # List the topups of an eSIM > List the topups of one eSIM, newest first. Endpoint: `GET /esims/bookings/{iccid}/topup-history` Source: https://fx-port.com/docs/api/list-esim-topup-history `GET /api/v1/esims/bookings/{iccid}/topup-history` Requires an API key with **Read** permission; a **Read+Write** key works too. Each entry has the shape of a booking with `type` `topup`. The list has no pagination. It is empty when the eSIM has no topup or is not yours. This is the only eSIM endpoint that lists topups; the account history lists them next to the other bookings. ## Request ### Path parameters - `iccid` (string, required): The eSIM whose topups to list. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/bookings/8900000540463077957/topup-history \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The topups of the eSIM. - `success` (boolean): `true` when the list was returned. - `data` (array of EsimBooking): The topups of this ICCID, newest first. Empty when there are none or the ICCID is not yours. ```json { "environment": "sandbox", "success": true, "data": [ { "id": "bd6ff7d7-392f-4d56-8dea-4f0f3dff0af9", "booking_id": "FX-1793546625391", "order_id": "20261101-106853", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "topup", "package_name": "1 GB - 7 days", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:45.424526+00:00", "updated_at": "2026-11-01T15:23:45.424526+00:00", "environment": "sandbox" } ] } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_iccid` | iccid is not a valid ICCID (received '!'). | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [eSIM bookings](https://fx-port.com/docs/esims/bookings.md) --- # Check that an eSIM booking is yours > Tell whether an eSIM booking belongs to your agency, before you build a link or a screen around its booking_id. Endpoint: `GET /esims/bookings/{booking_id}/verify` Source: https://fx-port.com/docs/api/verify-esim-booking `GET /api/v1/esims/bookings/{booking_id}/verify` Requires an API key with **Read** permission; a **Read+Write** key works too. The answer is `isOwner` (twin `is_owner`). A booking of another agency and a booking that does not exist answer the same, `false` with status 200, so the call cannot be used to find out which references exist. Only an owner gets `userAgencyId` and `bookingAgencyId`, both your own agency id. The booking must be an eSIM booking of this environment (a sandbox key never sees a live booking). ## Request ### Path parameters - `booking_id` (string, required): The booking reference to check. At most 100 characters. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/bookings/FX-1793546602916/verify \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: Whether the booking is yours. - `isOwner` (boolean, deprecated): Legacy name: use `is_owner`. `true` when the booking is an eSIM booking of your agency in this environment. `false` when it belongs to another agency or does not exist: the two cases are not told apart. The older name of `is_owner`: same value, use `is_owner`. - `is_owner` (boolean): `true` when the booking is an eSIM booking of your agency in this environment. `false` when it belongs to another agency or does not exist: the two cases are not told apart. The snake_case twin of `isOwner`, with the same value: use this name. - `userAgencyId` (string (uuid)): Your agency id. Present only when `isOwner` is `true`. - `bookingAgencyId` (string (uuid)): The agency id of the booking, which is your own agency id. Present only when `isOwner` is `true`. ```json { "environment": "sandbox", "isOwner": true, "is_owner": true, "userAgencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "bookingAgencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d" } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_booking_id` | booking_id is not a valid booking reference (received '!'). | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [eSIM bookings](https://fx-port.com/docs/esims/bookings.md) --- # Get the setup data of a booking > Get the original order of an eSIM booking: the eSIM with its activation data and the installation steps as HTML, to build your own confirmation or setup page. Endpoint: `GET /esims/bookings/{booking_id}/setup-instructions` Source: https://fx-port.com/docs/api/get-esim-setup-instructions `GET /api/v1/esims/bookings/{booking_id}/setup-instructions` Requires an API key with **Read** permission; a **Read+Write** key works too. Use the `booking_id` of the order (`FX-` followed by a number). It works for orders only: a topup booking has no setup data. The activation data is sensitive: show it only to the traveler who owns the eSIM. This endpoint replaces the older `GET /esims/bookings/{booking_id}/setup-url`, which is deprecated: it answers with the install links only and a `deprecated_endpoint` warning. ## Request ### Path parameters - `booking_id` (string, required): The booking reference returned when the eSIM was ordered. At most 100 characters. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/bookings/FX-1793546602916/setup-instructions \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The setup data of the booking. - `success` (boolean): `true` when the data was returned. - `data` (object): The original order of a booking: the eSIM with its activation data and the installation steps. - `code` (string, deprecated): Legacy name: use `order_id`. The supplier's order code. The older name of `order_id`: same value, use `order_id`. - `order_id` (string): The supplier's order code. The snake_case twin of `code`, with the same value: use this name. - `currency` (string): Currency of `price`: your agency currency. - `package_id` (string): The package that was ordered. - `quantity` (integer): Always `1`. - `type` (string): Always `sim`. - `description` (string, nullable): The `description` of the order. - `esim_type` (string): Kind of eSIM plan, for example `local`. - `validity` (integer): Validity in days. - `package` (string): Name of the package: operator, data allowance and validity. - `data` (string): Data allowance as text. - `price` (number): The price charged to the traveler, in your agency currency. Same as `total_price`. - `created_at` (string): When the supplier created the order: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone. - `manual_installation` (string): HTML with the steps to install the eSIM by entering the activation data by hand. Show it as HTML or strip the tags. - `qrcode_installation` (string): HTML with the steps to install the eSIM by scanning the QR code. - `installation_guides` (object): Links to the supplier's general installation guide, by language code. - `text` (integer, nullable): SMS included, or null. - `voice` (integer, nullable): Voice minutes included, or null. - `sims` (array of object): The eSIMs of the order. One for an eSIM order. - `created_at` (string, nullable): When the supplier created the eSIM: `YYYY-MM-DD HH:MM:SS`, UTC, without a zone. - `iccid` (string): The ICCID of the eSIM. - `lpa` (string, nullable): Host of the SM-DP+ server (the LPA address). - `imsis` (string, nullable): IMSI of the profile, when the supplier returns it. - `qrcode` (string, nullable): The activation string the QR code encodes. Sensitive: show it only to the traveler. - `qrcode_url` (string, nullable): Link to the QR code image. - `apn_type` (string, nullable): `automatic` or `manual`. - `apn_value` (string, nullable): The APN to enter on a manual setup, or `N/A`. - `is_roaming` (boolean, nullable): Whether the eSIM works through roaming. - `confirmation_code` (string, nullable): Confirmation code to enter at installation, when required. - `apn` (object, nullable): APN settings per operating system. - `ios` (object): Access point name (APN) settings for one operating system. - `apn_type` (string): `automatic` when the device configures the access point name by itself, `manual` when the traveler has to enter it. - `apn_value` (string, nullable): The access point name to enter on a manual setup; null or `N/A` when none is needed. - `android` (object): Access point name (APN) settings for one operating system. - `apn_type` (string): `automatic` when the device configures the access point name by itself, `manual` when the traveler has to enter it. - `apn_value` (string, nullable): The access point name to enter on a manual setup; null or `N/A` when none is needed. - `msisdn` (string, nullable): Phone number of the eSIM, for the rare plans that include one. - `direct_apple_installation_url` (string, nullable): Link that starts the installation on an iPhone. - `direct_android_installation_url` (string, nullable): Link that starts the installation on an Android phone that supports it. - `sharing` (object, nullable): A shareable installation page for the eSIM. - `access_code` (number): Code the traveler enters on the sharing page. - `link` (string): Page where the traveler can open the eSIM details and QR code. - `total_price` (number): The price to charge the traveler, in your agency currency. - `retail_price` (number): The price to charge the traveler, in your agency currency. Another name of `total_price`, with the same value. - `agency_currency` (string): Same as `currency`. ```json { "environment": "sandbox", "success": true, "data": { "code": "20261101-106852", "order_id": "20261101-106852", "currency": "DZD", "package_id": "france-in-7days-1gb", "quantity": 1, "type": "sim", "description": "Order for John Smith", "esim_type": "local", "validity": 7, "package": "KPN France Data only-1 GB - 7 days", "data": "1 GB", "price": 468.84, "created_at": "2026-11-01 15:23:22", "manual_installation": "

eSIM name: Élan

Coverage: France

To manually activate the eSIM on your eSIM capable device:

  1. Settings > Cellular/Mobile > Add Cellular/Mobile Plan.
  2. ...
", "qrcode_installation": "

eSIM name: Élan

Coverage: France

To activate the eSIM by scanning the QR code on your eSIM capable device you need to print or display this QR code on another device:

...", "installation_guides": { "en": "https://www.airalo.com/help/getting-started-with-airalo" }, "text": null, "voice": null, "sims": [ { "created_at": "2026-11-01 15:23:22", "iccid": "8900000540463077957", "lpa": "lpa.airalo.com", "imsis": null, "qrcode": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qrcode_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=...", "apn_type": "automatic", "apn_value": "N/A", "is_roaming": true, "confirmation_code": null, "apn": { "ios": { "apn_type": "automatic", "apn_value": "N/A" }, "android": { "apn_type": "automatic", "apn_value": "N/A" } }, "msisdn": null, "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "direct_android_installation_url": "https://esimsetup.android.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "sharing": { "access_code": 8803, "link": "https://esims.cloud/v8hy7-usgkq6wg" } } ], "total_price": 468.84, "retail_price": 468.84, "agency_currency": "DZD" } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_booking_id` | booking_id is not a valid booking reference (received '!'). | | 404 | `booking_not_found` | No eSIM booking was found for this booking_id. | | 404 | `not_found` | Setup data not available for this booking | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Setup and installation instructions](https://fx-port.com/docs/esims/instructions.md) --- # Get eSIM installation instructions > Get step-by-step installation instructions for one eSIM, per operating system, in the language of the request. Endpoint: `GET /esims/instructions/{iccid}` Source: https://fx-port.com/docs/api/get-esim-instructions `GET /api/v1/esims/instructions/{iccid}` Requires an API key with **Read** permission; a **Read+Write** key works too. The steps are generic text of the supplier: they say the activation details are "available on the app". Show the QR code and the activation data of the order next to the steps in your own product. The response also contains the activation data of the eSIM, so show it only to the traveler who owns it. ## Request ### Path parameters - `iccid` (string, required): The eSIM to explain. It must be an eSIM that your agency ordered. ### Query parameters - `locale` (string, optional): Language of the text fields. Without it the `Accept-Language` header is used, then `en`. Any spelling of a locale is read and only its language counts: `fr`, `fr-CA`, `zh-Hans`, `pt_BR`, `fr_FR.UTF-8`, or a whole `Accept-Language` value such as `fr-FR,fr;q=0.9` (the first language is used). Supported: `en`, `fr`, `ar`, `de`, `es`, `it`, `ja`, `ko`, `pl`, `pt`, `ro`, `ru`, `sq`, `tr`, `vi`, `zh`. Another well-formed language is answered in English and the response carries a `coerced_value` warning. At most 80 characters. ### Example request ```bash curl https://api.fx-port.com/api/v1/esims/instructions/8900000540463077957 \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The instructions. - `success` (boolean): `true` when the instructions were returned. - `data` (object): Wrapper object: the steps are in `data.data.instructions`. - `data` (object): Wrapper object. - `instructions` (object): The steps per operating system. - `language` (string): Language of the steps, upper case, for example `EN`. - `ios` (array of object): Steps for iPhones. - `model` (string, nullable): A device model the steps are written for, or null for every model. - `version` (string, nullable): Operating system versions the steps apply to, comma separated, or null for every version. - `direct_apple_installation_url` (string): Link that starts the installation on an iPhone. iOS entries only. - `direct_android_installation_url` (string): Link that starts the installation on an Android phone. Android entries only. - `installation_via_qr_code` (object): Steps to install the eSIM by scanning its QR code. - `steps` (object): The steps as text, keyed by their number: `"1"`, `"2"`, ... - `qr_code_data` (string): The activation string the QR code encodes: `LPA:1$$`. - `qr_code_url` (string): Link to the QR code image. - `installation_manual` (object): Steps to install the eSIM by entering its activation data by hand. - `steps` (object): The steps as text, keyed by their number: `"1"`, `"2"`, ... - `smdp_address_and_activation_code` (string): The SM-DP+ address and the activation code in one string: `LPA:1$$`. - `smdp_address` (string): The SM-DP+ address on its own. iOS entries only. - `activation_code` (string): The activation code on its own. iOS entries only. - `network_setup` (object): Steps to select the network and turn on data roaming, with the APN to enter. - `steps` (object): The steps as text, keyed by their number: `"1"`, `"2"`, ... - `apn_type` (string): `automatic` or `manual`. - `apn_value` (string): The APN to enter, or `N/A`. - `is_roaming` (boolean): Whether data roaming has to be turned on. - `android` (array of object): Steps for Android phones. - `model` (string, nullable): A device model the steps are written for, or null for every model. - `version` (string, nullable): Operating system versions the steps apply to, comma separated, or null for every version. - `direct_apple_installation_url` (string): Link that starts the installation on an iPhone. iOS entries only. - `direct_android_installation_url` (string): Link that starts the installation on an Android phone. Android entries only. - `installation_via_qr_code` (object): Steps to install the eSIM by scanning its QR code. - `steps` (object): The steps as text, keyed by their number: `"1"`, `"2"`, ... - `qr_code_data` (string): The activation string the QR code encodes: `LPA:1$$`. - `qr_code_url` (string): Link to the QR code image. - `installation_manual` (object): Steps to install the eSIM by entering its activation data by hand. - `steps` (object): The steps as text, keyed by their number: `"1"`, `"2"`, ... - `smdp_address_and_activation_code` (string): The SM-DP+ address and the activation code in one string: `LPA:1$$`. - `smdp_address` (string): The SM-DP+ address on its own. iOS entries only. - `activation_code` (string): The activation code on its own. iOS entries only. - `network_setup` (object): Steps to select the network and turn on data roaming, with the APN to enter. - `steps` (object): The steps as text, keyed by their number: `"1"`, `"2"`, ... - `apn_type` (string): `automatic` or `manual`. - `apn_value` (string): The APN to enter, or `N/A`. - `is_roaming` (boolean): Whether data roaming has to be turned on. - `meta` (object): The supplier's status line. - `message` (string): `success` when the supplier answered. ```json { "environment": "sandbox", "success": true, "data": { "data": { "instructions": { "language": "EN", "ios": [ { "model": null, "version": "15.0,14.0,13.0,12.0", "direct_apple_installation_url": "https://esimsetup.apple.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "installation_via_qr_code": { "steps": { "1": "Go to “Settings”, tap “Cellular/Mobile Data”, then tap “Add Cellular/Data Plan” on your device.", "2": "Scan the QR code available on the app, then tap “Add Cellular/Data Plan”.", "3": "Choose a label for your new eSIM plan." }, "qr_code_data": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qr_code_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=..." }, "installation_manual": { "steps": { "1": "Go to “Settings”, tap “Cellular/Mobile Data”, then tap “Add Cellular/Data Plan” on your device.", "2": "Tap “Enter Details Manually” and enter the SM-DP+ Address and Activation Code available on the app by copying them, tap “Next”, then tap “Add Cellular/Data Plan”." }, "smdp_address_and_activation_code": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "smdp_address": "lpa.airalo.com", "activation_code": "DUMMY-2610301358-iKFxr-44741" }, "network_setup": { "steps": { "1": "Go to “Cellular/Mobile Data”, then select the recently downloaded eSIM on your device. Enable the “Turn On This Line” toggle, then select your new eSIM plan for cellular/mobile data.", "2": "Tap “Cellular/Mobile Data Network”, then enter the APN available on the app in all APN fields by copying it." }, "apn_type": "automatic", "apn_value": "N/A", "is_roaming": true } } ], "android": [ { "model": null, "version": null, "direct_android_installation_url": "https://esimsetup.android.com/esim_qrcode_provisioning?carddata=LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "installation_via_qr_code": { "steps": { "1": "Go to “Settings”, tap “Connections”, then tap “SIM card manager” on your device.", "2": "Tap “Add mobile plan”, then tap “Scan carrier QR code”.", "3": "Scan the QR code available on the app, then tap “Confirm”." }, "qr_code_data": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741", "qr_code_url": "https://sandbox.airalo.com/qr?expires=1879860202&id=473564&signature=..." }, "installation_manual": { "steps": { "1": "Go to “Settings”, tap “Connections”, then tap “SIM card manager” on your device.", "2": "Tap “Add mobile plan”, then tap “Scan carrier QR code”.", "3": "Tap “Enter activation code”." }, "smdp_address_and_activation_code": "LPA:1$lpa.airalo.com$DUMMY-2610301358-iKFxr-44741" }, "network_setup": { "steps": { "1": "Go to “SIM card manager”, then turn on your eSIM by enabling the toggle, then tap “OK” on your device.", "2": "Select your eSIM for mobile data." }, "apn_type": "automatic", "apn_value": "N/A", "is_roaming": true } } ] } }, "meta": { "message": "success" } } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_locale` | locale must be a language code such as en, fr or ar (received '12'). | | 404 | `iccid_not_found` | No eSIM with this ICCID was found for your account. | | 503 | `service_unavailable` | Airalo token unavailable | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Setup and installation instructions](https://fx-port.com/docs/esims/instructions.md) --- # List balances > List the balance of your agency in each currency it holds. Endpoint: `GET /balances` Source: https://fx-port.com/docs/api/list-balances `GET /api/v1/balances` Requires an API key with **Read** permission; a **Read+Write** key works too. Keep the `id` of a balance: it is the `balance_id` of the balance history. Never send `agency_id`: the agency comes from the key and an API key that sends it is refused with `agency_id_not_allowed`. The sandbox has no balances: `data` is empty and `meta` says so (`financialOperationsEnabled` is `false`). The live example below shows the shape of a live answer with made-up amounts. ## Request ### Example request ```bash curl https://api.fx-port.com/api/v1/balances \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The balances of your agency, ordered by currency. - `success` (boolean): `true` when the balances were returned. - `data` (array of object): One balance per currency your agency holds, ordered by currency. Always empty in the sandbox. - `id` (string (uuid)): The id of the balance. Send it as `balance_id` to read its history. - `agency_id` (string (uuid)): The id of your agency. - `currency_code` (string, deprecated): Legacy name: use `currency`. The currency of the balance (ISO 4217), for example `DZD` or `EUR`. The older name of `currency`: same value, use `currency`. - `currency` (string): The currency of the balance (ISO 4217), for example `DZD` or `EUR`. The snake_case twin of `currency_code`, with the same value: use this name. - `available_balance` (number): The amount you can spend, in this currency. - `created_at` (string): When the balance was created: ISO 8601, UTC. - `updated_at` (string): When the balance last moved: ISO 8601, UTC. - `meta` (object): Facts about the answer. - `agencyId` (string (uuid), deprecated): Legacy name: use `agency_id`. The id of your agency, resolved from the key. The older name of `agency_id`: same value, use `agency_id`. - `agency_id` (string (uuid)): The id of your agency, resolved from the key. The snake_case twin of `agencyId`, with the same value: use this name. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `financialOperationsEnabled` (boolean, deprecated): Legacy name: use `financial_operations_enabled`. Sandbox only, always `false`: the sandbox has no balance ledger. The older name of `financial_operations_enabled`: same value, use `financial_operations_enabled`. - `financial_operations_enabled` (boolean): Sandbox only, always `false`: the sandbox has no balance ledger. The snake_case twin of `financialOperationsEnabled`, with the same value: use this name. - `message` (string): Sandbox only: why `data` is empty. ```json { "environment": "sandbox", "success": true, "data": [], "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox", "financialOperationsEnabled": false, "financial_operations_enabled": false, "message": "Sandbox bookings do not debit live balances; no sandbox balance ledger is exposed." } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `agency_id_not_allowed` | External API keys resolve agency_id automatically; do not send agency_id as a query parameter. | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [List balances](https://fx-port.com/docs/account/balances.md) --- # Get the history of a balance > List the money movements recorded against one balance: top-ups, booking debits and refunds, newest first. Endpoint: `GET /balances/{balance_id}/history` Source: https://fx-port.com/docs/api/get-balance-history `GET /api/v1/balances/{balance_id}/history` Requires an API key with **Read** permission; a **Read+Write** key works too. Each entry has a `source`. `transactions` are the authoritative balance movements: sum only these for reconciliation. `flight_balance_operations` and `hotel_balance_operations` are audit records of one booking (initial debit, exchange fee, adjustment, refund); a single action produces both a transaction and an operation. eSIM orders and topups have no audit source: their debits are transactions. `type` filters on the `type` of the entry and is exact and case sensitive: `DEBIT` matches transactions (upper case) and `debit` matches operations (lower case). Paging is built from the newest 500 entries of each source, so older entries are not reachable: `pagination.total` counts the matching entries, but follow `has_more` to know whether another page can be served. In the sandbox the answer is always empty. On live a `balance_id` that is not a balance of your agency answers `balance_not_found`. ## Request ### Path parameters - `balance_id` (string (uuid), required): The `id` of a balance returned by `GET /balances`. ### Query parameters - `date_from` (string, optional): Inclusive start. Any ISO 8601 spelling is read: a date (`2026-07-01` or `20260701`, taken as 00:00:00 UTC) or a datetime (`2026-07-01T10:30:00Z`) with 1 to 9 fraction digits and an offset written `Z`, `+01:00` or `+0100`. A datetime without an offset is read as UTC. Years 1900 to 2200. In a URL write a `+` as `%2B`. At most 40 characters. - `date_to` (string, optional): Inclusive end, in the same spellings as `date_from`. A bare date means the end of that day (23:59:59.999999 UTC). It must not be before `date_from`. At most 40 characters. - `from` (string, deprecated, optional): Legacy name: use `date_from`. Previous name of `date_from`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `from_date` (string, deprecated, optional): Legacy name: use `date_from`. Previous name of `date_from`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `to` (string, deprecated, optional): Legacy name: use `date_to`. Previous name of `date_to`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `to_date` (string, deprecated, optional): Legacy name: use `date_to`. Previous name of `date_to`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `type` (string, optional): Keep only entries of this type. Exact and case sensitive: `CREDIT`, `DEBIT` or `REFUND` for transactions; `credit`, `debit` or `refund` for operations. At most 32 characters. - `page` (integer, optional): The page, starting at 1 (at most 10000). An empty value means not sent. Between 1 and 10000. Default `1`. - `limit` (integer, optional): Entries per page, from 1 to 100. An empty value means not sent. Between 1 and 100. Default `20`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/balances/db002f7a-c136-4f32-b19d-38a2542f2af9/history?limit=5' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: A page of movements. - `success` (boolean): `true` when the history was returned. - `data` (array of object): The movements of this page, newest first. Always empty in the sandbox. - `source` (string): Where the entry comes from. `transactions` are the authoritative balance movements; the two `*_balance_operations` sources are audit records per booking. One of `transactions`, `flight_balance_operations`, `hotel_balance_operations`. - `id` (string (uuid)): Id of the entry. - `type` (string): The kind of movement. `transactions` use upper case, `*_balance_operations` lower case. One of `CREDIT`, `DEBIT`, `REFUND`, `credit`, `debit`, `refund`. - `amount` (number): The amount, always positive: `type` tells the direction. - `currency` (string): The currency of the balance. - `description` (string, nullable): A label of the movement, usually with the booking reference. - `balanceId` (string (uuid), deprecated): Legacy name: use `balance_id`. `transactions` only. The balance the movement was recorded against. The older name of `balance_id`: same value, use `balance_id`. - `balance_id` (string (uuid)): `transactions` only. The balance the movement was recorded against. The snake_case twin of `balanceId`, with the same value: use this name. - `bookingRecordId` (string (uuid), deprecated): Legacy name: use `booking_record_id`. `*_balance_operations` only. The internal id of the booking, to match it with the account history. The older name of `booking_record_id`: same value, use `booking_record_id`. - `booking_record_id` (string (uuid)): `*_balance_operations` only. The internal id of the booking, to match it with the account history. The snake_case twin of `bookingRecordId`, with the same value: use this name. - `createdAt` (string, deprecated): Legacy name: use `created_at`. When the movement was recorded: ISO 8601, UTC. The older name of `created_at`: same value, use `created_at`. - `created_at` (string): When the movement was recorded: ISO 8601, UTC. The snake_case twin of `createdAt`, with the same value: use this name. - `pagination` (object): Where this page sits in the list. - `page` (integer): The page returned. - `limit` (integer): The page size used. - `total` (integer): Number of matching entries. Only the newest ones can be paged to (see the endpoint): follow `has_more`, not `total`. - `total_pages` (integer): Number of pages: `total` divided by `limit`, rounded up. Absent when the count is not available. - `hasMore` (boolean, deprecated): Legacy name: use `has_more`. `true` when another page can be served. The older name of `has_more`: same value, use `has_more`. - `has_more` (boolean): `true` when another page can be served. The snake_case twin of `hasMore`, with the same value: use this name. - `meta` (object): Facts about the answer. - `agencyId` (string (uuid), deprecated): Legacy name: use `agency_id`. The id of your agency, resolved from the key. The older name of `agency_id`: same value, use `agency_id`. - `agency_id` (string (uuid)): The id of your agency, resolved from the key. The snake_case twin of `agencyId`, with the same value: use this name. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `balanceId` (string (uuid), deprecated): Legacy name: use `balance_id`. Live only. The balance of the history. The older name of `balance_id`: same value, use `balance_id`. - `balance_id` (string (uuid)): Live only. The balance of the history. The snake_case twin of `balanceId`, with the same value: use this name. - `balanceCurrency` (string, deprecated): Legacy name: use `balance_currency`. Live only. The currency of the balance. The older name of `balance_currency`: same value, use `balance_currency`. - `balance_currency` (string): Live only. The currency of the balance. The snake_case twin of `balanceCurrency`, with the same value: use this name. - `financialOperationsEnabled` (boolean, deprecated): Legacy name: use `financial_operations_enabled`. Sandbox only, always `false`. The older name of `financial_operations_enabled`: same value, use `financial_operations_enabled`. - `financial_operations_enabled` (boolean): Sandbox only, always `false`. The snake_case twin of `financialOperationsEnabled`, with the same value: use this name. - `message` (string): Sandbox only: why `data` is empty. ```json { "environment": "sandbox", "success": true, "data": [], "pagination": { "page": 1, "limit": 5, "total": 0, "total_pages": 0, "hasMore": false, "has_more": false }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox", "financialOperationsEnabled": false, "financial_operations_enabled": false, "message": "Sandbox bookings skip debits, refunds, transactions, payouts, and balance operations." } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_date` | Date parameters must be ISO-8601 values, for example 2026-05-08 or 2026-05-08T10:30:00Z. | | 400 | `invalid_date_range` | date_from must not be after date_to. | | 400 | `agency_id_not_allowed` | External API keys resolve agency_id automatically; do not send agency_id as a query parameter. | | 404 | `balance_not_found` | No balance found for ID 'db002f7a-c136-4f32-b19d-38a2542f2af9'. | | 422 | `validation_error` | Input should be less than or equal to 100 | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Balance history](https://fx-port.com/docs/account/balance-history.md) --- # List booking history > List the bookings of your agency across flights, hotels, eSIMs and transfers, newest first, as short summaries. Endpoint: `GET /history` Source: https://fx-port.com/docs/api/list-booking-history `GET /api/v1/history` Requires an API key with **Read** permission; a **Read+Write** key works too. Use it for status tracking, reconciliation and dashboards, and `GET /bookings/{booking_id}` for one booking in full. The list is limited to the environment of your key. eSIM orders and topups are both listed (`type` tells them apart). `pagination.total` counts the bookings that match. Each product contributes at most its 200 newest bookings to the pages, so at most 800 can be reached with `product=all`: follow `has_more`, and narrow the list with `date_from` and `date_to` to reach older bookings. `status` is an exact match on the status of the booking, in any case; spaces and hyphens are read as underscores (`On Hold` is `on_hold`). `pending` is accepted as another name of `awaiting_payment`, a held flight that is not paid. An unknown status returns an empty list. ## Request ### Query parameters - `product` (string, optional): Keep one product. Case does not matter. The singular names (`flight`, `hotel`, `esim`, `transfer`) are read as the plural. One of `all`, `esims`, `flights`, `hotels`, `transfers`. Default `all`. - `status` (string, optional): Keep bookings with this status, for example `confirmed`, `awaiting_payment` (or `pending`), `cancelled`, `failed`, `refunded`. Case does not matter; spaces and hyphens are read as underscores (`On Hold` is `on_hold`). At most 32 characters. At most 32 characters. - `date_from` (string, optional): Inclusive start. Any ISO 8601 spelling is read: a date (`2026-07-01` or `20260701`, taken as 00:00:00 UTC) or a datetime (`2026-07-01T10:30:00Z`) with 1 to 9 fraction digits and an offset written `Z`, `+01:00` or `+0100`. A datetime without an offset is read as UTC. Years 1900 to 2200. In a URL write a `+` as `%2B`. At most 40 characters. - `date_to` (string, optional): Inclusive end, in the same spellings as `date_from`. A bare date means the end of that day (23:59:59.999999 UTC). It must not be before `date_from`. At most 40 characters. - `from` (string, deprecated, optional): Legacy name: use `date_from`. Previous name of `date_from`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `from_date` (string, deprecated, optional): Legacy name: use `date_from`. Previous name of `date_from`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `to` (string, deprecated, optional): Legacy name: use `date_to`. Previous name of `date_to`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `to_date` (string, deprecated, optional): Legacy name: use `date_to`. Previous name of `date_to`. It still works, with the same spellings, and the response carries a `deprecated_field` warning. At most 40 characters. - `page` (integer, optional): The page, starting at 1 (at most 10000). An empty value means not sent. Between 1 and 10000. Default `1`. - `limit` (integer, optional): Entries per page, from 1 to 100. An empty value means not sent. Between 1 and 100. Default `20`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/history?product=esims&limit=2' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: A page of booking summaries. - `success` (boolean): `true` when the history was returned. - `data` (array of object): The bookings of this page, newest first. eSIM topups are listed next to eSIM orders. - `product` (string): The product of the booking. One of `flights`, `hotels`, `esims`, `transfers`. - `id` (string (uuid)): Internal id of the booking (UUID). Stable and unique: store it as your key. - `bookingId` (string, deprecated): Legacy name: use `booking_id`. The public reference of the booking: the airline record locator of a flight (not unique across suppliers), the hotel booking id, `FX-` followed by a number for an eSIM, or the transfer reference. The older name of `booking_id`: same value, use `booking_id`. - `booking_id` (string): The public reference of the booking: the airline record locator of a flight (not unique across suppliers), the hotel booking id, `FX-` followed by a number for an eSIM, or the transfer reference. The snake_case twin of `bookingId`, with the same value: use this name. - `status` (string): State of the booking. The values depend on the product: for example `confirmed`, `awaiting_payment` (a held flight that is not paid), `cancelled`, `failed`, `refunded`, `partial_refund`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `agencyId` (string (uuid), deprecated): Legacy name: use `agency_id`. The id of your agency. The older name of `agency_id`: same value, use `agency_id`. - `agency_id` (string (uuid)): The id of your agency. The snake_case twin of `agencyId`, with the same value: use this name. - `agentId` (string, nullable, deprecated): Legacy name: use `agent_id`. The user of your agency who created the booking, or null when it was created through an API key without a user. The older name of `agent_id`: same value, use `agent_id`. - `agent_id` (string, nullable): The user of your agency who created the booking, or null when it was created through an API key without a user. The snake_case twin of `agentId`, with the same value: use this name. - `currency` (string): Currency of the amounts: the settlement currency of your agency. - `totalPrice` (number, deprecated): Legacy name: use `total_price`. What the traveler pays, in your agency currency. It includes your commission. The older name of `total_price`: same value, use `total_price`. - `total_price` (number): What the traveler pays, in your agency currency. It includes your commission. The snake_case twin of `totalPrice`, with the same value: use this name. - `netPrice` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance: `totalPrice` minus `agencyCommission`. The older name of `b2b_price`: same value, use `b2b_price`. - `b2b_price` (number): The amount debited from your agency balance: `totalPrice` minus `agencyCommission`. The snake_case twin of `netPrice`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. Your commission as an amount, in your agency currency. `0` when there is none. The older name of `agency_commission`: same value, use `agency_commission`. - `agency_commission` (number): Your commission as an amount, in your agency currency. `0` when there is none. The snake_case twin of `agencyCommission`, with the same value: use this name. - `providerName` (string, nullable, deprecated): Legacy name: use `provider_name`. Name of the supplier that fulfilled the booking. The older name of `provider_name`: same value, use `provider_name`. - `provider_name` (string, nullable): Name of the supplier that fulfilled the booking. The snake_case twin of `providerName`, with the same value: use this name. - `supplierId` (string, nullable, deprecated): Legacy name: use `supplier_id`. Id of the supplier that fulfilled the booking, as stored on the booking. It can differ from the id the supplier list uses: `ratehawk` and `airalo` here are `worldota_1` and `airalo_1` there. The older name of `supplier_id`: same value, use `supplier_id`. - `supplier_id` (string, nullable): Id of the supplier that fulfilled the booking, as stored on the booking. It can differ from the id the supplier list uses: `ratehawk` and `airalo` here are `worldota_1` and `airalo_1` there. The snake_case twin of `supplierId`, with the same value: use this name. - `createdAt` (string, deprecated): Legacy name: use `created_at`. When the booking was created: ISO 8601, UTC. The older name of `created_at`: same value, use `created_at`. - `created_at` (string): When the booking was created: ISO 8601, UTC. The snake_case twin of `createdAt`, with the same value: use this name. - `updatedAt` (string, deprecated): Legacy name: use `updated_at`. When the booking last changed: ISO 8601, UTC. The older name of `updated_at`: same value, use `updated_at`. - `updated_at` (string): When the booking last changed: ISO 8601, UTC. The snake_case twin of `updatedAt`, with the same value: use this name. - `departureAirport` (string): Flights only. IATA code of the first departure airport. - `arrivalAirport` (string): Flights only. IATA code of the final arrival airport. - `departureDate` (string): Flights only. Local departure date and time of the first segment, without a zone. - `arrivalDate` (string): Flights and transfers. Local arrival date and time, without a zone for flights. - `ticketNumber` (string, nullable): Flights only. The ticket number once issued, or null. - `requireAcd` (boolean, nullable, deprecated): Legacy name: use `require_acd`. Flights only. Whether the booking needs ACD documents. The older name of `require_acd`: same value, use `require_acd`. - `require_acd` (boolean, nullable): Flights only. Whether the booking needs ACD documents. The snake_case twin of `requireAcd`, with the same value: use this name. - `voucherUrl` (string, nullable): Flights and hotels: the confirmation PDF with prices (same as `voucherUrlWithPrice`), null while it is generated. Transfers: the supplier voucher, null until a driver is assigned. - `voucherUrlWithPrice` (string, nullable): Flights and hotels. The confirmation PDF with prices. - `voucherUrlNoPrice` (string, nullable): Flights and hotels. The confirmation PDF without prices, to give to the traveler. - `hotelId` (string): Hotels only. The id of the hotel. - `hotelName` (string, nullable): Hotels only. The name of the hotel, or null when the supplier did not give one (a failed booking, for example). - `checkinDate` (string): Hotels only. Check-in date: `YYYY-MM-DD`. - `checkoutDate` (string): Hotels only. Check-out date: `YYYY-MM-DD`. - `bookingMode` (string, nullable): Hotels only. How the booking was made, for example `standard`. - `orderId` (string, deprecated): Legacy name: use `order_id`. eSIMs only. The supplier's order code. The older name of `order_id`: same value, use `order_id`. - `order_id` (string): eSIMs only. The supplier's order code. The snake_case twin of `orderId`, with the same value: use this name. - `type` (string): eSIMs only. `esim` for an order, `topup` for a topup. One of `esim`, `topup`. - `packageName` (string, deprecated): Legacy name: use `package_name`. eSIMs only. Name of the package. The older name of `package_name`: same value, use `package_name`. - `package_name` (string): eSIMs only. Name of the package. The snake_case twin of `packageName`, with the same value: use this name. - `iccid` (string): eSIMs only. The ICCID of the eSIM. - `customerEmail` (string, deprecated): Legacy name: use `customer_email`. eSIMs only. The traveler's email. The older name of `customer_email`: same value, use `customer_email`. - `customer_email` (string): eSIMs only. The traveler's email. The snake_case twin of `customerEmail`, with the same value: use this name. - `customer_first_name` (string): eSIMs only. The traveler's first name. - `customer_last_name` (string): eSIMs only. The traveler's last name. - `customer_phone` (string): eSIMs only. The traveler's phone number. - `flightNumber` (string, nullable): Transfers only. The flight number the transfer was booked for. - `receiptUrl` (string, nullable): Transfers only. FX-Port's receipt PDF, once the booking is confirmed. - `pagination` (object): Where this page sits in the list. - `page` (integer): The page returned. - `limit` (integer): The page size used. - `total` (integer): Number of matching entries. Only the newest ones can be paged to (see the endpoint): follow `has_more`, not `total`. - `total_pages` (integer): Number of pages: `total` divided by `limit`, rounded up. Absent when the count is not available. - `hasMore` (boolean, deprecated): Legacy name: use `has_more`. `true` when another page can be served. The older name of `has_more`: same value, use `has_more`. - `has_more` (boolean): `true` when another page can be served. The snake_case twin of `hasMore`, with the same value: use this name. - `meta` (object): Facts about the answer. - `agencyId` (string (uuid), deprecated): Legacy name: use `agency_id`. The id of your agency, resolved from the key. The older name of `agency_id`: same value, use `agency_id`. - `agency_id` (string (uuid)): The id of your agency, resolved from the key. The snake_case twin of `agencyId`, with the same value: use this name. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `product` (string): The `product` filter that was applied, `all` by default. ```json { "environment": "sandbox", "success": true, "data": [ { "product": "esims", "id": "bd6ff7d7-392f-4d56-8dea-4f0f3dff0af9", "bookingId": "FX-1793546625391", "booking_id": "FX-1793546625391", "status": "confirmed", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "agent_id": null, "currency": "DZD", "totalPrice": 468.84, "total_price": 468.84, "netPrice": 418.6, "b2b_price": 418.6, "agencyCommission": 50.24, "agency_commission": 50.24, "providerName": "Airalo", "provider_name": "Airalo", "supplierId": "airalo", "supplier_id": "airalo", "createdAt": "2026-11-01T15:23:45.424526+00:00", "created_at": "2026-11-01T15:23:45.424526+00:00", "updatedAt": "2026-11-01T15:23:45.424526+00:00", "updated_at": "2026-11-01T15:23:45.424526+00:00", "orderId": "20261101-106853", "order_id": "20261101-106853", "type": "topup", "packageName": "1 GB - 7 days", "package_name": "1 GB - 7 days", "iccid": "8900000540463077957", "customerEmail": "traveler@example.com", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123" }, { "product": "esims", "id": "639417b4-b103-4ece-bd5d-850315322278", "bookingId": "FX-1793546602916", "booking_id": "FX-1793546602916", "status": "confirmed", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "agent_id": null, "currency": "DZD", "totalPrice": 468.84, "total_price": 468.84, "netPrice": 418.6, "b2b_price": 418.6, "agencyCommission": 50.24, "agency_commission": 50.24, "providerName": "Airalo", "provider_name": "Airalo", "supplierId": "airalo", "supplier_id": "airalo", "createdAt": "2026-11-01T15:23:22.961228+00:00", "created_at": "2026-11-01T15:23:22.961228+00:00", "updatedAt": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "orderId": "20261101-106852", "order_id": "20261101-106852", "type": "esim", "packageName": "1 GB - 7 days • France", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customerEmail": "traveler@example.com", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123" } ], "pagination": { "page": 1, "limit": 2, "total": 80, "total_pages": 40, "hasMore": true, "has_more": true }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox", "product": "esims" } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_product` | product must be one of all, esims, flights, hotels, transfers. | | 400 | `invalid_date` | Date parameters must be ISO-8601 values, for example 2026-05-08 or 2026-05-08T10:30:00Z. | | 400 | `invalid_date_range` | date_from must not be after date_to. | | 400 | `agency_id_not_allowed` | External API keys resolve agency_id automatically; do not send agency_id as a query parameter. | | 422 | `validation_error` | Input should be less than or equal to 100 | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Booking history](https://fx-port.com/docs/account/history.md) --- # Get a booking > Get one booking of your agency, whatever its product: the summary of the history, the stored record and, for flights and hotels, the price breakdown and the balance operations. Endpoint: `GET /bookings/{booking_id}` Source: https://fx-port.com/docs/api/get-account-booking `GET /api/v1/bookings/{booking_id}` Requires an API key with **Read** permission; a **Read+Write** key works too. The reference is looked up in these fields, in this order, and the first booking found is returned. Flights: `booking_id` (the record locator), the supplier order id, `id`. Hotels: `booking_id`, `id`. eSIMs: `booking_id` (`FX-...`), the supplier order code, the ICCID, `id`. Transfers: `booking_id`, `id`. Without `product` the products are tried in the order flights, hotels, eSIMs, transfers. Prefer the internal `id` (a UUID): record locators are not unique across suppliers. An ICCID matches the original order of the eSIM, not its topups: look a topup up by its `booking_id` or its `id`. A reference that does not match the `product` you ask for is `booking_not_found`. ## Request ### Path parameters - `booking_id` (string, required): The booking reference: the internal `id` or the public reference of the product (see above). At most 100 characters. ### Query parameters - `product` (string, optional): Look in one product only, which is faster and avoids a match in another product. Case does not matter. The singular names (`flight`, `hotel`, `esim`, `transfer`) are read as the plural. One of `all`, `esims`, `flights`, `hotels`, `transfers`. Default `all`. ### Example request ```bash curl 'https://api.fx-port.com/api/v1/bookings/FX-1793546602916?product=esims' \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The booking. The keys after `updatedAt` and in `record` depend on the product. - `success` (boolean): `true` when the booking was found. - `data` (AccountBookingDetail): One booking with its stored record. The summary keys are the ones of the history; `record` repeats the booking in snake_case. - `meta` (object): Facts about the answer. - `agencyId` (string (uuid), deprecated): Legacy name: use `agency_id`. The id of your agency, resolved from the key. The older name of `agency_id`: same value, use `agency_id`. - `agency_id` (string (uuid)): The id of your agency, resolved from the key. The snake_case twin of `agencyId`, with the same value: use this name. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. ```json { "environment": "sandbox", "success": true, "data": { "product": "esims", "id": "639417b4-b103-4ece-bd5d-850315322278", "bookingId": "FX-1793546602916", "booking_id": "FX-1793546602916", "status": "confirmed", "environment": "sandbox", "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agentId": null, "agent_id": null, "currency": "DZD", "totalPrice": 468.84, "total_price": 468.84, "netPrice": 418.6, "b2b_price": 418.6, "agencyCommission": 50.24, "agency_commission": 50.24, "providerName": "Airalo", "provider_name": "Airalo", "supplierId": "airalo", "supplier_id": "airalo", "createdAt": "2026-11-01T15:23:22.961228+00:00", "created_at": "2026-11-01T15:23:22.961228+00:00", "updatedAt": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "orderId": "20261101-106852", "order_id": "20261101-106852", "type": "esim", "packageName": "1 GB - 7 days • France", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customerEmail": "traveler@example.com", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "record": { "id": "639417b4-b103-4ece-bd5d-850315322278", "booking_id": "FX-1793546602916", "order_id": "20261101-106852", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "esim", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "environment": "sandbox" } }, "meta": { "agencyId": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "agency_id": "0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d", "environment": "sandbox" } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | | 400 | `invalid_product` | product must be one of all, esims, flights, hotels, transfers. | | 400 | `invalid_booking_id` | booking_id is not a valid booking reference (received '!'). | | 400 | `agency_id_not_allowed` | External API keys resolve agency_id automatically; do not send agency_id as a query parameter. | | 404 | `booking_not_found` | No sandbox booking found for 'FX-0000000000000'. | | 503 | `service_overloaded` | The service is temporarily overloaded; retry after the time given in the Retry-After header. | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Booking detail](https://fx-port.com/docs/account/booking-detail.md) --- # List suppliers > List the suppliers that are enabled for your key, grouped by product: flights, hotels and eSIMs. Endpoint: `GET /suppliers` Source: https://fx-port.com/docs/api/list-suppliers `GET /api/v1/suppliers` Requires an API key with **Read** permission; a **Read+Write** key works too. A product with no enabled supplier is left out, and so are disabled suppliers. The list can change at any time: read it, do not hard-code it. Flight suppliers carry `routes`, `capabilities` and `void_deadline`; hotel and eSIM suppliers only `id`, `name`, `technology` and `description`. `void_deadline` is the next midnight in the supplier's time zone, in UTC, recalculated on every call: do not keep it across days. The supplier `id` is the one to send as `supplier_id` where an endpoint takes one. The bookings of your account carry their own `supplierId`, which can differ (`ratehawk` for `worldota_1`, `airalo` for `airalo_1`). ## Request ### Example request ```bash curl https://api.fx-port.com/api/v1/suppliers \ -H "Authorization: Bearer fxp_test_YOUR_KEY" ``` ## Response ### 200: The enabled suppliers, by product. - `success` (boolean): `true` when the list was returned. - `products` (array of object): One entry per product that has at least one enabled supplier. - `product` (string): The product. One of `flights`, `hotels`, `esims`. - `suppliers` (array of object): The enabled suppliers of the product. - `id` (string): The id of the supplier. Send it as `supplier_id` where an endpoint takes one. - `name` (string): The name of the supplier. - `technology` (string): How FX-Port connects to the supplier: for example `amadeus_aqc`, `duffel`, `worldota` or `airalo`. New values appear when connections are added. - `routes` (array of string): Flights only. The markets the supplier serves. - `description` (string): What the supplier is good for. Free text: it can change. - `capabilities` (object): What a flight supplier supports. - `hold` (boolean): Flights. The supplier can hold a booking to pay later. - `void` (boolean): Flights. The supplier can void an issued ticket on the day of issue. - `flexible_dates` (boolean): Flights. The supplier supports the flexible-dates search. - `ancillaries` (boolean): Flights. The supplier sells extras such as seats and bags. - `voidDeadline` (string, deprecated): Legacy name: use `void_deadline`. Flights only, for suppliers that can void. The next cut-off for a void, in UTC: the next midnight in the supplier's time zone. It is recalculated on every request. The older name of `void_deadline`: same value, use `void_deadline`. - `void_deadline` (string): Flights only, for suppliers that can void. The next cut-off for a void, in UTC: the next midnight in the supplier's time zone. It is recalculated on every request. The snake_case twin of `voidDeadline`, with the same value: use this name. - `meta` (object): Facts about the answer. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `permissionRequired` (string, deprecated): Legacy name: use `permission_required`. `read`: the permission the endpoint needs. The older name of `permission_required`: same value, use `permission_required`. - `permission_required` (string): `read`: the permission the endpoint needs. The snake_case twin of `permissionRequired`, with the same value: use this name. ```json { "environment": "sandbox", "success": true, "products": [ { "product": "flights", "suppliers": [ { "id": "amadeus_aqc_dz_1", "name": "Amadeus Algeria", "technology": "amadeus_aqc", "routes": [ "DZ" ], "description": "Operates on routes from and to Algeria using a dedicated Algiers GDS office with negotiated fares. Benefits from an advantageous DZD exchange rate that typically results in significantly lower prices on outbound Algeria routes ...", "capabilities": { "hold": true, "void": true, "flexible_dates": true, "ancillaries": false }, "voidDeadline": "2026-11-01T23:00:00Z", "void_deadline": "2026-11-01T23:00:00Z" }, { "id": "duffel_1", "name": "Duffel", "technology": "duffel", "routes": [ "*" ], "description": "TravelPort-connected supplier with broad global route coverage across hundreds of airlines at published fares. Particularly competitive on European, Middle Eastern, and long-haul routes ...", "capabilities": { "hold": false, "void": false, "flexible_dates": false, "ancillaries": true }, "voidDeadline": "2026-11-02T00:00:00Z", "void_deadline": "2026-11-02T00:00:00Z" } ] }, { "product": "hotels", "suppliers": [ { "id": "worldota_1", "name": "RateHawk", "technology": "worldota", "description": "Aggregates hotel inventory from global hotel chains and independent properties across leisure and business destinations worldwide. Broad coverage across leisure and business destinations with real-time availability and net rates." } ] }, { "product": "esims", "suppliers": [ { "id": "airalo_1", "name": "Airalo", "technology": "airalo", "description": "Global eSIM data provider with coverage across 190+ countries and regions. Data plans are activated instantly and the eSIM QR code is delivered directly to the customer's email address after purchase." } ] } ], "meta": { "environment": "sandbox", "permissionRequired": "read", "permission_required": "read" } } ``` ## Errors | Status | Code | Example message | | --- | --- | --- | See [Errors](https://fx-port.com/docs/errors.md) for every code and how to react. ## Related guides - [Suppliers](https://fx-port.com/docs/suppliers.md) --- # The Error object > The body of every failed call. Source: https://fx-port.com/docs/api/objects/error Parse `errors[]`: each entry has a stable `code`, a `type` that tells you how to react, a human `message` and, when there is one, the `field` at fault. The older keys (`detail`, `error`, `message`, `code`) stay in the body for integrations written before `errors[]` existed. See the Errors guide. ## Attributes - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `success` (boolean): Always `false` for an error. - `errors` (array of object): The errors, most important first. Parse this array; the other keys are the older shapes of the same error and stay for existing integrations. - `code` (string): Stable, machine-readable code in snake_case. Branch on this, never on the message. - `type` (string): The family of the error: tells you how to react. One of `authentication_error`, `permission_error`, `validation_error`, `not_found`, `conflict`, `payment_error`, `rate_limit_error`, `supplier_error`, `internal_error`. - `title` (string): A short label of the error. - `message` (string): A sentence for a human: what is wrong and how to fix it. Wording can change; the code does not. - `field` (string, nullable): The request field the error is about, when there is one. - `details` (object, nullable): Structured extras of the error (for example `available` and `required` for an insufficient balance). - `documentation_url` (string): The documentation of this error code. - `meta` (object): The id of the request and the HTTP status, repeated for clients that only read the body. - `request_id` (string): The id of the request. - `status` (integer): The HTTP status code. - `request_id` (string): The id of the request, also in the `X-Request-Id` header. Quote it when you write to support. - `detail` (object, deprecated): Legacy name: use `errors`. The older error body (a string, an object with `error` and `message`, or a list of validation errors). - `error` (object, deprecated): Legacy name: use `errors[].code`. The older error code or text. - `message` (string, deprecated): Legacy name: use `errors[].message`. The older error message. - `code` (string, deprecated): Legacy name: use `errors[].code`. The older error code. --- # The Flight offer object > One bookable flight offer of one supplier: the route, the price, the fare rules and the price of every traveler. Source: https://fx-port.com/docs/api/objects/flight-offer `get_flights` returns offers under `suppliers[n].results.offers[]` (and in the `supplier_result` and `offers_chunk` events of the stream); `price_flight` returns the priced version under `results.offers[]`. Keep the `id` of the offer together with the `searchId` of the supplier entry it is in: `price_flight` and `book_flight` need the pair, and an offer can only be priced with the `searchId` of its own supplier entry. An offer is a snapshot that expires: a supplier withdraws it after a short while (a Duffel offer lives 15 to 60 minutes), and `price_flight` is the call that confirms it and makes it bookable. Every amount is in your agency currency; the fields of the price block are explained in Flight pricing. See Search flights. ## Attributes - `id` (string, deprecated): Legacy name: use `offer_id`. The id of the offer: `fx_offer_` and 8 hex characters. Send it as `offer_id` to `price_flight` and `book_flight`. - `offer_id` (string): The id of the offer: `fx_offer_` and 8 hex characters. Send it as `offer_id` to `price_flight` and `book_flight`. The snake_case twin of `id`, with the same value: use this name. - `type` (string): Always `flight_offer`. Always `flight_offer`. - `supplier` (object): The supplier the offer comes from. - `id` (string, deprecated): Legacy name: use `supplier_id`. Id of the supplier: `amadeus_aqc_dz_1` or `duffel_1` in the sandbox. - `supplier_id` (string): Id of the supplier: `amadeus_aqc_dz_1` or `duffel_1` in the sandbox. The snake_case twin of `id`, with the same value: use this name. - `name` (string, deprecated): Legacy name: use `supplier_name`. Name of the supplier. - `supplier_name` (string): Name of the supplier. The snake_case twin of `name`, with the same value: use this name. - `originalId` (string, deprecated): Legacy name: use `supplier_offer_id`. The supplier's own id of the offer. For support only. - `supplier_offer_id` (string): The supplier's own id of the offer. For support only. The snake_case twin of `originalId`, with the same value: use this name. - `source` (string): Priced offer only: where the offer comes from, `duffel` or `GDS`. - `bookableSeats` (integer, nullable): Seats left at this fare, when the supplier says (Amadeus: up to 9). - `supportHold` (boolean, deprecated): Legacy name: use `support_hold`. `true` when the offer can be held (booked without payment) with `booking_type: "hold"`. Read it again on the priced offer. - `support_hold` (boolean): `true` when the offer can be held (booked without payment) with `booking_type: "hold"`. Read it again on the priced offer. The snake_case twin of `supportHold`, with the same value: use this name. - `supportVoid` (boolean, deprecated): Legacy name: use `support_void`. `true` when the ticket can be voided during the same day. - `support_void` (boolean): `true` when the ticket can be voided during the same day. The snake_case twin of `supportVoid`, with the same value: use this name. - `voidDeadline` (string, nullable, deprecated): Legacy name: use `void_deadline`. ISO 8601 UTC time when the same-day void window ends, `null` when the supplier cannot void. - `void_deadline` (string, nullable): ISO 8601 UTC time when the same-day void window ends, `null` when the supplier cannot void. The snake_case twin of `voidDeadline`, with the same value: use this name. - `filtersApplied` (object): The filters you asked for, echoed. It says what you asked for, not what the supplier honoured. - `warnings` (array of object): Notes of the supplier about this offer. Empty in every sample. - `instantTicketingRequired` (boolean): `true`: the fare cannot be held; book it with `booking_type: "issue"`. - `lastTicketingDate` (string): Duffel only: the day the offer stops being sellable. - `lastTicketingDateTime` (string): Duffel only: the moment the offer stops being sellable. Typically 15 to 60 minutes after the search. - `pricing` (object): The agency-facing price of an offer, a booking or a quote. Every amount is in `agencyCurrency`; the rate, when the currencies differ, is `exchangeRate` at the top level of the response. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. Every amount of the block is in it. - `basePrice` (number, deprecated): Legacy name: use `base_price`. The airline's base fare, before taxes, in the agency currency. - `base_price` (number): The airline's base fare, before taxes, in the agency currency. The snake_case twin of `basePrice`, with the same value: use this name. - `taxesAndFees` (number, deprecated): Legacy name: use `taxes_and_fees`. Taxes plus the platform's fees, folded together. Excludes your agency commission. - `taxes_and_fees` (number): Taxes plus the platform's fees, folded together. Excludes your agency commission. The snake_case twin of `taxesAndFees`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. The commission of your agency on this sale. - `agency_commission` (number): The commission of your agency on this sale. The snake_case twin of `agencyCommission`, with the same value: use this name. - `finalPrice` (number, deprecated): Legacy name: use `final_price`. What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. - `final_price` (number): What the traveler pays: `basePrice + taxesAndFees + agencyCommission`. The snake_case twin of `finalPrice`, with the same value: use this name. - `b2bPrice` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance: `finalPrice - agencyCommission`. - `b2b_price` (number): The amount debited from your agency balance: `finalPrice - agencyCommission`. The snake_case twin of `b2bPrice`, with the same value: use this name. - `totalAmount` (number): Order quote only: an alias of `b2bPrice`. - `taxAmount` (number): Order quote of a Duffel order only: the airline's taxes in your agency currency. - `originalTaxAmount` (number): Order quote of a Duffel order only: the same taxes in the supplier currency. - `additionalServices` (array of object): Paid extras. A list in the offers of a search (empty for most offers). - `acd` (object): Whether the fare is an ACD fare. Present on the offers of Amadeus Algeria, absent on Duffel offers. An ACD fare carries more keys with the amounts of the adjustment: they are informational, can change without notice and are not part of the contract. - `applied` (boolean): `true` for an ACD fare: the booking needs one passport copy for every traveler, sent with `upload_acd_documents`. `false` for an ordinary fare. - `scenario` (string): Only on an ACD fare: the rule that adjusted the price. Informational. One of `A`, `B`. - `exempt` (boolean): `true` when the search named an ACD-exempt company of your agency: the fare is priced without ACD. - `reason` (string): Only on some ACD fares: why the rule applied. Informational; the wording can change. - `itinerary` (array of object): One entry for each direction. - `direction` (string): `outbound`, `return`, and `segment_3`, `segment_4` ... for the third and later legs of a multi-city search. - `duration` (string): ISO 8601 duration of this direction. - `stops` (integer): Number of stops. `0` is non-stop. - `segments` (array of object): The flights of this direction, in order. - `id` (string): Id of the segment. `fareDetailsBySegment[].segmentId` points to it. - `departure` (object): Where and when a segment leaves or arrives. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `terminal` (string, nullable): Terminal, when the supplier says. - `datetime` (string): Local date and time at the airport, `YYYY-MM-DDTHH:MM:SS`, without a time zone. - `timeZone` (string): IANA time zone of the airport. - `arrival` (object): Where and when a segment leaves or arrives. - `airportCode` (string): IATA code of the airport. - `airportName` (string): Name of the airport. - `cityName` (string): Name of the city. - `countryCode` (string): ISO 3166-1 alpha-2 country code. - `terminal` (string, nullable): Terminal, when the supplier says. - `datetime` (string): Local date and time at the airport, `YYYY-MM-DDTHH:MM:SS`, without a time zone. - `timeZone` (string): IANA time zone of the airport. - `flight` (object): The airline and the number of a flight. - `carrierCode` (string): IATA code of the marketing airline. - `carrierName` (string): Name of the marketing airline. - `carrierLogoUrl` (string): Address of the logo of the marketing airline. - `flightNumber` (string): The flight number without the carrier code. - `aircraftCode` (string, nullable): IATA code of the aircraft type. - `aircraftName` (string, nullable): Name of the aircraft type. - `operatingCarrier` (string): IATA code of the airline that operates the flight. A different value than `carrierCode` means a codeshare. - `operatingCarrierName` (string): Name of the operating airline. - `duration` (string): ISO 8601 duration of the flight. - `cabin` (string): The cabin of this segment. It can differ from the cabin you asked for. One of `ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS`, `FIRST`. - `bookingClass` (string, nullable): Booking class letter (Amadeus: `Q`, `O`; Duffel: null). - `fareBasis` (string, nullable): Fare basis code. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `conditions` (object): What the fare allows before departure. Indicative only. A Duffel order can carry `null` for both when the airline does not say. - `refundBeforeDeparture` (object, nullable): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `changeBeforeDeparture` (object, nullable): What the fare says about a change before departure. - `allowed` (boolean): Whether a change is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `travelerPricing` (array of object): One entry for each priced traveler. - `travelerId` (string, deprecated): Legacy name: use `passenger_id`. `"1"`, `"2"` ... for Amadeus, `pas_...` for Duffel. - `passenger_id` (string): `"1"`, `"2"` ... for Amadeus, `pas_...` for Duffel. The snake_case twin of `travelerId`, with the same value: use this name. - `travelerType` (string, deprecated): Legacy name: use `passenger_type`. `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). - `passenger_type` (string): `ADULT`, `SENIOR`, `YOUNG`, `CHILD`, `SEATED_INFANT` or `HELD_INFANT` (a lap infant). The snake_case twin of `travelerType`, with the same value: use this name. - `fareOption` (string): The fare option, for example `STANDARD`. - `price` (object): The price of one traveler. - `supplierCurrency` (string): ISO 4217 currency in which the supplier prices the fare. - `agencyCurrency` (string): ISO 4217 currency of your agency. - `base` (number): The traveler's base fare in the agency currency. - `total` (number): The traveler's final price: what this traveler pays. The totals of all travelers add up to `pricing.finalPrice`. - `b2bPrice` (number): The traveler's share of the amount debited from your balance. - `fareDetailsBySegment` (array of object): The fare of this traveler on every segment. - `segmentId` (string): The `id` of the segment this fare detail is about. - `cabin` (string): Cabin of the fare on this segment. One of `ECONOMY`, `PREMIUM_ECONOMY`, `BUSINESS`, `FIRST`. - `fareBasis` (string, nullable): Fare basis code. - `brandedFare` (string, nullable): Fare family code. - `brandedFareLabel` (string, nullable): Display label of the fare family. - `bookingClass` (string, nullable): Booking class letter. - `includedCheckedBags` (object): The checked baggage included in the fare. - `quantity` (integer): Number of checked bags included in the fare. - `includedCabinBags` (object): The cabin baggage included in the fare. - `quantity` (integer): Number of cabin bags included in the fare. - `amenities` (array of object): What else the fare includes or sells (seat, bag, meal, Wi-Fi ...). - `conditions` (object): What the fare allows before departure. Indicative only. A Duffel order can carry `null` for both when the airline does not say. - `refundBeforeDeparture` (object, nullable): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `changeBeforeDeparture` (object, nullable): What the fare says about a change before departure. - `allowed` (boolean): Whether a change is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `fareRules` (object): The fare rules of the offer: what exchange, refund and revalidation allow. - `exchange` (object): What the fare says about an exchange before departure. - `allowed` (boolean): Whether an exchange is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `refund` (object): What the fare says about a refund before departure. - `allowed` (boolean): Whether a refund is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `revalidation` (object): What the fare says about a revalidation before departure. - `allowed` (boolean): Whether a revalidation is allowed before departure. Indicative, not a promise: the fare rules of the airline decide. - `supplierCurrency` (string): Currency of the penalty amounts, when there are some. - `agencyCurrency` (string): Currency of your agency. - `validatingAirline` (array of object): The airline whose rules and ticket stock govern the fare. - `code` (string): IATA code of the airline. - `name` (string): Name of the airline. - `additionalServices` (object): Paid extras. A list in the offers of a search (Amadeus only); an object with a `baggage` list in a priced offer. - `requiresACD` (boolean): Amadeus Algeria only, present only when `true`: the fare is an ACD fare and the booking needs passports (see `upload_acd_documents`). ## Example ```json { "id": "fx_offer_9ab6d940", "type": "flight_offer", "supplier": { "id": "duffel_1", "name": "Duffel", "originalId": "off_034q6JKdiQDZ8HIDMFus64" }, "bookableSeats": null, "supportHold": false, "supportVoid": false, "voidDeadline": null, "filtersApplied": {}, "warnings": [], "instantTicketingRequired": false, "lastTicketingDate": "2026-11-01", "lastTicketingDateTime": "2026-11-01T15:52:40.830072Z", "pricing": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "basePrice": 16385, "taxesAndFees": 5226.2, "agencyCommission": 21.62, "finalPrice": 21632.82, "b2bPrice": 21611.2, "additionalServices": [] }, "itinerary": [ { "direction": "outbound", "duration": "PT2H24M", "stops": 0, "segments": [ { "id": "seg_0000BC0a5iUChSV6Qrz0Co", "departure": { "airportCode": "ALG", "airportName": "Houari Boumediene Airport", "cityName": "Algiers", "countryCode": "DZ", "terminal": "2", "datetime": "2026-12-15T10:50:00", "timeZone": "Africa/Algiers" }, "arrival": { "airportCode": "CDG", "airportName": "Paris Charles de Gaulle Airport", "cityName": "Paris", "countryCode": "FR", "terminal": "1", "datetime": "2026-12-15T13:14:00", "timeZone": "Europe/Paris" }, "flight": { "carrierCode": "ZZ", "carrierName": "Duffel Airways", "carrierLogoUrl": "https://assets.duffel.com/img/airlines/for-light-background/full-color-logo/ZZ.svg", "flightNumber": "7426", "aircraftCode": null, "aircraftName": null, "operatingCarrier": "ZZ", "operatingCarrierName": "Duffel Airways" }, "duration": "PT2H24M", "cabin": "ECONOMY", "bookingClass": null, "fareBasis": "Y20LGTN2", "brandedFare": "BASIC", "brandedFareLabel": "Basic" } ], "conditions": { "changeBeforeDeparture": { "allowed": false }, "refundBeforeDeparture": { "allowed": false } } } ], "travelerPricing": [ { "travelerId": "pas_0000BC0a5iH5UE0bmBqWkl", "travelerType": "ADULT", "fareOption": "STANDARD", "price": { "supplierCurrency": "USD", "agencyCurrency": "DZD", "base": 16385, "total": 21632.82, "b2bPrice": 21611.2 }, "fareDetailsBySegment": [ { "segmentId": "seg_0000BC0a5iUChSV6Qrz0Co", "cabin": "ECONOMY", "fareBasis": "Y20LGTN2", "brandedFare": "BASIC", "brandedFareLabel": "Basic", "bookingClass": null, "includedCheckedBags": { "quantity": 1 }, "includedCabinBags": { "quantity": 1 }, "amenities": [ { "description": "WIFI", "isChargeable": true, "amenityType": "WIFI", "amenityProvider": { "name": "BrandedFare" } }, { "description": "POWER SUPPLY", "isChargeable": false, "amenityType": "ENTERTAINMENT", "amenityProvider": { "name": "BrandedFare" } } ] } ] } ], "conditions": { "refundBeforeDeparture": { "allowed": false }, "changeBeforeDeparture": { "allowed": false } }, "fareRules": { "exchange": { "allowed": false }, "refund": { "allowed": false }, "revalidation": { "allowed": false }, "supplierCurrency": "USD", "agencyCurrency": "DZD" }, "validatingAirline": [ { "code": "ZZ", "name": "Duffel Airways" } ] } ``` --- # The Flight booking object > A flight booking of your agency as FX-Port stores it: its status, price, ticket number, the links of the PDF tickets and the ACD state. Source: https://fx-port.com/docs/api/objects/flight-booking `get_booking` returns it under `booking`. `id` is the FX-Port UUID, the stable key to store; `booking_id` (also `pnr`) is the airline record locator, which is not unique across suppliers and airlines; `amadeus_id` (also `order_id`) is the supplier's order id, the value `pay_order`, `cancel_order` and `cancel_quote` take. The booking is a database row: more keys than the ones listed here exist (the void history, the details of a changed schedule); they are internal and not part of the contract. The links `bucket_link` (`pdf_url`) and `bucket_link_no_price` are public addresses of PDFs that hold the traveler's name and the fare, and the `link` of every entry of `acd_documents` (also `acd_links`) is a public address of a passport: treat them all as sensitive. FX-Port's own storage keys of the passports are never returned. See Get a booking. ## Attributes - `id` (string): The FX-Port UUID of the booking. **Use it as the key** when you store or re-read a booking. - `booking_id` (string, deprecated): Legacy name: use `pnr`. The PNR. Not unique; prefer `id`. - `pnr` (string): The PNR. Not unique; prefer `id`. The snake_case twin of `booking_id`, with the same value: use this name. - `amadeus_id` (string, deprecated): Legacy name: use `order_id`. The supplier's order id (the name is historical: it also holds the `ord_...` of a Duffel order). This is the `order_id` of `pay_order`, `cancel_order` and `cancel_quote`. - `order_id` (string): The supplier's order id (the name is historical: it also holds the `ord_...` of a Duffel order). This is the `order_id` of `pay_order`, `cancel_order` and `cancel_quote`. The snake_case twin of `amadeus_id`, with the same value: use this name. - `status` (string): `confirmed`, `awaiting_payment` (a hold), `cancelled`, `partial_refund`, `failed`; `voided`, `refunded` and `expired` can appear for changed orders. - `created_at` (string): When the booking was created. - `paid_at` (string, nullable): When it was paid. - `updated_at` (string): When it last changed. - `total_price` (number): The final price in the currency of the agency. - `agency_commission` (number): Your commission. - `currency` (string): Currency of the agency. - `departure_airport` (string): IATA code of the first departure. - `arrival_airport` (string): IATA code of the last arrival. - `departure_date` (string): Local departure time, without a zone. - `arrival_date` (string): Local arrival time, without a zone. - `return_departure_date` (string, nullable): Local departure time of the return. - `return_arrival_date` (string, nullable): Local arrival time of the return. - `included_checked_bags` (string): The checked bags included, as a text. - `ticketing_agreement` (string, nullable): The ticketing-time-limit agreement of an Amadeus booking, for example `DELAY_TO_CANCEL`. - `supplier_name` (string): Name of the supplier. - `supplier_id` (string): Id of the supplier. - `ticket_number` (string, nullable): The e-ticket number(s), as one text; several numbers are joined by commas. `null` until the airline returns one. - `bucket_link` (string, nullable, deprecated): Legacy name: use `pdf_url`. Address of the PDF of the ticket (a hold: the hold voucher). `null` until generated. A public link. - `pdf_url` (string, nullable): Address of the PDF of the ticket (a hold: the hold voucher). `null` until generated. A public link. The snake_case twin of `bucket_link`, with the same value: use this name. - `bucket_link_no_price` (string, nullable, deprecated): Legacy name: use `pdf_url_no_price`. Address of the same PDF without prices. A public link. - `pdf_url_no_price` (string, nullable): Address of the same PDF without prices. A public link. The snake_case twin of `bucket_link_no_price`, with the same value: use this name. - `email_sent` (boolean): Whether the confirmation e-mail went out. - `require_acd` (boolean): `true` for an ACD booking (the stored flag). - `acd_required` (boolean): `true` for an ACD booking whose passports FX-Port did not waive: the same value as `acd.acd_required` of `get_acd_documents`. - `requires_acd` (boolean): The same value as `acd_required`. - `acd_submitted` (boolean): `true` once every traveler has a passport. Never `null`. - `acd_documents` (array of object, nullable): The passports uploaded through the API, one entry for each traveler who has one. `null` for a booking that never had one. `get_acd_documents` gives the same information for each traveler, with the files FX-Port staff added from the dashboard. - `traveler_id` (string): The traveler the passport belongs to: the `traveler_id` of `get_acd_documents`. - `traveler_name` (string): The name on the booking. - `traveler_type` (string): The passenger type, for example `ADULT`. - `link` (string): The **public** address of the file: anybody who has it can open the passport. Treat it as sensitive. - `filename` (string): The file name that was sent. - `content_type` (string): The type FX-Port detected from the content, for example `application/pdf` or `image/png`. - `size` (integer): The size of the stored file in bytes (`size_bytes` in `get_acd_documents`). - `format` (string): The detected format: `pdf`, `jpeg`, `png`, `webp`, `heif`, `avif`, `tiff`, `bmp`, `gif`, `jp2` or `jxl`. - `uploaded_at` (string): When the file was stored: ISO 8601, UTC. - `replaced_count` (integer): How many times the passport was replaced. - `previous_links` (array of object): The files this passport replaced, oldest first. Empty for a passport that was never replaced. - `link` (string): The public address of a file that was replaced. - `uploaded_at` (string): When that file was stored. - `replaced_at` (string): When it was replaced. - `source` (string): How the file arrived: `api` for an upload through the API. - `acd_links` (array of string, nullable): The public addresses of every passport file of the booking, including the ones FX-Port staff added. `null` when there is none. - `acd_amount` (number, nullable): The ACD amount of the booking in euros, saved when the set of passports became complete. Informational. `null` before. - `acd_paid` (boolean): `true` once FX-Port's team has marked the ACD amount as paid. You cannot set it. - `acd_documents_revision` (integer): Increases by one with each upload that stored a file. `0` for a booking without any. - `has_changed` (boolean): `true` when the airline changed the schedule after ticketing. - `new_flight_details` (object, nullable): The new schedule, when `has_changed`. - `voided_at` (string, nullable): When the ticket was voided. - `refund_amount` (number, nullable): The refund of a cancelled Duffel order. - `refund_currency` (string, nullable): Currency of the refund. - `refund_method` (string, nullable): How the refund was paid: `balance`. - `agent_id` (string, nullable): Your own agent id, when you sent one. - `environment` (string): `sandbox` or `live`. - `pricingBreakdown` (object, nullable): The price of the booking in snake_case. The rate and the ACD details are not here. - `supplier_currency` (string): Currency of the supplier. - `agency_currency` (string): Currency of the agency. - `base_price` (number): Base fare. - `taxes_and_fees` (number): Taxes and fees. - `agency_commission` (number): Your commission. - `final_price` (number): What the traveler pays. - `b2b_price` (number): What was debited from your balance. - `travelerPricing` (array of object): The price of each traveler. - `balanceOperations` (array of object): The ledger entries of this booking (debits, refunds). Empty in the sandbox. - `id` (string): Id of the entry. - `flight_booking_id` (string): The `id` of the booking. - `operation` (string): `debit`, `refund` ... - `amount` (number): The amount. - `currency` (string): Currency of the amount. - `description` (string): What the entry is for. - `created_at` (string): When it was written. - `b2b_price` (number): The booking's `b2b_price` at that time. - `final_price` (number): The booking's `final_price` at that time. - `apiResponse` (object): Only with `include_api_response: true`: the stored answer of the booking call, with the full passenger list. ## Example ```json { "id": "711371c3-00aa-4be2-b790-9f0ca2ccfdb7", "booking_id": "Y7PAEU", "amadeus_id": "eJzTd9ePNA9wdA0FAAs8AmE", "status": "confirmed", "created_at": "2026-11-01T15:26:14.871035+00:00", "paid_at": "2026-11-01T15:26:21.961664+00:00", "updated_at": "2026-11-01T15:26:21.998944+00:00", "total_price": 4930.37, "agency_commission": 4.93, "currency": "DZD", "departure_airport": "ALG", "arrival_airport": "ORN", "departure_date": "2026-12-22T19:45:00", "arrival_date": "2026-12-22T20:45:00", "return_departure_date": null, "return_arrival_date": null, "included_checked_bags": "0", "ticketing_agreement": "DELAY_TO_CANCEL", "supplier_name": "Amadeus Algeria", "supplier_id": "amadeus_aqc_dz_1", "ticket_number": "124-2446977397", "bucket_link": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "bucket_link_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf", "email_sent": false, "require_acd": false, "acd_submitted": false, "acd_links": null, "acd_amount": null, "acd_paid": false, "acd_documents_revision": 0, "acd_documents": null, "has_changed": false, "new_flight_details": null, "voided_at": null, "refund_amount": null, "refund_currency": null, "refund_method": null, "agent_id": null, "environment": "sandbox", "pricingBreakdown": { "supplier_currency": "DZD", "agency_currency": "DZD", "base_price": 3160, "taxes_and_fees": 1765.44, "agency_commission": 4.93, "final_price": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:22.176516+00:00", "environment": "sandbox" }, "travelerPricing": [ { "traveler_id": "1", "traveler_type": "ADULT", "fare_option": "STANDARD", "supplier_currency": "DZD", "agency_currency": "DZD", "base": 3160, "total": 4930.37, "b2b_price": 4925.44, "created_at": "2026-11-01T15:26:15.133066+00:00", "environment": "sandbox" } ], "balanceOperations": [], "order_id": "eJzTd9ePNA9wdA0FAAs8AmE", "pnr": "Y7PAEU", "pdf_url": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH.pdf", "pdf_url_no_price": "https://s3.example.com/flight-bookings/Y7PAEU-JAMES-SMITH-no-price.pdf", "acd_required": false, "requires_acd": false } ``` --- # The Hotel mini object > The compact card of a hotel, made for a list of search results: its identity, place, stars, photos and amenities in 13 snake_case fields, about 5 KB. Source: https://fx-port.com/docs/api/objects/hotel-mini `search_advanced` returns one per hotel in `hotels_details[]`; `POST /hotels/search?detail_level=minimal` and `get_hotel_info` with `detail_level=minimal` return the same card. It has no price and no room: prices come from `hotels_pricing` and `get_rates`. Use it for lists and call the full document for the one hotel the user opens. See Mini and full hotel data. ## Attributes - `id` (string): The FX-Port id of the hotel, a lower-case slug. Send it as `hotel_id` to `get_rates` and `get_hotel_info`. - `hid` (integer): The supplier's numeric hotel id. `get_hotel_info` also accepts it as a lookup key. - `name` (string): Name of the hotel. - `kind` (string): Type of property as the supplier names it: `Hotel`, `Apartment`, `Resort`, `Hostel`, `Guesthouse`, `Boutique and Design` ... - `address` (string): Street address, in English. - `latitude` (number): Latitude of the hotel. - `longitude` (number): Longitude of the hotel. - `postal_code` (string, nullable): Postal code. `null` for a minority of hotels. - `region` (object): The city or area a hotel is in. - `id` (integer): Supplier id of the region. - `country_code` (string): ISO 3166-1 alpha-2 country code, upper case. - `iata` (string): IATA code of the main airport or city of the region. - `name` (string): Name of the region, usually the city. - `type` (string): Kind of region as the supplier names it: `City`, `Region`, `Airport` ... - `type_v2` (string): The same kind in lower case (`city`). Missing for a few hotels. - `star_rating` (integer): Number of stars, 0 to 5. `0` means the property is not rated. Between 0 and 5. - `images` (array of string, nullable): URL templates of the hotel photos, best photo first. Replace `{size}` to get a picture: `120x120`, `240x240`, `640x400` and `1024x768` work, and so does `x500` (500 px high); `orig` does not. See "Images" in Mini and full hotel data. - `serp_filters` (array of string): Tags to build result filters with: `has_internet`, `has_parking`, `has_pool`, `has_spa`, `has_fitness`, `has_pets`, `has_kids`, `has_meal`, `has_jacuzzi`, `has_business`, `has_smoking`, `has_disabled_support`, `has_airport_transfer`, `air_conditioning`, `kitchen`. - `amenity_groups` (array of object): The amenities of the hotel by group. With the images it makes up about 90% of the bytes of a mini card. - `group_name` (string): Name of the group: `General`, `Rooms`, `Services and amenities`, `Meals`, `Sports and Recreation` ... - `amenities` (array of string): Every amenity of the group, free or paid. - `non_free_amenities` (array of string, nullable): The amenities of `amenities` that are paid at the property. `null` or empty when all are free. ## Example ```json { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "latitude": 14.079872, "longitude": -87.21677, "postal_code": null, "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" }, "star_rating": 0, "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg", "https://cdn.worldota.net/t/{size}/extranet/15/44/15440558c952ec4c3488730d1563dc9e8d25832c.JPEG" ], "serp_filters": [ "has_internet", "has_parking", "has_spa", "has_pets", "has_jacuzzi", "kitchen" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Early check-in", "Late check-out" ], "non_free_amenities": [], "group_name": "General" }, { "amenities": [ "Concierge services", "Dry-cleaning", "Shoe shine" ], "non_free_amenities": [ "Dry-cleaning", "Shoe shine" ], "group_name": "Services and amenities" } ] } ``` --- # The Hotel full object > Everything FX-Port knows about one hotel, for the hotel page before checkout: the mini fields plus the description, every room type with photos and amenities, the policies, the facts and the contact details. Source: https://fx-port.com/docs/api/objects/hotel-full It is 5 to 10 times larger than the mini card (35 KB on average, up to 130 KB). `POST /hotels/search` returns it by default in snake_case; `get_hotel_info` returns the same data in camelCase and `POST /hotels/details` the stored document with two more fields. See Mini and full hotel data. ## Attributes - `id` (string): The FX-Port id of the hotel. Send it as `hotel_id`. - `hid` (integer): The supplier's numeric hotel id. - `name` (string): Name of the hotel. - `kind` (string): Type of property: `Hotel`, `Apartment`, `Resort` ... - `hotel_chain` (string): The hotel chain, or `No chain`. - `address` (string): Street address, in English. - `postal_code` (string, nullable): Postal code; `null` for a minority of hotels. - `latitude` (number): Latitude of the hotel. - `longitude` (number): Longitude of the hotel. - `region` (object): The city or area a hotel is in. - `id` (integer): Supplier id of the region. - `country_code` (string): ISO 3166-1 alpha-2 country code, upper case. - `iata` (string): IATA code of the main airport or city of the region. - `name` (string): Name of the region, usually the city. - `type` (string): Kind of region as the supplier names it: `City`, `Region`, `Airport` ... - `type_v2` (string): The same kind in lower case (`city`). Missing for a few hotels. - `star_rating` (integer): Number of stars, 0 to 5. `0` means not rated. Between 0 and 5. - `star_certificate` (object, nullable): The official star certificate of a hotel, when it has one. - `certificate_id` (string): Id of the star certificate; an empty string when the supplier has none. - `valid_to` (string): End of validity of the certificate; an empty string when unknown. - `email` (string, nullable): Contact e-mail of the hotel. - `phone` (string): Contact phone number of the hotel. - `check_in_time` (string): Time of day from which guests can check in, `HH:MM:SS`, local time. A time, not a date. - `check_out_time` (string): Time of day by which guests must check out, `HH:MM:SS`, local time. - `check_in_time_end` (string): Time of day until which guests can check in. Present only when the hotel states it. - `front_desk_time_start` (string, nullable): Time of day the front desk opens, `HH:MM:SS`; `null` when unknown. - `front_desk_time_end` (string, nullable): Time of day the front desk closes, `HH:MM:SS`; `null` when unknown. - `images` (array of string, nullable): URL templates of the hotel photos, best photo first. Replace `{size}` to get a picture: `120x120`, `240x240`, `640x400` and `1024x768` work, and so does `x500` (500 px high); `orig` does not. See "Images" in Mini and full hotel data. - `amenity_groups` (array of object): The amenities of the hotel by group. - `group_name` (string): Name of the group: `General`, `Rooms`, `Services and amenities`, `Meals`, `Sports and Recreation` ... - `amenities` (array of string): Every amenity of the group, free or paid. - `non_free_amenities` (array of string, nullable): The amenities of `amenities` that are paid at the property. `null` or empty when all are free. - `serp_filters` (array of string): Tags to build result filters with (see `HotelMini`). - `description_struct` (array of object): The description of the hotel, in titled blocks. - `title` (string): Title of the block: `Location`, `At the hotel`, `At the apartment`, `Room amenities` ... - `paragraphs` (array of string): The paragraphs of the block, in English. - `metapolicy_struct` (object): The policies of the hotel: internet, meals, extra beds, children, pets, parking, deposits ... The keys are the same in `get_hotel_info` in camelCase (`childrenMeal`, `extraBed` ...); the entries inside keep their snake_case keys. - `internet` (array of object): Internet access and its price. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `meal` (array of object): Meals that can be added to a rate, with prices. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `children_meal` (array of object): Meals for children, by age range. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `extra_bed` (array of object): Extra beds and their price. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `cot` (array of object): Cots and their price. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `pets` (array of object): Whether pets are allowed and what they cost. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `shuttle` (array of object): Shuttle services and their price. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `parking` (array of object): Parking and its price. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `children` (array of object): What a child of each age range costs and whether an extra bed is available. Read it before you assume a child stays free. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `visa` (object): Visa support of the hotel. - `visa_support` (string): `support_enable` when the hotel supports visa invitations, `unspecified` otherwise. - `deposit` (array of object): Deposits the hotel asks for at arrival. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `no_show` (object): The no-show policy. - `availability` (string): Whether a no-show policy exists: `available` or `unspecified`. - `time` (string, nullable): Time of day after which a guest counts as a no-show. - `day_period` (string): `after_midday`, `before_midday` or `unspecified`. - `add_fee` (array of object): Other fees payable at the property. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `check_in_check_out` (array of object): Early check-in and late check-out and their price. - `inclusion` (string): Whether the service is included in the room price. One of `included`, `not_included`. - `price` (string): The amount as a decimal string, in `currency`. - `currency` (string, nullable): ISO 4217 currency of `price`; `null` when the service is free or the price is a percentage. - `price_unit` (string): What the price is for: `per_room_per_night`, `per_guest_per_stay`, `per_car_per_night`, `per_hour`, `unspecified` ... - `meal_type` (string): Meal of the entry: `breakfast`, `lunch`, `dinner` ... - `internet_type` (string): Kind of internet access: `wifi`, `wired`, `unspecified` ... - `work_area` (string): Where the internet works: `hotel`, `room`, `public_area` ... - `age_start` (integer): First age of a child the entry applies to. - `age_end` (integer): Last age of a child the entry applies to. - `amount` (integer): How many of the item the entry is about (extra beds, cots). - `pets_type` (string): Kind of pet the entry applies to: `dog`, `cat`, `unspecified` ... - `shuttle_type` (string): Kind of shuttle: `one_way`, `round_trip`. - `destination_type` (string): Where the shuttle goes: `airport`, `train`, `bus` ... - `territory_type` (string): Where the car park is: `inside`, `outside`, `unspecified`. - `extra_bed` (string): For a child entry: whether an extra bed is `available`, `not_available` or `unspecified`. - `availability` (string): Whether the service is `available`, `not_available` or `unspecified`. - `pricing_method` (string): For a deposit: `fixed` or `percent`. - `payment_type` (string): For a deposit: how it is paid (`cash`, `card`, `unspecified`). - `deposit_type` (string): For a deposit: what it covers (`breakage`, `unspecified` ...). - `check_in_check_out_type` (string): For an early or late stay: `early_checkin`, `late_checkout` ... - `metapolicy_extra_info` (string, nullable): Free text with the house rules of the hotel. - `room_groups` (array of object): Every room type of the hotel, with photos and amenities. About 80% of the bytes of a full document. - `room_group_id` (integer): Id of the room group. A rate of `get_rates` belongs to one room group by name. - `name` (string): Full name of the room type. - `name_struct` (object): The room name taken apart. - `main_name` (string): The room name without the bedding. - `bedding_type` (string, nullable): The bedding of the room, when the name says it. - `bathroom` (string, nullable): The bathroom, when the name says it. - `rg_ext` (object): Numeric codes of the properties of a room group. They are the supplier's own; the camelCase `get_hotel_info` document decodes them into words (`roomClass`, `quality`, `beddingType` ...). - `class` (integer): Room class code. - `quality` (integer): Room quality code. - `sex` (integer): Gender restriction code; `0` for none. - `bathroom` (integer): Bathroom code. - `bedding` (integer): Bedding code. - `family` (integer): `1` for a family room, `0` otherwise. - `capacity` (integer): Capacity code; `0` when unknown. - `club` (integer): `1` for a club room, `0` otherwise. - `bedrooms` (integer): Number of bedrooms; `0` when unknown. - `balcony` (integer): `1` when the room has a balcony, `0` otherwise. - `floor` (integer): Floor code; `0` when unknown. - `view` (integer): View code; `0` when unknown. - `room_amenities` (array of string): Amenities of the room, as tags (`bath`, `heating`, `shared-bathroom` ...). - `images` (array of string, nullable): Photos of the room, as URL templates with `{size}`. - `size` (number, nullable): Size of the room in square meters, when the supplier knows it. - `facts` (object): Facts about the building. - `floors_number` (integer, nullable): Number of floors. - `rooms_number` (integer, nullable): Number of rooms. - `year_built` (integer, nullable): Year the building was built. - `year_renovated` (integer, nullable): Year of the last renovation. - `electricity` (object): The electricity of the hotel. - `frequency` (array of integer): Mains frequencies, for example `[60]`. - `voltage` (array of number, nullable): Mains voltages, when known. - `sockets` (array of string): Socket types (`a`, `b`, `c` ...). - `register` (object, nullable): Registration data of the property, for the countries that require it. - `payment_methods` (array of string): Payment methods accepted at the property. - `keys_pickup` (object): How the guest gets the keys (mostly for apartments). - `type` (string): How guests collect the keys: `unspecified`, `lockbox`, `reception`, `apartment_office` ... - `is_contactless` (boolean): `true` when the keys are collected without meeting anybody. - `phone` (string, nullable): Phone number to call for the keys. - `email` (string, nullable): E-mail address to write to for the keys. - `apartment_office_address` (string, nullable): Address of the office that hands over the keys. - `apartment_extra_information` (string, nullable): Free text about the key pick-up. - `is_closed` (boolean): `true` when the property is closed on the supplier side: availability calls return no rates. - `is_gender_specification_required` (boolean): `true` when the hotel needs the gender of each guest. - `deleted` (boolean): `true` when the supplier removed the hotel from its catalogue. - `distance_center` (number, nullable): Distance to the city center, when the supplier gives it. - `giata_code` (string): GIATA code of the hotel. Present only for hotels that have one. ## Example ```json { "id": "test_hotel_do_not_book", "hid": 8473727, "name": "Test Hotel (Do Not Book) test", "kind": "Apartment", "hotel_chain": "No chain", "address": "Avenida Roble, Frente al Mall Multiplaza, Tegucigalpa", "postal_code": null, "latitude": 14.079872, "longitude": -87.21677, "region": { "id": 3421, "country_code": "HN", "iata": "TGU", "name": "Tegucigalpa", "type": "City", "type_v2": "city" }, "star_rating": 0, "star_certificate": null, "email": "test@example.com", "phone": "+15555550123", "check_in_time": "14:00:00", "check_out_time": "12:00:00", "front_desk_time_start": "00:00:00", "front_desk_time_end": "00:00:00", "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ], "amenity_groups": [ { "amenities": [ "24-hour reception", "Early check-in", "Late check-out" ], "non_free_amenities": [], "group_name": "General" } ], "serp_filters": [ "has_internet", "has_parking", "kitchen" ], "description_struct": [ { "title": "Location", "paragraphs": [ "A perfect fit for a big group of travellers: apartment «Test Hotel (Do Not Book)» ..." ] } ], "metapolicy_struct": { "internet": [ { "inclusion": "included", "price": "0.00", "currency": null, "price_unit": "unspecified", "internet_type": "unspecified", "work_area": "hotel" } ], "meal": [ { "inclusion": "not_included", "price": "300.00", "currency": "USD", "meal_type": "breakfast" } ], "children_meal": [ { "inclusion": "not_included", "price": "200.00", "currency": "USD", "meal_type": "breakfast", "age_start": 0, "age_end": 5 } ], "extra_bed": [ { "inclusion": "not_included", "price": "100.00", "currency": "ALL", "price_unit": "per_guest_per_night", "amount": 1 } ], "cot": [ { "inclusion": "not_included", "price": "0.00", "currency": "USD", "price_unit": "per_room_per_night", "amount": 1 } ], "pets": [ { "inclusion": "not_included", "price": "560.00", "currency": "USD", "price_unit": "per_room_per_stay", "pets_type": "unspecified" } ], "shuttle": [ { "inclusion": "not_included", "price": "100.00", "currency": "EUR", "shuttle_type": "one_way", "destination_type": "train" } ], "parking": [ { "inclusion": "not_included", "price": "100.00", "currency": "USD", "price_unit": "per_car_per_night", "territory_type": "unspecified" } ], "children": [ { "age_start": 0, "age_end": 3, "price": "500.00", "currency": "CHF", "extra_bed": "available" } ], "visa": { "visa_support": "support_enable" }, "deposit": [ { "availability": "available", "price": "123.00", "currency": "HNL", "price_unit": "per_room_per_stay", "pricing_method": "fixed", "payment_type": "cash", "deposit_type": "unspecified" } ], "no_show": { "availability": "available", "time": "09:00:00", "day_period": "after_midday" }, "add_fee": [], "check_in_check_out": [] }, "metapolicy_extra_info": "Front desk is open 24/7.", "room_groups": [ { "room_group_id": 95, "name": "Standard Double room full double bed", "name_struct": { "main_name": "Standard Double room", "bedding_type": "full double bed", "bathroom": "shared bathroom" }, "rg_ext": { "class": 3, "quality": 2, "sex": 0, "bathroom": 1, "bedding": 3, "family": 0, "capacity": 2, "club": 0, "bedrooms": 0, "balcony": 0, "floor": 0, "view": 0 }, "room_amenities": [ "bath", "heating" ], "images": [ "https://cdn.worldota.net/t/{size}/extranet/b5/d3/b5d3d33394494c68321246882c1bd93a6832dcd5.jpeg" ] } ], "facts": { "floors_number": null, "rooms_number": null, "year_built": null, "year_renovated": null, "electricity": { "frequency": [ 60 ], "voltage": null, "sockets": [ "a", "b" ] }, "register": null }, "payment_methods": [ "visa", "maestro", "cash" ], "keys_pickup": { "type": "lockbox", "phone": "+15555550123", "email": "test@example.com", "apartment_office_address": null, "apartment_extra_information": "The keys are in the box by the front door.", "is_contactless": true }, "is_closed": false, "is_gender_specification_required": false, "deleted": false } ``` --- # The eSIM package object > An eSIM package is a data plan you can sell: its data allowance, validity, destination, network operator and the prices for your agency. Source: https://fx-port.com/docs/api/objects/esim-package The catalog returns it with the supplier's country and operator facts flattened into `country_*` and `operator_*` keys. Four amounts describe the price. `price` is the base price before your commission, `total_price` and `retail_price` are what you charge the traveler, and `balance_debit_amount` is what is debited from your balance when you order. Read the eSIM pricing guide before you compute anything from them. ## Attributes - `id` (string, deprecated): Legacy name: use `package_id`. Package id. Send it as `package_id` when you create an order. Always read it from the catalog: ids change when suppliers change their offer. The older name of `package_id`: same value, use `package_id`. - `package_id` (string): Package id. Send it as `package_id` when you create an order. Always read it from the catalog: ids change when suppliers change their offer. The snake_case twin of `id`, with the same value: use this name. - `type` (string): Always `sim`: the package is sold as a new eSIM. - `price` (number, deprecated): Legacy name: use `base_price`. Base price in your agency currency, before your commission. It is not what is debited from your balance: use `balance_debit_amount`. The older name of `base_price`: same value, use `base_price`. - `base_price` (number): Base price in your agency currency, before your commission. It is not what is debited from your balance: use `balance_debit_amount`. The snake_case twin of `price`, with the same value: use this name. - `amount` (integer): Data allowance in megabytes. `0` means unlimited: check `is_unlimited`. - `day` (integer): Validity in days. It starts at the first use of the eSIM or at installation, see `operator_activation_policy`. - `is_unlimited` (boolean): `true` for unlimited-data packages. - `title` (string): Label of the package: data allowance and validity, in the language of the request. - `is_fair_usage_policy` (boolean): Whether a fair usage policy applies. A boolean on limited packages and `0` or `1` on unlimited ones: treat `0` as false and `1` as true. - `fair_usage_policy` (string, nullable): The fair usage terms, for example the speed after a daily threshold. Null when no policy applies. - `data` (string): Data allowance as text, for example `1 GB` or `Unlimited`. - `voice` (integer, nullable): Voice minutes included. Null for data-only packages. - `text` (integer, nullable): SMS included. Null for data-only packages. - `country_name` (string): Destination country or region name, in the language of the request. - `country_code` (string): Destination country code (ISO 3166-1 alpha-2). An empty string for regional and worldwide bundles, whose `country_slug` is the region, for example `europe`. - `country_slug` (string): Destination slug. Send it as the `country` filter of the catalog. - `country_image_url` (string, nullable): Flag or destination image, for display. - `operator_name` (string): Name of the mobile network operator or brand the package runs on. - `operator_id` (integer): Identifier of the operator. - `operator_style` (string, nullable): Display hint: `light` or `dark` text on the operator's colors. - `operator_gradient_start` (string, nullable): First color of the operator's gradient, as a hex value. - `operator_gradient_end` (string, nullable): Second color of the operator's gradient, as a hex value. - `operator_type` (string): Scope of the package: `local` for one country, `global` for regional and worldwide bundles. There is no `regional` value. One of `local`, `global`. - `operator_is_prepaid` (boolean): Whether the underlying plan is prepaid. - `operator_esim_type` (string): Type of the plan, for example `Prepaid`. - `operator_warning` (string, nullable): A caution to show the traveler before the sale. Null when there is none. - `operator_apn_type` (string): `automatic` or `manual`: whether the APN is configured by the eSIM profile or entered by the traveler. - `operator_is_roaming` (boolean): Whether the package works through roaming rather than a local profile. - `operator_info` (array of string): Short facts about the network, the technology and whether the eSIM can be recharged. - `operator_image_url` (string, nullable): Logo of the operator. - `operator_plan_type` (string): Kind of plan, for example `data` for a data-only plan. - `operator_activation_policy` (string): When validity starts, for example `first-usage`: at the first connection, not at purchase. - `operator_is_kyc_verify` (boolean): Whether the destination requires identity verification for this operator. Rare. - `operator_rechargeability` (boolean): Whether the eSIM can be topped up, see the topup guide. - `operator_other_info` (string, nullable): Longer notes on coverage and exclusions, for example territories that are not covered. - `operator_coverages` (array of object): Countries covered, with their networks. - `name` (string): Country or area name as the supplier lists it. - `code` (string): Country code (ISO 3166-1 alpha-2) or area code. - `networks` (array of object): Networks the eSIM can use in this country, with their technologies. - `name` (string): Name of the mobile network. - `types` (array of string): Technologies the network offers in this country. - `operator_apn` (object, nullable): APN settings per operating system. - `ios` (object): Access point name (APN) settings for one operating system. - `apn_type` (string): `automatic` when the device configures the access point name by itself, `manual` when the traveler has to enter it. - `apn_value` (string, nullable): The access point name to enter on a manual setup; null or `N/A` when none is needed. - `android` (object): Access point name (APN) settings for one operating system. - `apn_type` (string): `automatic` when the device configures the access point name by itself, `manual` when the traveler has to enter it. - `apn_value` (string, nullable): The access point name to enter on a manual setup; null or `N/A` when none is needed. - `is_regional` (boolean): Always `false`. Regional bundles are `global` packages: check `operator_type`. - `is_global` (boolean): `true` when `operator_type` is `global`. - `retail_price` (number): The price to charge the traveler, in your agency currency. Equal to `total_price`. - `total_price` (number): The price to charge the traveler, in your agency currency. It includes your commission. - `balance_debit_amount` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance when you order this package. `total_price` minus this amount is your commission. The older name of `b2b_price`: same value, use `b2b_price`. - `b2b_price` (number): The amount debited from your agency balance when you order this package. `total_price` minus this amount is your commission. The snake_case twin of `balance_debit_amount`, with the same value: use this name. - `agency_commission` (string): Your commission rate as text, for example `12 %`; `0 %` for contracted corporate agencies. Bookings carry the commission as an amount. - `commission_rate` (number): Your commission rate as a number: `12` for a standard agency, `0` for a contracted corporate agency. Same rate as `agency_commission`. - `currency` (string): Currency of every amount on the package: the settlement currency of your agency. - `supplier_currency` (string): Currency the supplier prices the package in. - `exchange_rate` (number): Rate used to convert `supplier_currency` to your agency currency. ## Example ```json { "id": "france-in-7days-1gb", "package_id": "france-in-7days-1gb", "type": "sim", "price": 402.5, "base_price": 402.5, "amount": 1024, "day": 7, "is_unlimited": false, "title": "1 GB - 7 days", "is_fair_usage_policy": false, "fair_usage_policy": null, "data": "1 GB", "voice": null, "text": null, "country_name": "France", "country_code": "FR", "country_slug": "france", "country_image_url": "https://cdn-revamp.airalo.com/images/9753dedb-d495-47cf-b6e4-82e555564743.png", "operator_name": "KPN France Data only", "operator_id": 1704, "operator_style": "dark", "operator_gradient_start": "#F1D4FB", "operator_gradient_end": "#EEC0FF", "operator_type": "local", "operator_is_prepaid": false, "operator_esim_type": "Prepaid", "operator_warning": null, "operator_apn_type": "automatic", "operator_is_roaming": true, "operator_info": [ "Data-only eSIM.", "Rechargeable online with no expiry." ], "operator_image_url": "https://cdn-revamp.airalo.com/images/01b47d41-fbb4-4032-8f5a-d1ca6a0f3688.png", "operator_plan_type": "data", "operator_activation_policy": "first-usage", "operator_is_kyc_verify": false, "operator_rechargeability": true, "operator_other_info": "Covers metropolitan (mainland) France and Corsica. Does not include overseas territories (Guadeloupe, Martinique, Reunion, ...", "operator_coverages": [ { "name": "FR", "code": "FR", "networks": [ { "name": "Orange", "types": [ "5G" ] } ] } ], "operator_apn": { "ios": { "apn_type": "automatic", "apn_value": null }, "android": { "apn_type": "automatic", "apn_value": null } }, "is_regional": false, "is_global": false, "retail_price": 468.84, "total_price": 468.84, "balance_debit_amount": 418.6, "b2b_price": 418.6, "agency_commission": "12 %", "commission_rate": 12, "currency": "DZD", "supplier_currency": "USD", "exchange_rate": 250 } ``` --- # The eSIM booking object > An eSIM booking is one eSIM order or one topup of your agency: who it is for, what was bought, what it cost and which supplier fulfilled it. Source: https://fx-port.com/docs/api/objects/esim-booking `GET /esims/bookings` lists the orders and `GET /esims/bookings/{iccid}/topup-history` the topups. `total_price` is what you charge the traveler and `agency_commission` is your commission as an amount: the amount debited from your balance is `total_price` minus `agency_commission`. A failed attempt is kept with the status `failed` and an ICCID that starts with `PENDING-`. ## Attributes - `id` (string (uuid)): Internal id of the booking row (UUID). It is the `id` the account history returns. - `booking_id` (string): The FX-Port booking reference, `FX-` followed by a number. Use it to look the booking up. - `order_id` (string): The supplier's order code of the eSIM or of the topup. - `status` (string): State of the booking: `confirmed`, or `failed` when the supplier refused the order (those rows have an ICCID that starts with `PENDING-`). One of `confirmed`, `failed`. - `currency` (string): Currency of the amounts: the settlement currency of your agency. - `total_price` (number): The price to charge the traveler. - `agency_commission` (number): Your commission as an amount, in your agency currency. The amount debited from your balance is `total_price` minus this. - `type` (string): `esim` for an order, `topup` for a topup. One of `esim`, `topup`. - `package_name` (string): Name of the package, with the destination for an eSIM: `1 GB - 7 days • France`. - `iccid` (string): The ICCID of the eSIM. A topup row carries the ICCID that was topped up. - `customer_email` (string): The traveler's email address. - `customer_first_name` (string): The traveler's first name. - `customer_last_name` (string): The traveler's last name. - `customer_phone` (string): The traveler's phone number. - `provider_name` (string): Name of the supplier that fulfilled the booking. - `supplier_id` (string): Id of the supplier that fulfilled the booking. Here `airalo`: the supplier list calls the same supplier `airalo_1`. - `created_at` (string): When the booking was created: ISO 8601, UTC. - `updated_at` (string): When the booking last changed: ISO 8601, UTC. - `environment` (string): The environment of the booking. One of `sandbox`, `live`. ## Example ```json { "id": "639417b4-b103-4ece-bd5d-850315322278", "booking_id": "FX-1793546602916", "order_id": "20261101-106852", "status": "confirmed", "currency": "DZD", "total_price": 468.84, "agency_commission": 50.24, "type": "esim", "package_name": "1 GB - 7 days • France", "iccid": "8900000540463077957", "customer_email": "traveler@example.com", "customer_first_name": "John", "customer_last_name": "Smith", "customer_phone": "+15555550123", "provider_name": "Airalo", "supplier_id": "airalo", "created_at": "2026-11-01T15:23:22.961228+00:00", "updated_at": "2026-11-01T15:23:22.961228+00:00", "environment": "sandbox" } ``` --- # The Account booking object > A booking of your agency in the account endpoints, whatever its product: flight, hotel, eSIM or transfer. Source: https://fx-port.com/docs/api/objects/account-booking The top level is a camelCase summary shared with the booking history; `record` is the stored booking in snake_case; flights and hotels add the price breakdown and the balance operations. The amount debited from your agency balance is `netPrice`: `totalPrice` minus `agencyCommission`. Use `id` (a UUID) as your key for a booking: public references are not unique across suppliers. ## Attributes - `product` (string): The product of the booking. One of `flights`, `hotels`, `esims`, `transfers`. - `id` (string (uuid)): Internal id of the booking (UUID). Stable and unique: store it as your key. - `bookingId` (string, deprecated): Legacy name: use `booking_id`. The public reference of the booking: the airline record locator of a flight (not unique across suppliers), the hotel booking id, `FX-` followed by a number for an eSIM, or the transfer reference. The older name of `booking_id`: same value, use `booking_id`. - `booking_id` (string): The public reference of the booking: the airline record locator of a flight (not unique across suppliers), the hotel booking id, `FX-` followed by a number for an eSIM, or the transfer reference. The snake_case twin of `bookingId`, with the same value: use this name. - `status` (string): State of the booking. The values depend on the product: for example `confirmed`, `awaiting_payment` (a held flight that is not paid), `cancelled`, `failed`, `refunded`, `partial_refund`. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `agencyId` (string (uuid), deprecated): Legacy name: use `agency_id`. The id of your agency. The older name of `agency_id`: same value, use `agency_id`. - `agency_id` (string (uuid)): The id of your agency. The snake_case twin of `agencyId`, with the same value: use this name. - `agentId` (string, nullable, deprecated): Legacy name: use `agent_id`. The user of your agency who created the booking, or null when it was created through an API key without a user. The older name of `agent_id`: same value, use `agent_id`. - `agent_id` (string, nullable): The user of your agency who created the booking, or null when it was created through an API key without a user. The snake_case twin of `agentId`, with the same value: use this name. - `currency` (string): Currency of the amounts: the settlement currency of your agency. - `totalPrice` (number, deprecated): Legacy name: use `total_price`. What the traveler pays, in your agency currency. It includes your commission. The older name of `total_price`: same value, use `total_price`. - `total_price` (number): What the traveler pays, in your agency currency. It includes your commission. The snake_case twin of `totalPrice`, with the same value: use this name. - `netPrice` (number, deprecated): Legacy name: use `b2b_price`. The amount debited from your agency balance: `totalPrice` minus `agencyCommission`. The older name of `b2b_price`: same value, use `b2b_price`. - `b2b_price` (number): The amount debited from your agency balance: `totalPrice` minus `agencyCommission`. The snake_case twin of `netPrice`, with the same value: use this name. - `agencyCommission` (number, deprecated): Legacy name: use `agency_commission`. Your commission as an amount, in your agency currency. `0` when there is none. The older name of `agency_commission`: same value, use `agency_commission`. - `agency_commission` (number): Your commission as an amount, in your agency currency. `0` when there is none. The snake_case twin of `agencyCommission`, with the same value: use this name. - `providerName` (string, nullable, deprecated): Legacy name: use `provider_name`. Name of the supplier that fulfilled the booking. The older name of `provider_name`: same value, use `provider_name`. - `provider_name` (string, nullable): Name of the supplier that fulfilled the booking. The snake_case twin of `providerName`, with the same value: use this name. - `supplierId` (string, nullable, deprecated): Legacy name: use `supplier_id`. Id of the supplier that fulfilled the booking, as stored on the booking. It can differ from the id the supplier list uses: `ratehawk` and `airalo` here are `worldota_1` and `airalo_1` there. The older name of `supplier_id`: same value, use `supplier_id`. - `supplier_id` (string, nullable): Id of the supplier that fulfilled the booking, as stored on the booking. It can differ from the id the supplier list uses: `ratehawk` and `airalo` here are `worldota_1` and `airalo_1` there. The snake_case twin of `supplierId`, with the same value: use this name. - `createdAt` (string, deprecated): Legacy name: use `created_at`. When the booking was created: ISO 8601, UTC. The older name of `created_at`: same value, use `created_at`. - `created_at` (string): When the booking was created: ISO 8601, UTC. The snake_case twin of `createdAt`, with the same value: use this name. - `updatedAt` (string, deprecated): Legacy name: use `updated_at`. When the booking last changed: ISO 8601, UTC. The older name of `updated_at`: same value, use `updated_at`. - `updated_at` (string): When the booking last changed: ISO 8601, UTC. The snake_case twin of `updatedAt`, with the same value: use this name. - `departureAirport` (string): Flights only. IATA code of the first departure airport. - `arrivalAirport` (string): Flights only. IATA code of the final arrival airport. - `departureDate` (string): Flights only. Local departure date and time of the first segment, without a zone. - `arrivalDate` (string): Flights and transfers. Local arrival date and time, without a zone for flights. - `ticketNumber` (string, nullable): Flights only. The ticket number once issued, or null. - `requireAcd` (boolean, nullable, deprecated): Legacy name: use `require_acd`. Flights only. Whether the booking needs ACD documents. The older name of `require_acd`: same value, use `require_acd`. - `require_acd` (boolean, nullable): Flights only. Whether the booking needs ACD documents. The snake_case twin of `requireAcd`, with the same value: use this name. - `voucherUrl` (string, nullable): Flights and hotels: the confirmation PDF with prices (same as `voucherUrlWithPrice`), null while it is generated. Transfers: the supplier voucher, null until a driver is assigned. - `voucherUrlWithPrice` (string, nullable): Flights and hotels. The confirmation PDF with prices. - `voucherUrlNoPrice` (string, nullable): Flights and hotels. The confirmation PDF without prices, to give to the traveler. - `hotelId` (string): Hotels only. The id of the hotel. - `hotelName` (string, nullable): Hotels only. The name of the hotel, or null when the supplier did not give one (a failed booking, for example). - `checkinDate` (string): Hotels only. Check-in date: `YYYY-MM-DD`. - `checkoutDate` (string): Hotels only. Check-out date: `YYYY-MM-DD`. - `bookingMode` (string, nullable): Hotels only. How the booking was made, for example `standard`. - `orderId` (string, deprecated): Legacy name: use `order_id`. eSIMs only. The supplier's order code. The older name of `order_id`: same value, use `order_id`. - `order_id` (string): eSIMs only. The supplier's order code. The snake_case twin of `orderId`, with the same value: use this name. - `type` (string): eSIMs only. `esim` for an order, `topup` for a topup. One of `esim`, `topup`. - `packageName` (string, deprecated): Legacy name: use `package_name`. eSIMs only. Name of the package. The older name of `package_name`: same value, use `package_name`. - `package_name` (string): eSIMs only. Name of the package. The snake_case twin of `packageName`, with the same value: use this name. - `iccid` (string): eSIMs only. The ICCID of the eSIM. - `customerEmail` (string, deprecated): Legacy name: use `customer_email`. eSIMs only. The traveler's email. The older name of `customer_email`: same value, use `customer_email`. - `customer_email` (string): eSIMs only. The traveler's email. The snake_case twin of `customerEmail`, with the same value: use this name. - `customer_first_name` (string): eSIMs only. The traveler's first name. - `customer_last_name` (string): eSIMs only. The traveler's last name. - `customer_phone` (string): eSIMs only. The traveler's phone number. - `flightNumber` (string, nullable): Transfers only. The flight number the transfer was booked for. - `receiptUrl` (string, nullable): Transfers only. FX-Port's receipt PDF, once the booking is confirmed. - `record` (object): The stored record of the booking, in snake_case. The keys that exist depend on the product; a key that does not apply is absent. - `id` (string (uuid)): Internal id of the booking (UUID). - `booking_id` (string): The public reference of the booking. - `order_id` (string): eSIMs only. The supplier's order code. - `status` (string): State of the booking. - `currency` (string): Currency of the amounts. - `total_price` (number): What the traveler pays, in your agency currency. - `agency_commission` (number): Your commission as an amount. - `departure_airport` (string): Flights only. IATA code of the first departure airport. - `arrival_airport` (string): Flights only. IATA code of the final arrival airport. - `departure_date` (string): Flights only. Local departure date and time of the first segment. - `arrival_date` (string): Flights and transfers. Local arrival date and time. - `return_departure_date` (string, nullable): Flights only. Departure of the return trip, or null. - `return_arrival_date` (string, nullable): Flights only. Arrival of the return trip, or null. - `included_checked_bags` (string, nullable): Flights only. Checked bags included in the fare. - `ticket_number` (string, nullable): Flights only. The ticket number once issued, or null. - `require_acd` (boolean, nullable): Flights only. Whether the booking needs ACD documents. - `hotel_id` (string): Hotels only. The id of the hotel. - `hotel_name` (string): Hotels only. The name of the hotel. - `checkin_date` (string): Hotels only. Check-in date: `YYYY-MM-DD`. - `checkout_date` (string): Hotels only. Check-out date: `YYYY-MM-DD`. - `booking_mode` (string, nullable): Hotels only. How the booking was made. - `free_cancellation_deadline_at` (string, nullable): Hotels only. Until when the booking can be cancelled free of charge, or null. - `type` (string): eSIMs only. `esim` or `topup`. One of `esim`, `topup`. - `package_name` (string): eSIMs only. Name of the package. - `iccid` (string): eSIMs only. The ICCID of the eSIM. - `customer_email` (string): eSIMs only. The traveler's email. - `customer_first_name` (string): eSIMs only. The traveler's first name. - `customer_last_name` (string): eSIMs only. The traveler's last name. - `customer_phone` (string): eSIMs only. The traveler's phone number. - `provider_name` (string): eSIMs and transfers. Name of the supplier. - `supplier_id` (string): Hotels and eSIMs. Id of the supplier, as stored on the booking. - `flight_number` (string, nullable): Transfers only. The flight number. - `bucket_link` (string, nullable): Flights, hotels and transfers. The confirmation PDF with prices; for a transfer, FX-Port's receipt. - `bucket_link_no_price` (string, nullable): Flights and hotels. The confirmation PDF without prices. - `voucher_link` (string, nullable): Transfers only. The supplier voucher, once a driver is assigned. - `refund_amount` (number, nullable): Flights and hotels. The amount refunded, or null. - `refund_currency` (string, nullable): Flights and hotels. The currency of the refund, or null. - `refund_method` (string, nullable): Flights only. How the refund was made, or null. - `refunded_amount` (number, nullable): Transfers only. The amount refunded, or null. - `refunded_currency` (string, nullable): Transfers only. The currency of the refund, or null. - `paid_at` (string, nullable): Flights and hotels. When the booking was paid, or null. - `voided_at` (string, nullable): Flights only. When the ticket was voided, or null. - `created_at` (string): When the booking was created: ISO 8601, UTC. - `updated_at` (string): When the booking last changed: ISO 8601, UTC. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `pricing` (array of object): Flights and hotels only. The price breakdown. - `id` (string (uuid)): Hotels only. Id of the pricing row. - `supplier_currency` (string): Flights only. The currency the supplier prices in. - `agency_currency` (string): The currency of your agency. - `base_price` (number): Flights only. The fare before taxes. - `taxes_and_fees` (number): Flights only. Taxes and fees, without your commission. - `total_price` (number): Hotels only. What the traveler pays. - `agency_commission` (number): Your commission as an amount. - `final_price` (number): Flights only. What the traveler pays: `base_price` plus `taxes_and_fees` plus `agency_commission`. - `b2b_price` (number): The amount debited from your agency balance: the total minus `agency_commission`. - `created_at` (string): When the row was written: ISO 8601, UTC. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `travelerPricing` (array of object, deprecated): Legacy name: use `traveler_pricing`. Flights only. The price per traveler. The older name of `traveler_pricing`: same value, use `traveler_pricing`. - `traveler_id` (string): The traveler the row is for: the id of the traveler in the booking. - `traveler_type` (string): `ADULT`, `CHILD`, `INFANT` and so on. - `fare_option` (string): The fare option, for example `STANDARD`. - `supplier_currency` (string): The currency the supplier prices in. - `agency_currency` (string): The currency of your agency. - `base` (number): The fare of this traveler before taxes. - `total` (number): What this traveler's ticket costs the traveler. - `b2b_price` (number): The amount debited from your agency balance for this traveler. - `created_at` (string): When the row was written: ISO 8601, UTC. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `traveler_pricing` (array of object): Flights only. The price per traveler. The snake_case twin of `travelerPricing`, with the same value: use this name. - `traveler_id` (string): The traveler the row is for: the id of the traveler in the booking. - `traveler_type` (string): `ADULT`, `CHILD`, `INFANT` and so on. - `fare_option` (string): The fare option, for example `STANDARD`. - `supplier_currency` (string): The currency the supplier prices in. - `agency_currency` (string): The currency of your agency. - `base` (number): The fare of this traveler before taxes. - `total` (number): What this traveler's ticket costs the traveler. - `b2b_price` (number): The amount debited from your agency balance for this traveler. - `created_at` (string): When the row was written: ISO 8601, UTC. - `environment` (string): The environment of the key that made the call: `sandbox` for fxp_test_ keys, `live` for fxp_live_ keys. Present in every JSON body. One of `sandbox`, `live`. - `balanceOperations` (array of object, deprecated): Legacy name: use `balance_operations`. Flights and hotels only. The balance operations of the booking: empty in the sandbox. The older name of `balance_operations`: same value, use `balance_operations`. - `id` (string (uuid)): Id of the operation. - `type` (string): `debit`, `credit` or `refund`, in lower case. - `amount` (number): The amount, always positive. - `currency` (string): The currency of the operation. - `description` (string, nullable): A label of the operation. - `createdAt` (string, deprecated): Legacy name: use `created_at`. When the operation was recorded: ISO 8601, UTC. The older name of `created_at`: same value, use `created_at`. - `created_at` (string): When the operation was recorded: ISO 8601, UTC. The snake_case twin of `createdAt`, with the same value: use this name. - `balance_operations` (array of object): Flights and hotels only. The balance operations of the booking: empty in the sandbox. The snake_case twin of `balanceOperations`, with the same value: use this name. - `id` (string (uuid)): Id of the operation. - `type` (string): `debit`, `credit` or `refund`, in lower case. - `amount` (number): The amount, always positive. - `currency` (string): The currency of the operation. - `description` (string, nullable): A label of the operation. - `createdAt` (string, deprecated): Legacy name: use `created_at`. When the operation was recorded: ISO 8601, UTC. The older name of `created_at`: same value, use `created_at`. - `created_at` (string): When the operation was recorded: ISO 8601, UTC. The snake_case twin of `createdAt`, with the same value: use this name. --- # Changelog > What changed in the FX-Port API and in these docs, newest first. Source: https://fx-port.com/docs/changelog New entries are added at the top. A change that could break an integration ships under a new API version (the `/api/v1` prefix) and is announced here first; additive changes, such as a new field or a new accepted spelling, do not change the version. See [Conventions and naming](https://fx-port.com/docs/conventions.md). ## 2026-11-01: new documentation, one naming convention, clearer errors, safer defaults **Nothing was removed.** Every endpoint, request field and response key that worked yesterday works today. The changes below add names, add keys, tell you what to use, and refuse requests that could never have succeeded. **Documentation** - The documentation moved from `docs.fx-port.com` to **fx-port.com/docs**. Every old address redirects permanently to its new one, so links, bookmarks and search results keep working. - A generated **API reference** with every endpoint, its parameters, its responses and examples in nine languages, an **OpenAPI 3.1** file at [/openapi.json](https://fx-port.com/openapi.json), [llms.txt](https://fx-port.com/llms.txt) and [llms-full.txt](https://fx-port.com/llms-full.txt) for AI tools, every page as Markdown (add `.md` to its address), and search. - The Postman collections were regenerated from the same description of the API: see [Environments and Postman](https://fx-port.com/docs/environments.md). - The navigation is grouped by product (Flights, Hotels, eSIMs, Account), each with its stages, in the guides and in the API reference. The hotel search around a point is called [geo search](https://fx-port.com/docs/hotels/search.md) everywhere. - New guides: [Quickstart](https://fx-port.com/docs/quickstart.md), [Conventions and naming](https://fx-port.com/docs/conventions.md), [Legacy names and twins](https://fx-port.com/docs/legacy-names.md), [Errors](https://fx-port.com/docs/errors.md), [Rate limits and fair use](https://fx-port.com/docs/rate-limits.md), [Streaming search results](https://fx-port.com/docs/streaming.md), [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md) and the guides of [get_ticket_number](https://fx-port.com/docs/flights/get-ticket-number.md), [cancel_quote](https://fx-port.com/docs/flights/cancel-quote.md) and [confirm_cancel](https://fx-port.com/docs/flights/confirm-cancel.md). - The [flights](https://fx-port.com/docs/flights.md), [hotels](https://fx-port.com/docs/hotels.md) and [eSIMs](https://fx-port.com/docs/esims.md) guides were rewritten against the live API: corrected claims, real responses, the sandbox test hotels, and the [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md) reference. **Naming: one convention, the old names still work** - `snake_case` is the canonical spelling of every request field. A request that uses an earlier spelling (`searchId`, `offerId`, `bookingType`, `hotelId`, `checkinDate`, `partnerOrderId` ...) is still served. The response then carries a `warnings` entry and the headers `Deprecation`, `Link` and `X-FX-Deprecated-Fields` that name the spelling to use. - Every `camelCase` key of a response stays, and its `snake_case` twin is added next to it with the same value. `?naming=snake` (or the header `X-FX-Naming: snake`) returns the `snake_case` keys only. - `GET /api/v1/conventions/naming` publishes the complete map (no key needed). The [reference](https://fx-port.com/docs/api.md) marks each earlier spelling as deprecated and says what replaces it. **Errors and request ids** - Every error body now has `errors[]` (`code`, `type`, `title`, `message`, `field`, `details`, `documentation_url`), `meta.request_id` and `request_id`, next to the keys it always had. A reply with HTTP `200` and `success: false` has them too. The code is stable: write `if` statements against `errors[0].code`, not against the message. See [Errors](https://fx-port.com/docs/errors.md). - Every response carries `X-Request-Id`. Quote it when you contact support. - A request that could never succeed is refused with `400` and a stable code **before** any supplier is called: an unknown airport code, a date that is impossible or too far away, an absurd passenger count, an unknown value of `cabin_class` or `booking_type`, a hotel stay over 30 nights. These used to come back as `503 all_suppliers_failed`, as `500` or as a `502` that Cloudflare replaced by an HTML page. - `book_flight` answers a failure with JSON (`409`, `422` or `503`) instead of an HTML `502`. - Messages no longer carry the raw text of a supplier, of the database or of the code. **Flights** - [Streaming](https://fx-port.com/docs/streaming.md) is documented: `stream=true` returns the offers supplier by supplier as server-sent events, `environment` comes first, comment lines keep the connection alive, and `done` is always the last event. - `booking_type` and `cabin_class` are validated. An unknown `booking_type` used to create a paid instant order with Duffel. - `flexible_search` with no supplier able to answer returns `422 no_supplier_supports_flexible_search` instead of `503`. - `failures[]` of a search says why a supplier failed in `cause` (`supplier_timeout`, `supplier_api_error` ...). - `get_booking` adds `acd_required`, `requires_acd`, a boolean `acd_submitted`, `pdf_url` and `pdf_url_no_price`. - ACD uploads: `upload_acd_documents` reads the form after authentication, answers `415` to anything but `multipart/form-data`, and has the new codes `INVALID_MULTIPART` and `INVALID_BOOKING_ID`. The existing codes and the limits (10 MiB a file, 25 MiB a request) are unchanged. **Hotels** - [Mini and full hotel data](https://fx-port.com/docs/hotels/hotel-data.md): `get_hotel_info` and `hotels/details` accept `detail_level` (`minimal`, `mini` or `full`, default `full`) and `images_limit`. A mini card is about eight times smaller than the full document: use it for search results and read the full document for the hotel page. - In the sandbox, only the supplier's test hotels can be booked ([Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md)). `pre_book` of any other hotel answers `409 hotel_not_bookable_in_sandbox` instead of a success that could never be booked. - `pre_book` answers the same request within 60 seconds with the same result, and `book_hotel` refuses a second booking of the same `booking_id` with `409 duplicate_booking` and does not call the supplier again. - `search_advanced` answers at most 5,000 hotels and says `truncated` when it cut the list. An unknown hotel is `404 hotel_not_found` (it was a `502`). Supplier cost data and debug blocks are no longer in the answers to API keys. **eSIMs** - Usage, instructions, top-up packages and top-ups work for the eSIMs of your agency and environment only. Any other ICCID answers `404 iccid_not_found` before the supplier is called. - `setup-url` returns the install link only. The `search` of the eSIM list no longer fails on special characters, and `usage/bulk` takes up to 50 ICCIDs and answers one entry per ICCID. - An order or a top-up that the supplier refuses answers `supplier_error` with a fixed message, and no balance is debited. **Security** - A booking, a search or an eSIM of another agency answers the same `404` as one that does not exist, on every endpoint. Nothing tells a caller that an identifier exists. - A request body has a size limit: 64 KB for a search or a price, 256 KB for any other JSON, 26 MB for an ACD upload. A larger body answers `413 request_too_large`. - A key that is not shaped like an FX-Port key (`fxp_live_` or `fxp_test_` and 16 to 64 letters and digits) answers `401 invalid_api_key_format` at once. A burst of requests with unknown keys from one address is slowed down, and never affects a valid key. See [Rate limits and fair use](https://fx-port.com/docs/rate-limits.md). - Internal data (net fares, commissions, internal ids) is no longer returned by `get_booking`, `get_ticket_number` and `include_bags`. **Limits and headers** - Every response of an API key has `ratelimit-limit`, `ratelimit-remaining`, `ratelimit-reset` and `ratelimit-policy`, and every `429` and `503` has `Retry-After`. New codes: `concurrency_limit_exceeded`, `auth_rate_limited`, `service_overloaded`, `supplier_busy`. - Search and hotel-content endpoints also limit how many calls of one key run at the same time. The ceilings are far above what an interactive application needs and run in observation mode first: nothing is refused because of them until we announce it here. ## 2026-10-25: ACD passport uploads **API** - `POST /api/v1/flights/get_acd_documents` tells you how many passports an ACD booking needs and which travelers already have one. - `POST /api/v1/flights/upload_acd_documents` adds one passport, several or all of them, and replaces a passport that was already sent. See [ACD passport uploads](https://fx-port.com/docs/flights/acd-documents.md). ## 2026-10-08: void window anchored to the payment day **API** - `booking.voidableUntil` is now computed from the day the booking was paid, not from the day it was created. See [Get a flight booking](https://fx-port.com/docs/flights/get-booking.md). ## 2026-08-27: security, eSIMs and hotels **Documentation** - The [Security](https://fx-port.com/docs/security.md) guide, the [eSIMs](https://fx-port.com/docs/esims.md) guides and the first [hotels](https://fx-port.com/docs/hotels.md) guides. ## 2026-08-17: first public documentation **API** - Flights: search, flexible-date search, price, book (issue or hold), quote, pay, cancel a held booking, get a booking, get the ticket PDF, airports. - Suppliers and account: the list of suppliers, balances, the balance ledger, booking history and booking detail. - Self-serve API keys with Read and Read+Write permissions, for the sandbox (`fxp_test_`) and for live (`fxp_live_`), on one base URL.