DubaiLand Travel API
Flight and hotel inventory for businesses. Search, book and ticket through one API, settled against a credit balance — no supplier contract, no card processing, no PCI scope of your own.
Quickstart
Three calls from nothing to a confirmed order. Every example works against sandbox.
# 1. Search — take an offer id from data.offers
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
-X POST https://api.dubailandtravel.com/partner/v1/flights/search \
-H 'Content-Type: application/json' \
-d '{"slices":[{"origin":"BGW","destination":"DXB","date":"2026-12-12"}],"passengers":{"adults":1}}'
# 2. Confirm the price is still real — take data.price_confirmation_token
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
-X POST https://api.dubailandtravel.com/partner/v1/flights/offers/$OFFER_ID/confirm-price
# 3. Order it, with the offer id and the token
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
-X POST https://api.dubailandtravel.com/partner/v1/orders \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d @order.json
order.json:
{
"items": [
{
"type": "flight",
"offer_id": "{{offer_id}}",
"price_confirmation_token": "{{price_confirmation_token}}"
}
],
"passengers": [
{
"type": "ADT",
"first_name": "Dana",
"last_name": "Aziz",
"date_of_birth": "1990-04-02",
"gender": "F",
"nationality": "IQ",
"is_lead": true,
"document": {
"type": "passport",
"number": "A1234567",
"issuing_country": "IQ",
"expires_on": "2031-11-12"
}
}
],
"contact": {
"email": "traveller@example.com",
"phone": "+9647701234567"
},
"partner_reference": "your-own-id"
}
Skip the reading
Everything on this page, in the form your tools want. All four are generated from the running API, so they cannot describe an endpoint that does not exist.
| For | Get it | Then |
|---|---|---|
| Postman, Insomnia, Bruno | Postman collection | Import it, set client_id and client_secret on the collection, send. Every request is pre-filled. |
| Code generators and SDKs | OpenAPI 3.1 (JSON) | Feed it to openapi-generator, or to your editor for autocomplete. |
| Claude, ChatGPT, Cursor, Copilot | Reference (Markdown) | Paste it into the chat, or give the assistant this URL, and ask it to write your integration. |
| Claude Code and other agents | AI skill (SKILL.md) | Save it as .claude/skills/dubailand-travel-api/SKILL.md in your project. The agent then knows this API whenever it is relevant. |
AI tools that look for it will also find /llms.txt.
confirm-price immediately before ordering — an order whose
offer has expired or whose price confirmation no longer matches is refused (for a
flight TB-FLIGHT-1402, TB-BOOK-1701 or
TB-FLIGHT-1406; for a room TB-HOTEL-1502), not silently
repriced. We will never charge you more than the price you confirmed.
Authentication
HTTP Basic: your client id as the username, your secret as the password. A bearer
token of the form client_id:secret works identically if your HTTP
stack makes Basic awkward.
Authorization: Basic {base64(client_id:secret)}
The secret is shown once, when the key is issued, and is stored hashed — we cannot read it back to you. Lost secrets are replaced by issuing a new key, and both work until you revoke the old one, so a rotation needs no downtime.
Sandbox and live
| Environment | Key prefix | Reaches a supplier | Moves money |
|---|---|---|---|
| Sandbox | sk_test_… | No | No |
| Live | sk_live_… | Yes | Yes |
The environment is a property of the key, not a header or a base URL. There is no
flag to forget: a sandbox key cannot reach real inventory even if you point it at
production, and every successful response echoes meta.environment so a
support ticket answers its own first question.
TB-PARTNER-3004. Sandbox keys work from anywhere.
Endpoints
Base URL https://api.dubailandtravel.com/partner/v1.
Open an endpoint for what to send and what comes back.
GET /partner/language/{locale}
Scope —
No request body.
POST /flights/search Search flights
Returns offers already priced for the calling partner. A supplier failing degrades the search rather than failing it.
Scope flights.search
Request body
| Field | Type | Required |
|---|---|---|
| trip_type | string: one_way, round_trip, multi_city | — |
| slices | array | Yes |
| slices[].origin | string | Yes |
| slices[].destination | string | Yes |
| slices[].date | string | Yes |
| passengers | object | Yes |
| passengers.adults | integer | Yes |
| passengers.children | integer | — |
| passengers.infants | integer | — |
| cabin | string: ECONOMY, PREMIUM_ECONOMY, BUSINESS, FIRST | — |
| preferences | object | — |
| preferences.non_stop_only | boolean | — |
| preferences.airlines | array | — |
| preferences.exclude_airlines | array | — |
| preferences.max_price | integer | — |
| currency | string | — |
Example
{
"slices": [
{
"origin": "BGW",
"destination": "DXB",
"date": "2026-12-12"
}
],
"passengers": {
"adults": 1
}
}
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offers": [
{
"id": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"base": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"taxes": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"validating_carrier": "string",
"cabin": "string",
"fare_type": "string",
"slices": [
"string"
],
"passengers": [
"string"
],
"conditions": {
"refundable": true,
"changeable": true,
"seats_remaining": 0,
"last_ticketing_date": "string"
},
"baggage": {},
"expires_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"search_id": "string",
"count": 0,
"cache_hit": "string",
"degraded": true
}
}
GET /flights/search/{searchId}/results Read a held search
Cheap — the offers were computed once. Returns every offer in the search, with no paging. Reading it again never costs another supplier call or consumes the search rate limit.
Scope flights.results
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offers": [
{
"id": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"base": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"taxes": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"validating_carrier": "string",
"cabin": "string",
"fare_type": "string",
"slices": [
"string"
],
"passengers": [
"string"
],
"conditions": {
"refundable": true,
"changeable": true,
"seats_remaining": 0,
"last_ticketing_date": "string"
},
"baggage": {},
"expires_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"search_id": "string",
"count": 0
}
}
POST /flights/offers/{offerId}/confirm-price Confirm a price
Mandatory before an order. Airfare moves between search and booking often enough that ordering from a held price is a chargeback waiting to happen — and in B2B it would be the partner absorbing it, which is worse. Returns a token bound to the offer and its final amount. Ordering without one is refused.
Scope flights.price
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offer": {
"id": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"base": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"taxes": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"validating_carrier": "string",
"cabin": "string",
"fare_type": "string",
"slices": [
"string"
],
"passengers": [
"string"
],
"conditions": {
"refundable": true,
"changeable": true,
"seats_remaining": 0,
"last_ticketing_date": "string"
},
"baggage": {},
"expires_at": "string"
},
"price_changed": true,
"previous_total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"price_confirmation_token": "string"
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string"
}
}
POST /hotels/search Search hotels
Returns rates already priced for the calling partner. A supplier failing degrades the search rather than failing it.
Scope hotels.search
Request body
| Field | Type | Required |
|---|---|---|
| city_code | string | — |
| hotel_id | string | — |
| latitude | number | — |
| longitude | number | — |
| radius_km | integer | — |
| check_in | string | Yes |
| check_out | string | Yes |
| rooms | integer | — |
| adults | integer | — |
| children_ages | array | — |
| currency | string | — |
| min_stars | integer | — |
| board_type | string: room_only, breakfast, half_board, full_board, all_inclusive | — |
Example
{
"city_code": "DXB",
"check_in": "2026-12-12",
"check_out": "2026-12-15",
"rooms": 1,
"adults": 2
}
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offers": [
{
"id": "string",
"property": {
"id": "string",
"name": "string",
"star_rating": 0,
"city_code": "string",
"address": "string",
"latitude": 0,
"longitude": 0,
"amenities": [
"string"
],
"images": [
"string"
]
},
"room": {
"type_code": "string",
"description": {},
"board_type": "string",
"rate_plan": "string"
},
"stay": {
"check_in": "string",
"check_out": "string",
"nights": 0,
"rooms": 0,
"adults": 0,
"children_ages": [
"integer"
]
},
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"taxes_and_fees": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"per_night": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"payment_policy": "string",
"collected_by_property": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"payable_now": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"cancellation": {
"refundable": true,
"free_until": "string",
"penalty": {
"amount": "integer",
"currency": "string",
"decimals": "integer",
"formatted": "string"
},
"tiers": [
{}
],
"description": "string"
},
"expires_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"search_id": "string",
"count": "string",
"cache_hit": "string",
"expires_at": "string",
"degraded": true
}
}
GET /hotels/search/{searchId}/results Read a held search
Cheap — the rates were computed once. Returns every rate in the search, with no paging. Reading it again never costs another supplier call or consumes the search rate limit.
Scope hotels.results
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offers": [
{
"id": "string",
"property": {
"id": "string",
"name": "string",
"star_rating": 0,
"city_code": "string",
"address": "string",
"latitude": 0,
"longitude": 0,
"amenities": [
"string"
],
"images": [
"string"
]
},
"room": {
"type_code": "string",
"description": {},
"board_type": "string",
"rate_plan": "string"
},
"stay": {
"check_in": "string",
"check_out": "string",
"nights": 0,
"rooms": 0,
"adults": 0,
"children_ages": [
"integer"
]
},
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"taxes_and_fees": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"per_night": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"payment_policy": "string",
"collected_by_property": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"payable_now": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"cancellation": {
"refundable": true,
"free_until": "string",
"penalty": {
"amount": "integer",
"currency": "string",
"decimals": "integer",
"formatted": "string"
},
"tiers": [
{}
],
"description": "string"
},
"expires_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"search_id": "string",
"count": 0
}
}
POST /hotels/offers/{offerId}/confirm-price Confirm a rate
Mandatory before an order, and for more than the price: a cancellation policy that changed between search and booking is a promise we would otherwise make and not keep — and in B2B it is the partner who would have made it to their customer. Returns a token bound to the offer and its final amount. Ordering without one is refused.
Scope hotels.price
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offer": {
"id": "string",
"property": {
"id": "string",
"name": "string",
"star_rating": 0,
"city_code": "string",
"address": "string",
"latitude": 0,
"longitude": 0,
"amenities": [
"string"
],
"images": [
"string"
]
},
"room": {
"type_code": "string",
"description": {},
"board_type": "string",
"rate_plan": "string"
},
"stay": {
"check_in": "string",
"check_out": "string",
"nights": 0,
"rooms": 0,
"adults": 0,
"children_ages": [
0
]
},
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"taxes_and_fees": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"per_night": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"payment_policy": "string",
"collected_by_property": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"payable_now": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"cancellation": {
"refundable": true,
"free_until": "string",
"penalty": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"tiers": [
{
"from": "string",
"amount_minor": "integer"
}
],
"description": "string"
},
"expires_at": "string"
},
"price_changed": true,
"previous_total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"policy_changed": true,
"price_confirmation_token": "string"
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string"
}
}
POST /transfers/search Quote a transfer
Returns every vehicle an operator will put on the journey, already priced for the calling partner and already filtered to those that can actually carry the party.
Scope transfers.search
Request body
| Field | Type | Required |
|---|---|---|
| origin_id | string | Yes |
| destination_id | string | Yes |
| pickup_at | string | Yes |
| return_at | string | — |
| adults | integer | Yes |
| children | integer | — |
| infants | integer | — |
| bags | integer | — |
| flight_number | string | — |
| currency | string | — |
Example
{
"origin_id": "{{origin_id}}",
"destination_id": "{{destination_id}}",
"pickup_at": "2026-12-12 14:30",
"adults": 2
}
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offers": [
{
"id": "string",
"journey": {
"origin": {
"id": "string",
"name": "string",
"type": "string",
"address": "string",
"city_name": "string",
"country_code": "string",
"iata_code": "string",
"latitude": "number",
"longitude": "number"
},
"destination": {
"id": "string",
"name": "string",
"type": "string",
"address": "string",
"city_name": "string",
"country_code": "string",
"iata_code": "string",
"latitude": "number",
"longitude": "number"
},
"pickup_at": "string",
"pickup_timezone": "string",
"return_pickup_at": "string",
"duration_minutes": 0,
"distance_km": 0
},
"vehicle": {
"class": "string",
"description": {},
"max_passengers": 0,
"max_bags": 0,
"inclusions": [
"string"
]
},
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"payment_policy": "string",
"collected_by_driver": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"payable_now": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"cancellation": {
"refundable": true,
"free_until": "string",
"penalty": {
"amount": "integer",
"currency": "string",
"decimals": "integer",
"formatted": "string"
},
"description": "string"
},
"expires_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"search_id": "string",
"count": "string",
"cache_hit": "string",
"expires_at": "string",
"degraded": true,
"unserved": "string"
}
}
GET /transfers/search/{searchId}/results Read a held quote
Cheap — the quotes were computed once. Re-reading a result set a partner already holds must never cost another operator call, and must never consume their search rate limit either.
Scope transfers.results
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"offers": [
{
"id": "string",
"journey": {
"origin": {
"id": "string",
"name": "string",
"type": "string",
"address": "string",
"city_name": "string",
"country_code": "string",
"iata_code": "string",
"latitude": "number",
"longitude": "number"
},
"destination": {
"id": "string",
"name": "string",
"type": "string",
"address": "string",
"city_name": "string",
"country_code": "string",
"iata_code": "string",
"latitude": "number",
"longitude": "number"
},
"pickup_at": "string",
"pickup_timezone": "string",
"return_pickup_at": "string",
"duration_minutes": 0,
"distance_km": 0
},
"vehicle": {
"class": "string",
"description": {},
"max_passengers": 0,
"max_bags": 0,
"inclusions": [
"string"
]
},
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"currency": "string",
"payment_policy": "string",
"collected_by_driver": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"payable_now": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"cancellation": {
"refundable": true,
"free_until": "string",
"penalty": {
"amount": "integer",
"currency": "string",
"decimals": "integer",
"formatted": "string"
},
"description": "string"
},
"expires_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"search_id": "string",
"count": 0
}
}
GET /transfers/locations Places we can be driven to
Transfers are quoted between identifiers from an operator's catalogue, not between addresses — so without this endpoint a partner has nothing valid to put in a search. It is part of the product rather than a convenience.
Scope transfers.locations
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"locations": [
{}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"count": 0
}
}
POST /orders Place an order
Requires an `Idempotency-Key`. Draws the total from the partner's balance, creates the order with the supplier, and returns the confirmation. A refusal at the supplier releases the hold immediately — the partner is never out of pocket for a booking that did not happen. One item per order; add a room or a car afterwards with the hotels and transfers endpoints of the order. A 402 means the balance and credit limit together do not cover it and the account is not set to wait for approval; the body states the shortfall. A 409 `TB-PARTNER-3019` means the supplier did not say whether it created the order: the hold stays and the order must not be placed again. When the balance and credit limit do not cover the order, our team may still approve it: the answer is then a 409 `TB-PARTNER-3020` with status `pending_approval`, and the order must not be placed again. Nothing is charged meanwhile. Its outcome arrives as `order.confirmed`, or as `order.failed` (`TB-PARTNER-3001`) if it is rejected or not decided in time; you may cancel it while it waits.
Scope orders.create · Idempotency-Key header required
Request body
| Field | Type | Required |
|---|---|---|
| items | array | Yes |
| items[].type | string: flight, hotel, transfer | Yes |
| items[].offer_id | string | Yes |
| items[].price_confirmation_token | string | Yes |
| passengers | array | — |
| passengers[].type | string: ADT, CHD, INF | — |
| passengers[].first_name | string | Yes |
| passengers[].last_name | string | Yes |
| passengers[].date_of_birth | string | — |
| passengers[].gender | string: M, F, X | — |
| passengers[].nationality | string | — |
| passengers[].is_lead | boolean | — |
| passengers[].document | object | — |
| passengers[].document.type | string | — |
| passengers[].document.number | string | — |
| passengers[].document.issuing_country | string | — |
| passengers[].document.expires_on | string | — |
| passenger_count | integer | — |
| guests | array | — |
| guests[].first_name | string | — |
| guests[].last_name | string | — |
| guests[].is_lead | boolean | — |
| special_requests | string | — |
| flight_number | string | — |
| bags | integer | — |
| estimated_arrival_time | string | — |
| contact | object | Yes |
| contact.email | string | Yes |
| contact.phone | string | Yes |
| contact.name | string | — |
| partner_reference | string | — |
Example
{
"items": [
{
"type": "flight",
"offer_id": "{{offer_id}}",
"price_confirmation_token": "{{price_confirmation_token}}"
}
],
"passengers": [
{
"type": "ADT",
"first_name": "Dana",
"last_name": "Aziz",
"date_of_birth": "1990-04-02",
"gender": "F",
"nationality": "IQ",
"is_lead": true,
"document": {
"type": "passport",
"number": "A1234567",
"issuing_country": "IQ",
"expires_on": "2031-11-12"
}
}
],
"contact": {
"email": "traveller@example.com",
"phone": "+9647701234567"
},
"partner_reference": "your-own-id"
}
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"order": {
"reference": "string",
"partner_reference": "string",
"status": "string",
"type": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"charged_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"contact": {
"email": "string",
"phone": "string",
"name": "string"
},
"passengers": [
{
"first_name": "string",
"last_name": "string",
"type": "string",
"document_last4": "string"
}
],
"items": [
{
"id": 0,
"type": "string",
"status": "string",
"total": {
"amount": "integer",
"currency": "string"
},
"hotel": "string",
"confirmation_number": "string",
"check_in": "string",
"check_out": "string"
}
],
"travel_starts_at": "string",
"travel_starts_at_local": "string",
"travel_timezone": "string",
"created_at": "string",
"ticketing_deadline_at": "string"
}
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
}
}
}
GET /orders List orders
Scope orders.read
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"orders": [
{
"reference": "string",
"partner_reference": "string",
"status": "string",
"type": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"charged_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"contact": {
"email": "string",
"phone": "string",
"name": "string"
},
"passengers": [
{
"first_name": "string",
"last_name": "string",
"type": "string",
"document_last4": "string"
}
],
"items": [
{
"id": "integer",
"type": "string",
"status": "string",
"total": {},
"hotel": "string",
"confirmation_number": "string",
"check_in": "string",
"check_out": "string"
}
],
"travel_starts_at": "string",
"travel_starts_at_local": "string",
"travel_timezone": "string",
"created_at": "string",
"ticketing_deadline_at": "string"
}
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"page": 0,
"per_page": 0,
"total": 0
}
}
GET /orders/{reference} Read an order
Scope orders.read
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"order": {
"reference": "string",
"partner_reference": "string",
"status": "string",
"type": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"charged_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"contact": {
"email": "string",
"phone": "string",
"name": "string"
},
"passengers": [
{
"first_name": "string",
"last_name": "string",
"type": "string",
"document_last4": "string"
}
],
"items": [
{
"id": 0,
"type": "string",
"status": "string",
"total": {
"amount": "integer",
"currency": "string"
},
"hotel": "string",
"confirmation_number": "string",
"check_in": "string",
"check_out": "string"
}
],
"travel_starts_at": "string",
"travel_starts_at_local": "string",
"travel_timezone": "string",
"created_at": "string",
"ticketing_deadline_at": "string"
}
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string"
}
}
POST /orders/{reference}/cancel Cancel an order
Returns the penalty the supplier actually charged and credits the remainder back to the partner's balance. On a prepaid model a refund is a ledger entry rather than a gateway call that can fail, which is the single biggest operational advantage of settling this way. An order still `pending_approval` was never charged: cancelling it closes it with no penalty and nothing to refund.
Scope orders.cancel · Idempotency-Key header required
Request body
| Field | Type | Required |
|---|---|---|
| reason | string | — |
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"order": {
"reference": "string",
"partner_reference": "string",
"status": "string",
"type": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"charged_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"contact": {
"email": "string",
"phone": "string",
"name": "string"
},
"passengers": [
{
"first_name": "string",
"last_name": "string",
"type": "string",
"document_last4": "string"
}
],
"items": [
{
"id": 0,
"type": "string",
"status": "string",
"total": {
"amount": "integer",
"currency": "string"
},
"hotel": "string",
"confirmation_number": "string",
"check_in": "string",
"check_out": "string"
}
],
"travel_starts_at": "string",
"travel_starts_at_local": "string",
"travel_timezone": "string",
"created_at": "string",
"ticketing_deadline_at": "string"
},
"refunded_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"penalty": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
}
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
}
}
}
POST /orders/{reference}/hotels Add a hotel to an order
Requires an `Idempotency-Key`: this changes what the partner owes, and a retry must not book the room twice. The room is reserved and confirmed on its own. A property refusing leaves the flight exactly as it was — that is the whole reason items carry independent lifecycles — and comes back as a 409 on the room rather than as a failure of the order.
Scope orders.add_hotel · Idempotency-Key header required
Request body
| Field | Type | Required |
|---|---|---|
| offer_id | string | Yes |
| price_confirmation_token | string | Yes |
| special_requests | string | — |
| estimated_arrival_time | string | — |
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"order": {
"reference": "string",
"partner_reference": "string",
"status": "string",
"type": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"charged_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"contact": {
"email": "string",
"phone": "string",
"name": "string"
},
"passengers": [
{
"first_name": "string",
"last_name": "string",
"type": "string",
"document_last4": "string"
}
],
"items": [
{
"id": 0,
"type": "string",
"status": "string",
"total": {
"amount": "integer",
"currency": "string"
},
"hotel": "string",
"confirmation_number": "string",
"check_in": "string",
"check_out": "string"
}
],
"travel_starts_at": "string",
"travel_starts_at_local": "string",
"travel_timezone": "string",
"created_at": "string",
"ticketing_deadline_at": "string"
}
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
}
}
}
POST /orders/{reference}/transfers Add a transfer to an order
Requires an `Idempotency-Key`: this changes what the partner owes, and a retry must not put a second car on the road. The car is booked on its own. An operator refusing leaves the flight exactly as it was and comes back as a 410 on the transfer rather than a failure of the order.
Scope orders.add_transfer · Idempotency-Key header required
Request body
| Field | Type | Required |
|---|---|---|
| offer_id | string | Yes |
| flight_number | string | — |
| passengers | integer | — |
| bags | integer | — |
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"order": {
"reference": "string",
"partner_reference": "string",
"status": "string",
"type": "string",
"total": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"charged_to_balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
},
"contact": {
"email": "string",
"phone": "string",
"name": "string"
},
"passengers": [
{
"first_name": "string",
"last_name": "string",
"type": "string",
"document_last4": "string"
}
],
"items": [
{
"id": 0,
"type": "string",
"status": "string",
"total": {
"amount": "integer",
"currency": "string"
},
"hotel": "string",
"confirmation_number": "string",
"check_in": "string",
"check_out": "string"
}
],
"travel_starts_at": "string",
"travel_starts_at_local": "string",
"travel_timezone": "string",
"created_at": "string",
"ticketing_deadline_at": "string"
}
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"balance": {
"amount": 0,
"currency": "string",
"decimals": 0,
"formatted": "string"
}
}
}
GET /account The account at a glance
`balances` is null on a sandbox key, which moves no money, rather than a zero that would read as "out of funds".
Scope account.read
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"partner": {
"name": "string",
"status": "string",
"account_id": "string"
},
"credential": {
"label": "string",
"environment": "string",
"scopes": [
"string"
],
"expires_at": "string"
},
"services": [
"string"
],
"usage_this_period": {
"period": "string",
"calls": 0,
"remaining": 0
},
"balances": [
"string"
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string"
}
}
GET /account/usage Usage for a closed or current period
Scope account.usage
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"period": "string",
"calls": 0,
"billable_calls": 0,
"overage_calls": 0,
"errors": 0,
"p95_duration_ms": 0
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string",
"billable": true
}
}
GET /account/statement Every movement on the account in a period, with a running balance
The running balance is what makes this reconcilable line by line rather than only in total — a partner whose closing figure disagrees with ours needs to find WHERE, not merely THAT. Live keys only: a sandbox key moves no money, so it has no statement and is refused with 403 `TB-PARTNER-3009`.
Scope account.statement
No request body.
Response
The shape of a successful reply. Values are placeholders.
{
"data": {
"period": "string",
"currency": "string",
"opening_balance": {},
"closing_balance": {},
"credits": {},
"debits": {},
"entries": [
"string"
]
},
"meta": {
"request_id": "string",
"timestamp": "string",
"environment": "string"
}
}
Generated from the running API: the fields, the scope each key is checked against and the response shapes are read from the code, not written here by hand.
Responses
Two shapes, and only two. Success:
{
"data": { … },
"meta": { "request_id": "…", "timestamp": "…", "environment": "sandbox" }
}
Failure, as an RFC 9457 problem document:
{
"type": "https://…/errors/TB-PARTNER-3001",
"title": "Insufficient balance",
"detail": "This booking exceeds the available balance and credit limit on this account.",
"status": 402,
"code": "TB-PARTNER-3001",
"action": "top_up",
"retryable": false,
"request_id": "…"
}
code and action, never on the message.
Messages are localised into Arabic, English and Kurdish Sorani and will be reworded.
Codes are permanent. action tells your integration what to do without
it having to know what every code means.
Idempotency
Every endpoint that books or moves money requires an Idempotency-Key
header — a value you generate, unique per logical operation: 8 to 64 characters of
letters, digits, ., _, : or -.
A UUID fits. Keys are yours alone: they are scoped to your account and to sandbox
or live.
Replaying a key returns the original response rather than repeating the work — including an error response, so a refused order stays refused. This is what makes a network timeout safe to retry: without it, a request that timed out after we created a booking would create a second one when you tried again, and you would be charged for both.
Keys are held for 24 hours; after that the same key runs the request again. Reuse one with a different body and the call is refused.
Webhooks
An order's state can change after the request that created it: a ticket issues, an order that was under review is resolved, an order is cancelled. A webhook tells your server when that happens, so you do not have to poll your orders.
Webhooks are an add-on to your account, sold separately from bookings. Your account
manager turns them on and registers your endpoint: an https address on
the public internet, for one of your keys. You receive its signing secret once,
when it is added or replaced. An endpoint on a sandbox key receives sandbox orders
only, and one on a live key receives live orders only; the envelope's
environment says which.
| Event | Meaning |
|---|---|
| order.confirmed | The supplier accepted the order. An order with more than one item can confirm partially: status is then partially_confirmed, and the order itself says which items were booked. |
| order.ticketed | Ticket numbers exist. This is the point at which the passenger can travel. |
| order.failed | The order was not booked: the supplier refused it, or it was over your credit limit and our team rejected it or did not decide in time (error_code TB-PARTNER-3001). Nothing was created and any credit hold has been released. |
| order.cancelled | The order was cancelled. Any refund is a credit on your statement; the cancel response itself carries the penalty applied. An order cancelled while waiting for approval was never charged. |
| order.needs_attention | The order is ambiguous at the supplier and a human is resolving it. The credit hold is KEPT. Do not retry. |
An endpoint receives the events you ask for, or every event — including any added later — if you ask for none in particular.
Every delivery is a POST of this envelope:
{
"id": "…", // unique per delivery; the same on every retry of it
"type": "order.confirmed",
"environment": "live", // or "sandbox"
"sent_at": "2026-11-07T09:30:00+00:00",
"data": {
"order_reference": "TB-…",
"status": "confirmed",
"partner_reference": "…", // when you sent one
"total": { … },
"airline_reference": "…", // flights, once a PNR exists
"ticket_numbers": [ … ], // once tickets exist
"attention_reason": "…", // when the order is under review
"occurred_at": "…"
}
}
The payload is a nudge, not the order. Read the order back with
GET /orders/{reference} for anything else, such as which items of a
partially confirmed order were booked. A cancellation's penalty is in the response
to POST /orders/{reference}/cancel, and any refund is a credit line on
your statement.
Each delivery also carries the headers X-Event-Type,
X-Delivery-Id and X-Attempt.
Verifying the signature
Every delivery carries X-Signature: HMAC-SHA256 of the
raw request body using your endpoint secret. Verify it before you
trust a payload — a webhook URL is otherwise an unauthenticated endpoint anyone
who learns it can post fabricated order confirmations to.
// Raw bytes, not the parsed JSON: re-encoding changes key order and
// unicode escaping, and the signature will not match.
$expected = hash_hmac('sha256', $request->getContent(), $endpointSecret);
if (! hash_equals($expected, $request->header('X-Signature'))) {
abort(400);
}
Retries
A 5xx, a timeout or a refused connection is retried with backoff at
10 seconds, 1 minute, 10 minutes and 1 hour. A 4xx is
not retried — an unchanged body will be rejected identically.
After twenty failed attempts in a row the endpoint is switched off. Events that
happen while it is off are not stored or sent later: once your account manager
switches it back on, read back the orders you have open to catch up.
id. We retry, so the same event can
arrive twice. Treat the envelope's id as seen-or-not and you will
never act on one twice.
Try it
Make a real call from this page. Your credentials stay in this browser tab — the request goes straight to the endpoint from here, and nothing is stored or proxied.
sk_test_ key. A live key typed into a page is a live key in your browser
history, and sandbox answers every question you have at this stage.
The response appears here.
Sandbox scenarios
You can reach the happy path by accident. What you cannot exercise without help is the handful of ways an order fails — and those are exactly the paths your support team will live in. Set a passenger's or guest's surname to a trigger:
| Surname | What happens |
|---|---|
| SANDBOXSOLDOUT | The supplier refuses the order. Nothing is created; on a live key the credit hold would be released. |
| SANDBOXPRICECHANGE | The price changed between pricing and ordering. The order is refused. |
| SANDBOXTIMEOUT | The supplier never answers. The order goes to manual review and is answered with TB-PARTNER-3019; on a live key the credit hold would be KEPT. Do not place it again. |
| SANDBOXNOTICKET | Flights only: the order confirms with a PNR, but no ticket is issued automatically. |
| SANDBOXPENALTY | Cancelling this order costs a penalty instead of being free. |
| SANDBOXOVERLIMIT | The order is over your credit limit and waits for our approval: answered with 409 TB-PARTNER-3020 and status pending_approval. Nobody approves a sandbox order: cancel it, or after 10 minutes it fails with order.failed (error_code TB-PARTNER-3001). Do not place it again. |
SANDBOXTIMEOUT before you go live.
It is the only outcome where the money is neither spent nor free, and an
integration that retries blindly on it will double-book a real passenger.
Rate limits
Requests are rate limited per key, and search carries its own tighter limit because every miss costs a supplier call. Reading back a held result set does not touch it — fetching your own results again is not a new search.
A 429 carries Retry-After; honour it rather than
tightening your retry loop. If your traffic needs a higher limit, ask your account
manager.
Balance and statements
Orders settle against a USD credit balance you top up in advance by bank transfer — there is no card in the booking path. A booking reserves before it commits and releases if the supplier refuses, so you are never out of pocket for an order that never existed.
409 TB-PARTNER-3020 and the order has status
pending_approval: nothing is charged and no supplier is called while our
team decides, for up to 10 minutes. Do not place it again. If it is
approved you receive order.confirmed; if it is rejected or nobody decides
in time, order.failed with error_code
TB-PARTNER-3001. You can cancel it while it waits, at no cost. Try it in
sandbox with the surname SANDBOXOVERLIMIT. If your account is set to refuse
at once instead, you get 402 TB-PARTNER-3001 as before. A room or car added to an existing order
is always refused with that 402 when it goes over the limit; it never waits.
GET /account returns your balance and your usage.
GET /account/statement returns every movement for a month with a
running balance, so a disagreement can be traced to a line rather than a total.
Versioning
partner/v1 is the contract. Adding a field, an endpoint or an error code does
not move it — so parse leniently: ignore fields you do not recognise rather
than failing on them. Anything that would break a working integration gets a new version and
an overlap, announced in advance.
Error catalogue
Every failure this API can return, grouped by what it is about, with the status it returns and the action your integration should take. Copy shown in English; the same codes carry Arabic and Kurdish Sorani.
Requests 12 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-CORE-1001 | 400 | correct_input | No | We could not read that request |
| TB-CORE-1002 | 422 | correct_input | No | Some details need correcting |
| TB-CORE-1003 | 404 | none | No | Not found |
| TB-CORE-1004 | 403 | none | No | Not allowed |
| TB-CORE-1005 | 429 | wait | Yes | Too many requests |
| TB-CORE-1007 | 426 | update_app | No | Please update the app |
| TB-CORE-1008 | 400 | correct_input | No | Missing request identifier |
| TB-CORE-1009 | 409 | none | No | This request was already submitted |
| TB-CORE-1010 | 409 | wait | Yes | Still working on that |
| TB-CORE-1011 | 400 | correct_input | No | Currency not available |
| TB-CORE-1012 | 400 | correct_input | No | Language not available |
| TB-CORE-1013 | 405 | none | No | Not supported |
Authentication 6 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-AUTH-1102 | 401 | reauthenticate | No | Your session has expired |
| TB-AUTH-1105 | 403 | contact_support | No | This account is suspended |
| TB-AUTH-1106 | 400 | correct_input | Yes | That code is incorrect |
| TB-AUTH-1107 | 410 | retry | No | That code has expired |
| TB-AUTH-1108 | 429 | wait | Yes | Too many codes requested |
| TB-AUTH-1114 | 401 | reauthenticate | No | Security check failed |
Search 5 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-SRCH-1301 | 422 | correct_input | No | Choose a departure date in the future |
| TB-SRCH-1302 | 422 | correct_input | No | Check your return date |
| TB-SRCH-1303 | 422 | correct_input | No | Choose two different places |
| TB-SRCH-1306 | 422 | correct_input | No | Too many passengers |
| TB-SRCH-1307 | 422 | correct_input | No | Each infant needs an adult |
Flights 4 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-FLIGHT-1401 | 410 | restart_search | No | This fare is no longer available |
| TB-FLIGHT-1402 | 410 | restart_search | No | This offer has expired |
| TB-FLIGHT-1403 | 409 | none | No | The price for this flight has changed |
| TB-FLIGHT-1406 | 422 | none | No | Please confirm the price first |
Hotels 6 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-HOTEL-1501 | 410 | restart_search | No | This room is no longer available |
| TB-HOTEL-1502 | 409 | restart_search | No | The rate has changed |
| TB-HOTEL-1503 | 409 | none | No | This property cannot confirm the booking |
| TB-HOTEL-1504 | 422 | correct_input | No | Check your dates |
| TB-HOTEL-1505 | 422 | correct_input | No | Too many guests for these rooms |
| TB-HOTEL-1507 | 422 | correct_input | No | The room needs a name |
Account and balance 20 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-PARTNER-3001 | 402 | top_up | No | Insufficient balance |
| TB-PARTNER-3002 | 403 | contact_support | No | Account suspended |
| TB-PARTNER-3003 | 409 | none | No | This top-up has already been processed |
| TB-PARTNER-3004 | 401 | check_credentials | No | Invalid API credentials |
| TB-PARTNER-3005 | 403 | check_scopes | No | This key is not permitted to do that |
| TB-PARTNER-3006 | 429 | back_off | Yes | Rate limit exceeded |
| TB-PARTNER-3007 | 402 | upgrade_plan | No | Monthly quota exhausted |
| TB-PARTNER-3008 | 403 | upgrade_plan | No | Product not included in this plan |
| TB-PARTNER-3009 | 403 | use_live_key | No | Sandbox keys cannot do that |
| TB-PARTNER-3010 | 402 | top_up | No | No balance in this currency |
| TB-PARTNER-3011 | 422 | correct_input | No | This API settles in USD |
| TB-PARTNER-3012 | 403 | contact_support | No | This service is not active on your account |
| TB-PARTNER-3013 | 429 | back_off | Yes | Daily limit reached |
| TB-PARTNER-3014 | 422 | correct_input | No | This order exceeds your per-booking limit |
| TB-PARTNER-3015 | 409 | contact_support | No | No permitted supplier can fulfil this |
| TB-PARTNER-3016 | 402 | top_up | No | Not enough balance for this feature |
| TB-PARTNER-3017 | 409 | none | No | This feature is already active |
| TB-PARTNER-3018 | 403 | contact_support | No | Webhooks are not part of this account |
| TB-PARTNER-3019 | 409 | wait | No | We are confirming this order with the supplier |
| TB-PARTNER-3020 | 409 | wait | No | This order is waiting for our approval |
Transfers 2 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-TRANSFER-1601 | 410 | restart_search | No | This transfer is no longer available |
| TB-TRANSFER-1602 | 422 | correct_input | No | Too many passengers or bags |
Orders 10 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-BOOK-1701 | 410 | restart_search | No | Your booking hold has expired |
| TB-BOOK-1704 | 409 | contact_support | No | This booking cannot be changed right now |
| TB-BOOK-1706 | 422 | contact_support | No | This fare cannot be cancelled |
| TB-BOOK-1708 | 202 | wait | No | Your booking needs a quick review |
| TB-BOOK-1709 | 409 | wait | Yes | This booking is being updated |
| TB-BOOK-1712 | 409 | none | No | A booking needs at least one item |
| TB-BOOK-1713 | 409 | restart_search | No | This booking can no longer be changed |
| TB-BOOK-1714 | 409 | restart_search | No | That price is in a different currency |
| TB-BOOK-1716 | 409 | contact_support | No | This booking needs a human to issue the ticket |
| TB-BOOK-1715 | 409 | restart_search | No | The supplier could not confirm this order |
Travellers 9 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-PAX-1801 | 422 | correct_input | No | Name must match the travel document |
| TB-PAX-1802 | 422 | correct_input | No | Use Latin characters for names |
| TB-PAX-1803 | 422 | correct_input | No | This passport expires before travel |
| TB-PAX-1804 | 422 | correct_input | No | Passport expires soon after travel |
| TB-PAX-1805 | 422 | correct_input | No | Date of birth does not match the passenger type |
| TB-PAX-1806 | 422 | correct_input | No | Check the infant age |
| TB-PAX-1807 | 422 | correct_input | No | Passenger count does not match the fare |
| TB-PAX-1808 | 422 | correct_input | No | Check the document number |
| TB-PAX-1809 | 422 | correct_input | No | Contact details needed |
Suppliers 8 codes
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-PROV-3001 | 503 | retry | Yes | This airline's system is unavailable |
| TB-PROV-3002 | 504 | wait | Yes | The airline is taking longer than usual |
| TB-PROV-3003 | 502 | contact_support | Yes | Something went wrong on our side |
| TB-PROV-3004 | 429 | wait | Yes | We're very busy right now |
| TB-PROV-3005 | 500 | contact_support | No | Something went wrong on our side |
| TB-PROV-3006 | 409 | correct_input | No | The airline did not accept these details |
| TB-PROV-3007 | 500 | contact_support | No | Something went wrong on our side |
| TB-PROV-3008 | 503 | wait | Yes | This option is temporarily unavailable |
System 1 code
| Code | HTTP | Action | Retry | Meaning |
|---|---|---|---|---|
| TB-SYS-5001 | 500 | contact_support | Yes | Something went wrong on our side |
Machine-readable contract: openapi.json. Generate a client with openapi-generator, or mock the whole API offline with npx @stoplight/prism-cli mock openapi.json.