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.
POST/api/v1/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 next.
Permission: Read+Write (a read key answers 403 read_write_permission_required)
Request body
POST /
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
book_hash | string | Yes | The bookHash of the rate, from get_rates. 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.
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).
| 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):
{
"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, and later to the booking detail, get_voucher and cancel_hotel. 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 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 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:
{
"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 with the partnerOrderId and the guests of each room.