Errors#
Every failure returns the same JSON envelope, whatever went wrong and whichever endpoint produced it.
Some failures add fields on top — a credit refusal carries the shortfall, a price conflict carries both figures — but the five below are always present.
{
"status": 400,
"error": "Bad Request",
"message": "The window between `from` and `to` accepts at most 365 days.",
"path": "/v1/availability",
"timestamp": "2026-07-29T13:05:56.198280408Z"
}statusintegerThe HTTP status, repeated in the body so a logged payload is self-describing.
errorstringThe HTTP reason phrase.
messagestringWritten for a human. Do not pattern-match on it — the wording is not part of the contract and will change.
pathstringThe request path that produced the failure.
timestampstring<date-time>ISO-8601 instant. Quote this and
pathwhen you contact support; together they identify the request.
Status codes#
| Status | Means | Retry? |
|---|---|---|
400 | Malformed request, or a cap exceeded | No — fix the request |
401 | Authentication failed | No |
402 | Credit denied, or account on hold | No — free credit first |
404 | Not found, or not yours | No |
409 | Conflict: price moved, or idempotency key reused | Only after re-quoting |
429 | Rate limited. Carries Retry-After | Yes, after the delay |
5xx | Our fault | Yes, with backoff |
404 is also "not yours"#
A resource that exists but belongs to another account returns 404, with the same message as an id that never existed.
This is a deliberate security property, not an oversight. A 403 would confirm the resource exists, which turns any id field into an oracle for enumerating other accounts' bookings. Because the two are indistinguishable, you cannot use this API to discover whether an id exists outside your own account — and neither can anyone who steals your key.
The practical consequence for your code: a 404 on an id you believe you own usually means you are pointing at the wrong environment or the wrong key, not that the record vanished.
400 — the caps reject, they never clamp#
Bulk endpoints have limits, and exceeding one is an error rather than a silent truncation.
That choice matters: a clamped request returns a partial answer that looks complete, and a nightly sync built on it drops rows forever without ever failing. An explicit 400 is the version you can debug.
| Cap | Applies to |
|---|---|
| 100 ids per parameter | listingIds, propertyIds, bookingIds, externalBookingIds |
| 365 days | any from/to window |
| 100 rows per page | size |
The id cap is per parameter, not per request — 100 listingIds and 100 propertyIds in one call is fine.
402 — credit denied#
Booking is the only operation that returns 402. The body carries three extra fields telling you exactly how far short you are, so you can show a real number rather than "try again later".
reasonCode is the field to branch on:
INSUFFICIENT_CREDIT — the booking exceeds what is left. shortfall is the gap in rupees.
ACCOUNT_ON_HOLD — an overdue balance past your grace period has blocked booking entirely. Paying the invoice clears it. Reads keep working throughout.
{
"status": 402,
"error": "Payment Required",
"message": "This booking would exceed the agent group's available credit.",
"path": "/v1/bookings",
"timestamp": "2026-07-29T13:05:56.198280408Z",
"reasonCode": "INSUFFICIENT_CREDIT",
"availableCredit": 18400.00,
"shortfall": 3650.00
}409 — conflicts#
Two distinct situations share this status, and the extra fields tell them apart.
The price changed#
Your acceptedTotal no longer matches what the stay costs. Nothing was booked and nothing was charged.
Re-quote with POST /v1/orders/{orderId}/reprice, show the new figure to whoever is deciding, and book again with the new total. Never retry with the old one — it will fail identically, by design.
This is the mechanism that stops a price moving underneath a customer between quote and confirmation.
{
"status": 409,
"error": "Conflict",
"message": "The price for this order has changed.",
"path": "/v1/bookings",
"timestamp": "2026-07-29T13:05:56.198280408Z",
"acceptedTotal": 22050.00,
"currentTotal": 23400.00
}The idempotency key was reused with different contents#
An externalBookingId already exists in your account against a different payload.
The same id with the same payload is not an error — that is a retry, and it returns the original booking. This 409 means two genuinely different bookings were attempted under one id, which is almost always a bug in id generation. Use a new id, or send the original payload.
Retrying, properly#
Only three classes are worth retrying: 429, 5xx, and network timeouts.
Never blind-retry a 4xx other than 429. It will fail identically and burn your rate limit.
Honour Retry-After when present rather than using a fixed delay — it reflects your account's real refill rate, so a hardcoded value will just be rejected again.
A timeout on POST /v1/bookings is not a failure. The booking may exist. Retry with the same externalBookingId — that is exactly what it is for.
RETRYABLE = {429, 500, 502, 503, 504}
def call(method, url, **kw):
for attempt in range(5):
try:
res = requests.request(method, url,
auth=(API_KEY, ""), timeout=30, **kw)
except requests.Timeout:
continue # same body, same idempotency key
if res.status_code not in RETRYABLE:
return res
wait = int(res.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
raise RuntimeError("gave up after 5 attempts")Failures that are not errors#
Two responses look like failures and are not:
- An empty
dataarray is a successful search with no matches, not a404. 202on a booking means accepted and being confirmed with the property. It is not a refusal — wait for the callback or pollGET /v1/bookings/{id}.
Next#
Rate limits covers 429 and the headers on every response.