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.
POST/api/v1/hotels/book_hotel
Finish the booking of a rate you locked with pre_book. 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)
Request body
POST /
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
booking_id | string | Yes | The partnerOrderId of pre_book. A booking id is used once. | |
rooms | array | Yes | One object per room, in the order you searched. See Guests and rooms. | |
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. |
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).
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": "[email protected]",
"phone": "+15555550123"
},
"send_email": false
}'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": "[email protected]",
"phone": "+15555550123",
"comment": "Late arrival"
},
"send_email": false
}'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": "[email protected]",
"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).
| 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:
{
"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. |
postProcessing.bucketLink | null here: the voucher is made after the call. Read it with get_voucher. 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.
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.
book_hotel → pending → (4 to 14 s) → confirmed → voucher links (at the same moment, or up to 10 s later)
→ failedCall GET / every 2 seconds (the booking changes within seconds: polling faster only uses your rate limits) until status is no longer pending, and stop after about 90 seconds.
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`);
}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. |
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 /. 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. 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. |
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:
{
"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 until it is confirmed, then fetch the voucher. To cancel, see Cancel a hotel booking.