Errors¶
This API returns three different error shapes, depending on which layer rejected the request. A parser that assumes one shape will crash on the others.
Read this before writing your error handling
The difference is not cosmetic. Authentication failures have no timestamp. Validation failures have no success field at all. Write one helper that normalises all three, and use it everywhere.
Status codes¶
| Status | Meaning | Typical cause |
|---|---|---|
400 |
Bad Request | Validation failure, invalid order or payment state, invalid return URL, a business rule failure, or invalid client credentials on /auth/token |
401 |
Unauthorized | Missing or invalid bearer token, revoked credential, or inactive company |
403 |
Forbidden | Your company does not own the resource |
404 |
Not Found | Route, package, order, traveller, or company not found |
409 |
Conflict | The resource is not in a state that allows this, or it changed underneath you. Some 409s are worth retrying — see Retries |
429 |
Too Many Requests | Rate limit exceeded. Wait Retry-After seconds — see Rate-limit errors |
500 |
Internal Server Error | Unexpected backend failure |
405, 413, 415, 502, 503 |
Other | Wrong method, body too large, wrong Content-Type, or a downstream service unavailable. Uses shape 2 |
The three shapes¶
1. Authentication errors¶
Rejected by the auth layer before reaching any endpoint. No timestamp field.
Returned for every 401, and for 429 rate-limit responses. See Authentication for the conditions that trigger a 401.
Rate-limit errors¶
A 429 uses this same shape, plus headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Retry-After-Seconds: 12
{
"success": false,
"message": "Rate limit exceeded. Please try again in 12 seconds.",
"data": null
}
Successful responses also carry X-RateLimit-Limit and X-RateLimit-Remaining, so you can slow down before you hit the limit. Limits are listed in the overview.
2. Validation and business errors¶
The most common shape. Returned by endpoints for 400, 403, 404, 409, and 500. No success field — it uses status and error instead.
{
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/external/v2/reference/quote",
"timestamp": "2026-07-07T03:30:00Z",
"details": {
"fieldErrors": {
"dobs": "Exactly one date of birth is supported"
}
}
}
| Field | Type | Description |
|---|---|---|
status |
Integer | HTTP status code, repeated in the body |
error |
String | Short error label — usually the status reason phrase, sometimes a category such as Traveller Validation Failed. Do not branch on it |
message |
String | Human-readable summary |
path |
String | The request path that failed |
timestamp |
String | ISO-8601 instant, UTC |
details |
Object | Present on validation failures and some other errors. See Validation errors |
3. Document upload errors¶
POST /documents handles some upload failures itself and returns the success envelope with success: false.
{
"success": false,
"message": "Failed to upload file",
"data": null,
"timestamp": "2026-07-07T03:30:00Z"
}
This shape looks like shape 1 but does carry a timestamp. Do not use the presence of timestamp to tell error types apart.
Validation errors¶
A 400 from a validation failure carries details.fieldErrors — a map of field name to message. This is the one place where reading the message is useful, because it is per-field and actionable.
{
"status": 400,
"error": "Bad Request",
"message": "Validation failed",
"path": "/api/external/v2/reference/quote",
"timestamp": "2026-07-07T03:30:00Z",
"details": {
"fieldErrors": {
"dobs": "Exactly one date of birth is supported",
"nationality": "Nationality is required"
}
}
}
Map fieldErrors keys onto your own form fields to show inline errors. Note details is absent on non-validation errors, so guard before reading it.
details varies with what failed:
| Failure | details content |
|---|---|
| JSON body validation | fieldErrors: field → message (string) |
| Query or path parameter validation | fieldErrors: field → array of messages; may also carry paramErrors and crossParameterErrors |
| Missing query parameter | missingParameters |
Missing multipart part (e.g. file on /documents) |
missingParts |
POST /travellers batch |
totalErrors and errors[], each with requestIndex, errorType, field, message |
Handle a fieldErrors value that is either a string or an array of strings.
Normalising the shapes¶
One helper, used everywhere:
/**
* Normalises all three HGN error shapes into one object.
* Never throws on an unexpected body.
*/
async function parseHgnError(res) {
let body = null;
try {
body = await res.json();
} catch {
// Non-JSON body (proxy timeout, gateway HTML page, empty response)
return { status: res.status, message: res.statusText, fieldErrors: {} };
}
return {
status: res.status,
// shape 2 uses `message`; shapes 1 and 3 also use `message`
message: body?.message ?? res.statusText,
// only present on validation failures
fieldErrors: body?.details?.fieldErrors ?? {},
raw: body,
};
}
async function hgnFetch(path, options) {
const res = await fetch(`${BASE_URL}${path}`, options);
if (!res.ok) {
const err = await parseHgnError(res);
throw Object.assign(new Error(err.message), err);
}
return (await res.json()).data;
}
class HgnApiError(Exception):
def __init__(self, status, message, field_errors=None, raw=None):
super().__init__(f"[{status}] {message}")
self.status = status
self.message = message
self.field_errors = field_errors or {}
self.raw = raw
def parse_hgn_error(res):
"""Normalises all three HGN error shapes."""
try:
body = res.json()
except ValueError:
return HgnApiError(res.status_code, res.reason)
return HgnApiError(
status=res.status_code,
message=body.get("message", res.reason),
# `details` is absent on non-validation errors
field_errors=(body.get("details") or {}).get("fieldErrors", {}),
raw=body,
)
def hgn_request(method, path, access_token, **kwargs):
res = requests.request(
method,
f"{BASE_URL}{path}",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
**kwargs,
)
if not res.ok:
raise parse_hgn_error(res)
return res.json()["data"]
Handling guidance¶
Never branch on message¶
message is for humans and logs. It is not part of the contract and can change without a version bump. Branch on the HTTP status code, and on fields inside data.
The one exception is details.fieldErrors, which is keyed by field name and is intended for display.
Promo rejections are deliberately uniform¶
Every reason that makes a code unusable returns the same message, Invalid promo code. This is an anti-enumeration measure — see Promo Codes.
success: true is not "it worked"¶
Two places return 200/201 with success: true while the thing you asked for did not happen:
| Case | What to check |
|---|---|
POST /orders with a promoCode |
promoApplyResult — a rejected code still creates the order |
GET /payment-status |
paymentStatus — NOT_INITIATED is a 200, not an error |
Retries¶
| Status | Retry? | Notes |
|---|---|---|
400, 403, 404 |
No | The request is wrong. Retrying changes nothing |
409 |
After re-reading | Re-read the order and retry if it still makes sense. For /pay, a 409 for an Idempotency-Key still being processed clears after a short delay, and a promo removed at payment time means refresh and pay again |
429 |
Yes | Wait Retry-After seconds |
401 |
Once | Refresh the token and retry. If it fails again, the credential is the problem — stop |
500 |
Yes, with backoff | Use exponential backoff and a retry cap |
| Network timeout | Carefully | For /pay, reuse the same Idempotency-Key for the same order — it returns the same payment session. A new key starts a new payment attempt and cancels the unfinished one |
Order creation is not idempotent
Only POST /orders/{orderRef}/pay takes an Idempotency-Key. POST /orders does not — a blind retry after a timeout can create a duplicate order.
On an ambiguous failure, reconcile with GET /travellers or your own stored references before retrying.