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, or a business rule failure |
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 | Duplicate or conflicting resource state |
500 |
Internal Server Error | Unexpected backend failure |
The three shapes¶
1. Authentication errors¶
Rejected by the auth layer before reaching any endpoint. No timestamp field.
Returned for every 401. See Authentication for the conditions that trigger it.
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/v1/reference/quote",
"timestamp": "2026-07-07T03:30:00Z",
"details": {
"fieldErrors": {
"ages": "Must provide exactly 1 age (single person only)"
}
}
}
| Field | Type | Description |
|---|---|---|
status |
Integer | HTTP status code, repeated in the body |
error |
String | Status reason phrase |
message |
String | Human-readable summary |
path |
String | The request path that failed |
timestamp |
String | ISO-8601 instant, UTC |
details |
Object | Present on validation failures |
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/v1/reference/quote",
"timestamp": "2026-07-07T03:30:00Z",
"details": {
"fieldErrors": {
"ages": "Must provide exactly 1 age (single person only)",
"nationality": "must not be blank"
}
}
}
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.
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 promo code rejection returns the same message regardless of cause. 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, 409 |
No | The request is wrong. Retrying changes nothing |
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. A new key starts a new payment attempt |
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.