Sandbox testing
Which hotel ids are safe to test with in the sandbox, and why booking any other hotel fails.
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.
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) 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, get_rates, get_hotel_info, POST / | Work on real hotels and real prices. A 5 km search around Paris returns 1,818 hotels. |
pre_book | 409 hotel_not_bookable_in_sandbox. errors[0].details.test_hotels lists the two hotels that can be booked. |
book_hotel | 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_bookand at 00:00 UTC fortest_hotel(seecancellationPolicy.freeCancellationBefore, a UTC time without zone suffix). Cancel before andcancel_hotelanswerscancellationType: "free"and the booking becomesrefunded. - 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_emailatfalseunless 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_bookand 30book_hotelcalls 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.
export FXP_KEY="fxp_test_YOUR_KEY""""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": "[email protected]", "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}")// 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: "[email protected]", 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}`);#!/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: "[email protected]", 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 1The program prints one line per step. This is a real run of the sandbox (2026-11-01):
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 DZDThe 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 |
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 / has nothing to show. |
| Webhooks | None, here or on live. FX-Port does not call a URL of yours. Poll GET /. |
| 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. |
Known quirks
- A booking is
pendingfirst.book_hotelanswerspendingin 3 to 6 seconds and the booking isconfirmed4 to 14 seconds later.cancel_hotelbefore that answers400 invalid_status: only aconfirmedbooking can be cancelled.invalid_statusalso answers when the booking is alreadyrefundedorfailed. - The voucher can come a little later than the status
confirmed.get_voucheranswers404 pdf_not_founduntil 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_hoteltwice for onebooking_id. The second call answers409 duplicate_bookingand does not reach the supplier. After a timeout or a5xx(except503 supplier_busy, refused before anything was sent), read the booking first. pre_booktakes 4 to 5 seconds. Apre_bookrepeated with the same rate and the sameprice_increase_percentwithin a minute gets the same answer instead of opening a second order.- Names.
first_nameandlast_nameare kept as letters, spaces and- , . '; digits and other symbols are replaced by a space. See Guests and rooms.
Testing checklist
Before you switch to a live key, run these against test_hotel_do_not_book:
- Locations:
locations/turns a city into coordinates.search - Name search:
name-searchreturns hotel ids for a name. - Geo search:
search_advancedwith coordinates and aradius, and with a hotel id. Render the mini cards with their prices and handlemissing_hotel_ids. - Hotel page:
get_hotel_infoandget_rates, for one room and for two rooms with a child. - Pre-book:
pre_bookwith thebookHash; checkformData.orderId. - Book:
book_hotelwith thepartnerOrderIdasbooking_id, with a child flaggedis_childandage. - Poll:
GET /untilbookings/ {id}?product=hotels confirmed. - Voucher:
get_voucher, and the retry while the answer is404 pdf_not_found. - Cancel:
cancel_hotel, and a second call that fails withinvalid_status. - 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 secondbook_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_hotelneeds the offer thatpre_booklocked (409 cached_offer_requiredotherwise) and a balance that covers the debit (402 insufficient_balance).- The sandbox flags
balanceDebited,transactionCreatedandpayoutCreatedare absent. - Your agency must be approved (
403 agency_not_activeotherwise).
Do not run the program above with a live key: it stops on purpose.