Requests
Idempotency
Every POST requires an Idempotency-Key header — searches included. Without one the request is rejected. This is the single rule most likely to save you a duplicate booking, so it is enforced rather than recommended.
Two mechanisms, not one
A booking call talks to a supplier. Suppliers are slow, and slow calls time out. When your HTTP client gives up after 30 seconds you do not know whether the booking was created — and the natural reaction, a retry, is what turns one reservation into two.
Two separate things guard against that, and you need both:
- The
Idempotency-Keyheader protects one HTTP call and its retries. The second attempt with the same key and the same body gets the stored answer instead of a second execution. externalReferencein the body protects the operation. It is your own reference, it is mandatory on every booking and order, and the same value twice yields one booking — even when the first response never reached you and even under a different idempotency key. This page's predecessor never mentioned it; it is the field that actually stops a second booking after a crash.
One case the key cannot cover
503 upstream_unavailable and deliberately does not release the reservation it may have created — releasing something that might exist is worse than leaving it. A 5xx also frees the idempotency key (see below), so a hotel retry after that error can produce a second reservation at the provider. Retry with the same externalReference, and if you get a second 503 in a row, stop and ask support rather than looping. For eSIM the upstream is keyed on externalReference and this cannot happen.Sending a key
POST /v1/hotels/bookings HTTP/1.1
Authorization: Bearer vcb_live_7Kq2xY…
Idempotency-Key: 6a4b1f2e-6c3a-4f1c-9e64-8b0f2d5a7c11
Content-Type: application/json
{
"rateId": "rate_8f2c…",
"externalReference": "booking-2026-0915-abc",
"guests": [
{ "firstName": "Ada", "lastName": "Lovelace" },
{ "firstName": "Alan", "lastName": "Turing", "age": 8 }
],
"contact": { "email": "ada@example.com", "phone": "+4915112345678" },
"specialRequests": "High floor if possible"
}Those are all the fields the endpoint knows. rateId, externalReference, guests and contact are required; specialRequests is optional. The request validator rejects unknown properties outright, so an extra field is a 400, not something we ignore.
- Generate one key per logical operation, not per HTTP attempt. All retries of the same booking reuse the same key. A fresh booking always gets a fresh key.
- The header must be 8 to 255 printable ASCII characters, with no spaces. A UUIDv4 is the obvious choice. Your own order id works if it clears that bar —
"42"or a value with a space in it does not, and is rejected with400 idempotency_key_requiredeven though you sent the header. - A key is scoped to your partner account and environment: sandbox and live never share a namespace, so a key you burned in the sandbox is free again with a live key. The route is stored with the key but is not part of the namespace — reusing the same value on a different route is a conflict, not a fresh slot. One key, one call.
- Persist the key with your order before you send the request. A key you only hold in memory is gone precisely when you need it — after a crash mid-call.
- Records are kept for 24 hours and then deleted. After that the same key executes the call again for real, so a next-day re-submission from a dead-letter queue is a new request — which is exactly why the booking endpoints also carry
externalReference.
What each case returns
Which failures are replayed
4xx is stored as the outcome: the replay returns that same error, and the key is spent. A 5xx, a timeout or a crash releases the key instead, so the retry really does reach the supplier again. That asymmetry is deliberate, and it is why externalReference carries the duplicate protection across a 5xx.Retry policy
Retry on 429, on 5xx, and on the one 409 that is retryable — idempotency_key_in_progress — reusing the same idempotency key every time. Do not retry other 4xx responses: they describe something wrong with the request, and repeating it unchanged will not fix it.
Honour Retry-After when it is there. The rate limiter sets it; the daily search cap does not, and its reset is at midnight UTC rather than in seconds — so treat that one as “stop for today”, not as something to back off into.
const idempotencyKey = crypto.randomUUID();
await orders.update(orderId, { idempotencyKey }); // persist first
// The in-flight claim lasts 90 seconds, so a retry loop that gives up after
// a few seconds gives up on a booking that is still being created.
for (let attempt = 0; attempt < 8; attempt++) {
const response = await fetch(`${apiUrl}/v1/hotels/bookings`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body, // byte-identical on every attempt
});
if (response.status < 500 && response.status !== 429) {
if (response.status !== 409) return response;
const { error } = await response.clone().json();
// 409 idempotency_key_reuse and 409 price_changed are final.
if (error?.type !== "idempotency_key_in_progress") return response;
}
const retryAfter = Number(response.headers.get("retry-after")) || 0;
await sleep(Math.max(retryAfter * 1000, 2 ** attempt * 500 + Math.random() * 250));
}Note that body is computed once, outside the loop. A body that is rebuilt per attempt and contains a timestamp or a regenerated id will differ from the stored one and earn you a 409 instead of the replay you wanted.
A replayed search still counts
POST …/searches served from the store is counted again even though no supplier was contacted. Retry searches because you need the result, not because retrying is free. See search quota.Which calls need a key
Every POST, without exception — and that includes the ones that create nothing: /v1/hotels/searches, …/searches/{id}/load-more, /v1/flights/searches, /v1/transfers/searches, the booking and order endpoints, …/cancel, and the webhook management calls POST /v1/webhooks/endpoints, …/{id}/test, …/{id}/rotate-secret and POST /v1/webhooks/deliveries/{id}/retry. Generate the key in your HTTP layer rather than in the booking path, or your first search will fail with 400.
GET, DELETE and PATCH take no key. PATCH on a hotel search refines state you already own.
When a call still leaves you uncertain, read the resource back before you retry anything: GET /v1/hotels/bookings/{id}, GET /v1/flights/orders/{id} and GET /v1/transfers/orders/{orderId}. There is no account-wide booking list — /v1/bookings does not exist, and GET /v1/esim/orders is the only listing endpoint in the gateway. That is the practical reason to keep your own record keyed by externalReference before you send the request. And if you are unsure how to interpret a status code, errors and versioning lists them all.

