Skip to content

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.

{
  "success": false,
  "message": "Missing Authorization header",
  "data": null
}

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 paymentStatusNOT_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.


Next steps

  • Examples — a complete booking with error handling in place
  • Overview — response envelope and conventions