Integrating

Errors and versioning

One error shape for everything the gateway answers, one table of the status codes it really produces, and an honest account of what the version promise is backed by.

The envelope

Every failure the gateway itself produces — validation, authorisation, supplier, ours — comes back in the same shape, with the HTTP status carrying the category.

Error response
{
  "error": {
    "type": "price_changed",
    "message": "The airline now prices this offer at 141.00 USD.",
    "requestId": "a4f1c358-6b2e-5acb-8ef5-0c3d0bcc35f5",
    "previousTotalCents": 13500,
    "currentTotalCents": 14100
  }
}
FieldUse it for
typeBranching. Machine-readable and stable — renaming one would need a new major version.
messageLogs and support tickets. Human-readable and subject to change — never match on it.
requestIdCorrelating with our side. Always present, also on every success as X-Request-Id. It is a plain UUID with no prefix and no fixed length beyond that — store it as a string, do not validate it against a pattern.
docUrlOptional. It appears only when the deployment has a documentation base URL configured, and it points at the topic page for the error, not at a page per type — so type it as optional and never dereference it blindly.
Extra fieldsSome types carry details: price_changed carries both amounts, insufficient_funds carries availableCents and requiredCents, the idempotency errors carry the key. Additive, so ignore what you do not know.

Two things the envelope does not cover

Failures that never reach the service — an ingress rejecting a body, a load balancer returning a 502 or an HTML error page — are not in this shape. Guard your parsing: check the content type and fall back on the status code rather than assuming error.type is there. And never parse message: messages get rewritten, translated and clarified, and that is not a breaking change.

Status codes

StatusTypes you will seeRetry?
400invalid_request — malformed body, a failed validation, or a property the endpoint does not know (unknown fields are rejected, not ignored). idempotency_key_required — the header is missing, or is not 8 to 255 printable ASCII characters.No — fix the request.
401authentication_required, invalid_api_key, api_key_revoked, api_key_expired.No.
402insufficient_funds — your prepaid account cannot cover the booking. Raised before the supplier is contacted, and carries the amounts.After topping up.
403insufficient_scope — the message names the missing scope. ip_not_allowed — the source address is not on the key's allow list. account_not_active — the account is read-only or suspended, which is an invoicing matter and not a key problem. Topping up does not help.No.
404not_found — unknown resource or unknown id.No.
409idempotency_key_reuse — same key, different body. idempotency_key_in_progress — the first call with this key is still running. price_changed — a flight repriced above the maxTotalCents you sent; nothing was booked.Only idempotency_key_in_progress, after a short wait. The other two are decisions, not glitches.
410offer_expired — a rate, offer or quote handle is past its ~20 minutes, or is unknown. It is deliberately not a 404: a 404 sends you looking for a bug on your side, a 410 tells you to search again.Not as-is — re-run the search and re-price.
413 / 415 / 405payload_too_large, unsupported_media_type, method_not_allowed. Note that payload_too_large — and conflict, the fallback type on a 409 that carries no type of its own — are not in the published type enum yet.No — fix the request.
429rate_limit_exceeded. Two different situations arrive under it: the per-minute limiter, which sets Retry-After, and your daily search cap, which does not and resets at midnight UTC. Tell them apart by the quota headers on the response.Yes — honour Retry-After when it is there. With no Retry-After and X-Search-Quota-Remaining: 0, stop for the day.
5xxupstream_unavailable (503, a supplier or an internal service did not answer), upstream_error (502, it answered with a failure or a rejection) and internal_error (500).Yes — with backoff and the same idempotency key. Bounded, not forever.

For anything retryable, keep the original idempotency key and the original body. That is what turns a retry into a replay instead of a second booking. Note that a 5xx releases the key, so on the booking endpoints it is externalReference that keeps a retry from becoming a second operation.

Known gap: a reused externalReference on travel

Sending an externalReference that already belongs to an operation is answered correctly on POST /v1/esim/orders — the existing order comes back, or you get a 409. On hotel, flight and transfer bookings the same situation currently surfaces as 500 internal_error. It is a caller error wearing a server error's clothes: retrying will not change it. If a booking returns 500, read the operation back before you retry more than once, and check whether you reused a reference.

If you use the TypeScript SDK

errorFrom() maps the known types to error classes and everything else by status code. price_changed is not among the mapped types today, so it arrives as an IdempotencyError by virtue of being a 409. Branch on error.type, not on the class, and you are unaffected.

Versioning

The version is in the path: every partner route lives under /v1. The unauthenticated probes /health, /health/ready and /health/full sit outside it and are in the same OpenAPI document. There is no version header and no per-account pinning, because two partners on different behaviour of the same URL is a support problem neither of us wants.

These changes land in /v1 at any time, without notice. Your integration has to tolerate them:

  • New fields in a response object.
  • New optional request parameters and new optional body fields.
  • New values in an existing enum — including new error types.
  • New endpoints and new resources.
  • New webhook event types, and new fields in existing event payloads.
  • New response headers.
  • Rewritten error messages, at an unchanged type.

Two rules that keep you compatible

Ignore fields you do not recognise instead of failing to deserialise them, and treat an unknown enum value as an unknown rather than a crash. Strict schema validation on our responses will break your integration on a change we are explicitly allowed to make — and the published type enum is already narrower than what the gateway can send, so a generated client with a closed union needs a fallback today, not one day.

Anything that could break a correct integration — removing or renaming a field, tightening a type, changing the meaning of a value, removing an endpoint or an enum value, making an optional parameter required — is not something we do in /v1. It belongs in a new major version at a new path.

What backs that promise, and what does not

It is a commitment we work to, not something the code enforces: there is no /v2, no version registry and no contract test that compares today's schema against a frozen published baseline. Keep your own snapshot tests on the responses you depend on. If a field you rely on disappears, that is a bug on our side — tell us, quoting a requestId.

Staying informed

Changes are written up in the changelog, which is maintained by hand. There is no feed, no API for it and no automatic notification: responses carry no Deprecation or Sunset header, and nothing emails your partner admins when the contract moves. Do not build alerting on a signal we do not send — watch the changelog, and talk to your Vacabee contact before you plan a migration around a date.