Book a flight
Create a booking, either an instant issue or a hold reservation. Requires Read+Write permission.
POST/api/v1/flights/book_flight
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.
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. 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.
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. |
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
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": "[email protected]",
"phone_number": "+15555550123"
}
}'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": "[email protected]",
"phone_number": "+15555550123"
}
}'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": "[email protected]",
"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.
{
"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": "[email protected]",
"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):
{
"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": "[email protected]",
"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 and Pay a held booking, or release the hold with Cancel a held booking.
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. |
postProcessing.bookingDbId | The FX-Port id of the booking: the id that get_booking 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. |
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 until booking.bucket_link has a value, or call get_ticket 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(withpnr). A booking that fails releases the offer. - Duffel books one offer per search. A second Duffel booking from the same
search_idfails with409 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 statusfailed; 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 for the body of an error.