Skip to content
FX-Port/Docs
GuidesAPI referenceChangelog
Get API keys
  • Introduction
  • Quickstart
  • Security
  • Authentication
  • Environments and Postman
  • Sandbox
  • Conventions and naming
  • Legacy names and twins
  • Errors
  • Rate limits and fair use
  • Streaming search results

Concepts

  • Overview
  • Sandbox testing
  • Mini and full data
  • Guests and rooms
  • Pricing

Search

  • Geo search
  • Locations and coordinates
  • Name search
  • Hotel details

Rates and booking

  • Rates
  • Pre-book
  • Book

After booking

  • Get a booking
  • Voucher
  • Cancel
  • Use these docs with AI

Hotels ยท Concepts

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.

Book only the test hotels, and cancel what you book

Sandbox search and pricing run on real supplier inventory, but booking is possible only on two supplier test properties: test_hotel_do_not_book (every residency, use this one) and test_hotel (residency dz, ru or us only). pre_book on any other hotel answers 409 hotel_not_bookable_in_sandbox and lists the test hotels. Every sandbox booking is a real order on the supplier's test contract: cancel it with cancel_hotel. It is free until 21:00 UTC on the check-in date.

The test hotels

Hotel idhidWorks with residencyNotes
test_hotel_do_not_book8473727Every 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_hotel8526976dz, 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_idtest_hotel_do_not_book
residencygb
checkin / checkouta date 30 days ahead, and 3 days later
guests[{ "adults": 2, "children": [] }]
Rate to bookthe cheapest one (1 EUR a night, 3 EUR for the stay)

What happens with another hotel

CallAny other hotel in the sandbox
search_advanced, get_rates, get_hotel_info, POST /hotels/searchWork on real hotels and real prices. A 5 km search around Paris returns 1,818 hotels.
pre_book409 hotel_not_bookable_in_sandbox. errors[0].details.test_hotels lists the two hotels that can be booked.
book_hotelCannot 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_book and at 00:00 UTC for test_hotel (see cancellationPolicy.freeCancellationBefore, a UTC time without zone suffix). Cancel before and cancel_hotel answers cancellationType: "free" and the booking becomes refunded.
  • 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_email at false unless 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_book and 30 book_hotel calls 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 1

The 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 DZD

The 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:

StepTime
search_advanced (one hotel)0.5 to 0.7 s
get_rates0.5 to 0.7 s
pre_book4.0 to 4.6 s
book_hotel (answers pending)3.0 to 3.1 s
Until confirmed7 to 10 s after the book_hotel answer (4 to 14 s over 9 runs)
get_voucher0.4 s
cancel_hotel3.6 to 3.9 s
Whole program19 to 24 s

The program prints the voucher as https://files.example.com/... here. The real link points to the public storage of FX-Port's vouchers. It is permanent and needs no key: do not post it where it should not be read (see Voucher).

What the sandbox changes

In the sandbox
BalanceNever debited, never credited. book_hotel adds balanceDebited, transactionCreated and payoutCreated, all false. They exist on a sandbox key only. GET /balances has nothing to show.
WebhooksNone, here or on live. FX-Port does not call a URL of yours. Poll GET /bookings/{id}?product=hotels.
Search and ratesReal hotels, real prices, real cancellation schedules.
BookingsReal orders on the supplier's test contract, only on the two test hotels.
VoucherA real PDF at a public link, also for a cancelled booking.
CancellationWorks like live. refundProcessed is true and refund shows amounts, but no money moves.
Rate limitsThe same as live: Rate limits.

Known quirks

  • A booking is pending first. book_hotel answers pending in 3 to 6 seconds and the booking is confirmed 4 to 14 seconds later. cancel_hotel before that answers 400 invalid_status: only a confirmed booking can be cancelled. invalid_status also answers when the booking is already refunded or failed.
  • The voucher can come a little later than the status confirmed. get_voucher answers 404 pdf_not_found until 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_hotel twice for one booking_id. The second call answers 409 duplicate_booking and does not reach the supplier. After a timeout or a 5xx (except 503 supplier_busy, refused before anything was sent), read the booking first.
  • pre_book takes 4 to 5 seconds. A pre_book repeated with the same rate and the same price_increase_percent within a minute gets the same answer instead of opening a second order.
  • Names. first_name and last_name are 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:

  1. Locations: locations/search turns a city into coordinates.
  2. Name search: name-search returns hotel ids for a name.
  3. Geo search: search_advanced with coordinates and a radius, and with a hotel id. Render the mini cards with their prices and handle missing_hotel_ids.
  4. Hotel page: get_hotel_info and get_rates, for one room and for two rooms with a child.
  5. Pre-book: pre_book with the bookHash; check formData.orderId.
  6. Book: book_hotel with the partnerOrderId as booking_id, with a child flagged is_child and age.
  7. Poll: GET /bookings/{id}?product=hotels until confirmed.
  8. Voucher: get_voucher, and the retry while the answer is 404 pdf_not_found.
  9. Cancel: cancel_hotel, and a second call that fails with invalid_status.
  10. 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 second book_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_hotel needs the offer that pre_book locked (409 cached_offer_required otherwise) and a balance that covers the debit (402 insufficient_balance).
  • The sandbox flags balanceDebited, transactionCreated and payoutCreated are absent.
  • Your agency must be approved (403 agency_not_active otherwise).

Do not run the program above with a live key: it stops on purpose.

PreviousHotels API overviewNext Mini and full hotel data

On this page

  • The test hotels
  • What happens with another hotel
  • Dates and stays
  • Sandbox bookings are real test orders
  • Run the whole flow
  • What the sandbox changes
  • Known quirks
  • Testing checklist
  • Before you go live

ยฉ 2026 FX-Port

TermsPrivacy[email protected]