Quickstart — from a key to a confirmed booking
Status: Explanatory, and deliberately incomplete. It is the shortest path that works, not the
contract: CHANNEL-API.md is normative for every endpoint, field and error, and
where the two disagree the contract is right — please tell us, because that is a defect in this page.
Audience: a developer holding a sandbox key, with curl, jq and uuidgen.
uuidgen is missing from many minimal images; python3 -c 'import uuid;print(uuid.uuid4())'
works anywhere and is what the examples fall back to if you prefer one command.
Time: about ten minutes.
Six calls take you from nothing to a confirmed booking and back out again — capabilities, locations, availability, quote, book, cancel. Six commands, six requests: each one saves its response to a file and reads from that, so you can look at what you are about to use without asking for it twice.
Everything here is written for the SANDBOX. Section 1 records which environment you are on and
section 5 refuses to book unless it says sandbox — because pointed at production, that call
rents a real car.
If you do not have sandbox keys yet, ask Caravel at [email protected], or
request them yourself from https://sandbox-api.caravelway.com/portal/ ("Get a sandbox key") — confirm your work
email to request sandbox access; we review each request, and once it is approved you sign in and reveal
your keys in your account, once. Keys are never sent by email. A portal key lives 90 days from the day it
is issued and carries every scope except read:settlements, so
INTEGRATION-GUIDE.md §9 (settlements) cannot be tried with one. An approval
you do not reveal within 14 days lapses: request access again.
Invented test data only. The booking in section 5 is for an invented customer on example.com; keep
your own tests the same way. The sandbox takes made-up names, e-mail addresses and phone numbers and never
a real customer's. Data that looks real may be flagged for our review, and a flag never blocks a request.
What you book there may be reset at any time, without notice.
INTEGRATION-GUIDE.md §1.1a has the rule in full.
🚨 The sandbox sells from TWO suppliers, and your ONE key reaches both. They are deliberately
unalike — different currency, different tax rate, different payment terms, different desks — and some
station and vehicle codes repeat across them under different ids. That is not a quirk of the test
data; it is the shape of the real contract, and it is there so a client that assumes one supplier
fails here rather than in production. Every list below answers for both, each entry naming its
supplier_id, and every call that names an id — a station, a quote, a booking — goes to that id's
supplier by itself. (An account approved before one-key provisioning holds one key per supplier
instead; walk this guide once with each.)
This pack is also published online — open https://api.caravelway.com/docs/ and follow the link to this operator's pack. Same files, always the current release, so you never have to hunt for the attachment.
export BASE=https://sandbox-api.caravelway.com
# ONE key reaches both sandbox suppliers: every list below answers for both, each entry naming its
# supplier_id. (An account approved before one-key provisioning holds a key per supplier: run the
# guide with KEY set to each in turn.)
export KEY='paste-your-sandbox-key-here' # quoted: bare <…> is shell redirection
# Dates that move with you: pickup 30 days from now, return four days later, both at 10:00 UTC.
# (No python3? Set both by hand, in the same shape: YYYY-MM-DDT10:00:00Z.)
export PICKUP=$(python3 -c 'import datetime as d; print((d.datetime.now(d.timezone.utc) + d.timedelta(days=30)).strftime("%Y-%m-%dT10:00:00Z"))')
export DROPOFF=$(python3 -c 'import datetime as d; print((d.datetime.now(d.timezone.utc) + d.timedelta(days=34)).strftime("%Y-%m-%dT10:00:00Z"))')
Prefer one command? walkthrough.mjs ships beside this file and does all six calls for you —
it reads the catalogue, finds something bookable, quotes, books and then cancels what it booked:
CHANNEL_BASE_URL=$BASE CHANNEL_API_KEY=$KEY node walkthrough.mjs
Node 18 or newer, no dependencies, nothing to install. It refuses to run against anything that is not a sandbox, because it creates a real booking. Read it — it is short, and it is the same six calls spelled out below.
Three rules that explain most of what you will see:
- Every response is an envelope —
{data, meta, request_id}. Your payload is indata. Logrequest_idon every call; it is the fastest route to an answer about one specific request. - All money is
{amount, currency}withamountin INTEGER MINOR UNITS.12345is €123.45. The only decimal you will ever see is a tax rate such as0.24; every amount is an integer. - Read what you recognise and ignore the rest. New optional fields and new enum members may
appear within
/v1at any time. A client that rejects an unknown field will break; one that ignores it never will.
1 · What does this host serve?
# Capabilities answers without a key. Send yours anyway, as every call here does — see below.
curl -sS -w '\n→ HTTP %{http_code}\n' -H "X-API-Key: $KEY" "$BASE/v1/capabilities" -o /tmp/caps.json
jq '.data | {api_version, environment, currency, limits, quote_ttl_seconds}' /tmp/caps.json
# What comes back (abridged):
# { "request_id": "req_…", "data": { "api_version": "1.66.0", "environment": "sandbox", "currency": "EUR",
# "limits": { "list_limit_default": 50, "list_limit_max": 200, … }, "quote_ttl_seconds": 1800, … } }
# Kept, because section 5 REFUSES to book unless this says sandbox.
export ENV=$(jq -r '.data.environment' /tmp/caps.json)
This is the one call to make before any other, on every host you point at. It reports the contract
version, whether you are on sandbox or production, and the live limits — which is why the filter
above keeps limits and quote_ttl_seconds rather than discarding the very thing it tells you to
read. Read it at startup rather than hardcoding anything it tells you.
It answers without a key — so does GET /v1/openapi.json — and that is a reason not to rely on it:
in a hosted certification run, the run reaches only requests that carry your key, and two of its
changes arrive on capabilities alone — a client that reads it keyless never sees them
(CERTIFICATION.md). So the call above sends your key.
currency here is this host's own default, not necessarily your supplier's. The currency your
supplier prices in — the one every price you receive is in — is currency on GET /v1/whoami,
per key: curl -sS -H "X-API-Key: $KEY" "$BASE/v1/whoami" | jq -r .data.currency. Pin that one.
1b · Who are you buying from?
curl -sS -w '\n→ HTTP %{http_code}\n' -H "X-API-Key: $KEY" "$BASE/v1/suppliers" -o /tmp/suppliers.json
jq '.data' /tmp/suppliers.json
# What comes back (in no promised order):
# { "request_id": "req_…", "data": [
# { "supplier_id": "acct_…", "name": "…", "status": "active", "currency": "GBP", "tax_inclusive": true, "payment_models": ["pay_on_arrival", "partial", "prepaid"] },
# { "supplier_id": "acct_…", "name": "…", "status": "active", "currency": "EUR", "tax_inclusive": true, "payment_models": ["pay_on_arrival", "partial", "prepaid"] } ], "meta": {…} }
# ITERATE. `.data` is a list — here it holds two entries, one per supplier — and `.data[0]` is a
# habit that breaks on the second. Read every one of them:
jq -r '.data[] | "\(.supplier_id)\t\(.name)\t\(.currency)\t\(.payment_models // ["-"] | join(","))"' /tmp/suppliers.json
export SUPPLIER=$(jq -r '.data[0].supplier_id' /tmp/suppliers.json) # the FIRST entry, for this walk only — your code iterates (above)
You are reselling somebody's cars, and this says whose. Save the supplier_id against everything
you store from the calls below — the stations in section 2, the offers in section 3, the booking in
section 5 — because each of those objects carries it, and your own tables want it as part of their
key.
🚨 Look at what differs between the two entries. They are different suppliers: a different
supplier_id and a different currency. Their payment_models read alike here, because your sandbox
agreement with each permits every model, so a booking at either may name the payment_model you will
sell in production. What differs is who collects the prepaid part, and on a partial booking how
large it is; a booking that prepays any part states both in its payment object, on
GET /v1/bookings/{ref} (CHANNEL-API.md §2.8). An account opened before the
sandbox gained these terms may still list fewer at a supplier until [email protected] brings
it up to date (CHANGELOG.md, Sandbox fixture data — 2026-10-16) — your own
entries are what applies to you. Under production terms an entry may list a single model, or omit the
member; an absent member means not stated — never an empty list, and never "prepaid". Take currency, tax
convention and payment model from HERE, per supplier — not from
GET /v1/whoami, which describes your key: with a key that reaches both, its top-level currency
is null, because no single currency is true of both, and its books[] says each supplier's. This
list is the one that answers per supplier, whatever your key reaches.
Notice what the loop does: it reads the ids rather than hardcoding one. Keep that habit and a third
supplier costs you nothing on the day it appears — it is one more entry here, and a different
supplier_id on the objects belonging to it. Nothing else in the API changes shape.
2 · Where can you rent from?
curl -sS -w '\n→ HTTP %{http_code}\n' -H "X-API-Key: $KEY" "$BASE/v1/reference/locations" -o /tmp/locs.json
jq '.data[0]' /tmp/locs.json
# What comes back (abridged):
# { "request_id": "req_…", "data": [ { "location_id": "…", "name": "…", "type": "airport", "timezone": "Europe/Athens", "hours": … }, … ],
# "meta": { "next_page": null, "as_of": "2026-09-07T06:55:19.437747+00:00" } }
export LOC=$(jq -r '.data[0].location_id // empty' /tmp/locs.json)
# Read this before pasting the next block: these checks REPORT, they cannot stop an interactive shell.
[ -n "$LOC" ] || echo "!! no locations came back — check \$KEY and \$BASE before going on"
location_id is stable. You map these once and reuse them on every call afterwards. Store each one
with the account it came from — INTEGRATION-GUIDE.md §2.1 says how.
🚨 Map on location_id, or on (supplier_id, code) — never on code alone. A station code is
unique only within a supplier, and the sandbox proves it: both suppliers publish a desk coded
sbx-apt, under different location_ids. Send one supplier's location_id with the other's key and
the request is refused, which is the whole reason the second supplier exists. The same holds for
vehicles: both suppliers publish an ECON, at different prices, with different vehicle_ids.
One more difference worth seeing: one supplier's desks publish iata and a geo coordinate pair and
the other's publish neither. A null geo from one supplier says nothing about the next.
3 · What is available?
curl -sS -w '\n→ HTTP %{http_code}\n' -G -H "X-API-Key: $KEY" "$BASE/v1/availability" \
--data-urlencode "pickup_location_id=$LOC" --data-urlencode "dropoff_location_id=$LOC" \
--data-urlencode "pickup=$PICKUP" --data-urlencode "dropoff=$DROPOFF" -o /tmp/avail.json
jq '.data[0] | {vehicle_id, plan, from_price: .price, protection: .protection.protection_id}' /tmp/avail.json
# What comes back (abridged) — one row per bookable vehicle group (per price plan, when plans are on):
# { "request_id": "req_…", "data": [ { "vehicle_id": "veh_…", "plan": "basic", "display_name": "…", "acriss": "ECMR", "available_units": 5,
# "price": { "amount": 9600, "currency": "EUR" }, "price_type": "indicative", "requires_quote": true,
# "protection": { "protection_id": "prot_…", "tier": "basic", … }, "protection_options": [ … ], "line_items": [ … ], "totals": { … } } ],
# "meta": { "as_of": "…", "rental_days": 4, "plans": [ { "plan": "basic", "served": false }, { "plan": "medium", "served": true }, … ], "withheld": [], … } }
pickup_location_id and dropoff_location_id are required; the two datetimes are full ISO-8601 with
an offset. Use -G --data-urlencode, not a hand-built query string — a literal + in a URL query
means SPACE, so …T10:00:00+02:00 pasted straight into one arrives as …T10:00:00 02:00 and is
refused 422. Encode it, or send the offset as Z. What comes back is an indicative "from" price per vehicle group — good enough for a
results page, and not a bookable price. That is the next call.
Take the first row forward — its vehicle, its protection, and its plan, all three from the same row:
# Same response, no second request.
export VEH=$(jq -r '.data[0].vehicle_id // empty' /tmp/avail.json)
export PROT=$(jq -r '.data[0].protection.protection_id // .data[0].protection_options[0].protection_id // empty' /tmp/avail.json)
export PLAN=$(jq -r '.data[0].plan // empty' /tmp/avail.json) # empty on an account served without plans
# `protection` carries the id on every account; `protection_options` (the ladder) is an opt-in
# enrichment, so both are read in that order. A valid request can legitimately return no availability
# for these dates — this line reports it; read it before going on.
[ -n "$VEH" ] && [ -n "$PROT" ] || echo "!! no availability for these dates — widen \$PICKUP/\$DROPOFF and retry"
.data[0] is "the first thing offered", not "the cheapest" and not "the one you want" — this is a
quickstart, so it takes the first. Choose deliberately in real code.
🚨 A row is a vehicle on one price plan, and its protection is that plan's package. Rows come
ordered by vehicle_id and then plan, so the first one is often not the plan you are served by
default (meta.plans, served: true). Send the row's plan with its protection_id, as the next
section does: a quote without plan is priced on your served plan, and there another plan's package
is refused 422 validation_error with issue unknown_protection.
4 · Turn one choice into a firm price
curl -sS -w '\n→ HTTP %{http_code}\n' -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" "$BASE/v1/quote" -d @- -o /tmp/quote.json <<JSON
{
"pickup": { "location_id": "$LOC", "at": "$PICKUP" },
"dropoff": { "location_id": "$LOC", "at": "$DROPOFF" },
"vehicle_id": "$VEH",
"protection_id": "$PROT",
$( [ -n "$PLAN" ] && printf '"plan": "%s",' "$PLAN" )
"drivers": [ { "age": 30, "role": "main" } ]
}
JSON
# The `$( … )` line adds `"plan": "<the row's plan>",` when section 3 found one, and nothing otherwise.
jq '{quote_id: .data.quote_id, grand_total: .data.totals.grand_total}' /tmp/quote.json
# What comes back (abridged) — 201 on a new quote, 200 on an idempotent replay:
# { "request_id": "req_…", "data": { "quote_id": "a1107968-…", "expires_at": "…", "status": "active", "rental_days": 4,
# "totals": { "grand_total": { "amount": 9600, "currency": "EUR" }, "pay_now": { … }, "pay_at_pickup": { … } }, "line_items": [ … ], … } }
export QUOTE=$(jq -r '.data.quote_id // empty' /tmp/quote.json)
[ -n "$QUOTE" ] || echo "!! no quote_id — read /tmp/quote.json; booking without one cannot work"
A quote is a firm, persisted total with its own quote_id. Book against it and the booking is
confirmed at exactly the quoted price. Quotes expire — quote_ttl_seconds on capabilities tells you
how long you have.
Idempotency-Key is required on the writes in this quickstart — this call, the booking, and
the cancel — and on the booking modify and the webhook redelivery. Send a fresh one per distinct
request, and the SAME one when you retry after a timeout: a retry then returns the stored result
instead of doing the work twice.
⚠️ Note what the commands here actually do: Idempotency-Key: $(uuidgen) mints a FRESH key every
time you paste one, which is right for a walkthrough where each paste is a new intention — and wrong
for a retry. If a call times out and you want to retry it, put the key in a variable first
(export IDEM=$(uuidgen)) and send $IDEM both times. That is the whole mechanism.
⚠️ It is NOT a universal property of every write on this API, and attaching the header does not make an arbitrary endpoint idempotent. The account-management writes — rotating or revoking a credential, re-pointing your webhook, rotating its secret — neither require it nor replay a stored result, so a retried one does the work AGAIN. Check the operation you are calling before you rely on a retry being safe.
5 · Book it
# YOUR reference. `uuidgen` rather than a timestamp: two runs in the same SECOND would otherwise
# collide, and a collision here is the one thing external_ref must not do.
export MYREF="quickstart-$(uuidgen)"
# THE GUARD THAT MUST ACTUALLY BLOCK: this call rents a car, so it is a conditional, not a warning.
# The `rm` matters: without it a refused booking leaves the previous run's file in place and section 6
# would cancel something you did not create today.
rm -f /tmp/booking.json
[ "$ENV" = "sandbox" ] || echo "REFUSING: environment is '$ENV', not sandbox. Nothing was booked."
[ "$ENV" = "sandbox" ] && curl -sS -w '\n→ HTTP %{http_code}\n' -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" "$BASE/v1/bookings" -d @- -o /tmp/booking.json <<JSON
{
"quote_id": "$QUOTE",
"external_ref": "$MYREF",
"drivers": [ { "first_name": "Jane", "last_name": "Roe", "age": 30, "role": "main", "country": "GB" } ],
"customer": { "first_name": "Jane", "last_name": "Roe", "email": "[email protected]", "country": "GB" }
}
JSON
jq '{booking_id: .data.booking_id, external_ref: .data.external_ref, revision: .data.revision, status: .data.status}' /tmp/booking.json
# What comes back (abridged) — 201 on a new booking, 200 on a replay:
# { "request_id": "req_…", "data": { "booking_id": "b9e2ade0-…", "external_ref": "quickstart-…", "status": "confirmed", "revision": 1,
# "quote_id": "a1107968-…", "price": { … }, "pickup": { … }, "dropoff": { … }, … } }
# The cancel needs these two.
# Named for what it HOLDS. $MYREF above is already your own reference — there is nothing to capture
# back — so the only new thing worth keeping is ours.
export BOOKING_ID=$(jq -r '.data.booking_id // empty' /tmp/booking.json)
export REV=$(jq -r '.data.revision // empty' /tmp/booking.json)
[ -n "$BOOKING_ID" ] && [ -n "$REV" ] || echo "!! no booking_id/revision — read /tmp/booking.json before cancelling"
That is a confirmed booking. revision is its version number, and you need it to change anything.
external_ref is YOUR reference for this rental: you choose it, we store it, and the booking paths
take it by default — /v1/bookings/{ref} resolves an external_ref unless you pass
?ref_type=booking_id. We also return booking_id, our own opaque id for the same booking.
🚨 MUTATE BY booking_id. That the default is external_ref makes it convenient
to look a booking up with; it does not make it a safe thing to CHANGE a booking by. Once a booking
exists under a reference, that reference is bound to it for good — cancelling never frees it. But the
reference is a name YOU allocate, and an attempt that never created a booking leaves its value free,
so nothing stops you (or a retry loop, or a reconciliation job) binding it later to a genuinely
different rental. A cancel or a modify addressed by external_ref can therefore, in principle,
resolve to a booking you did not mean. booking_id is ours, opaque and immutable, and cannot. Use yours to FIND, ours to CHANGE. That is why section 5 captures booking_id and section 6
sends it with ?ref_type=booking_id. You already hold your own reference in $MYREF; nothing needs
capturing back.
6 · Cancel it
# Guarded like the booking: cancelling is a write. The guard checks that $ENV is sandbox and that
# $BOOKING_ID and $REV are non-empty — not whose booking they name — so run section 6 in the same
# shell, directly after section 5. ($REV is in the guard so an empty one cannot send malformed JSON.)
rm -f /tmp/cancel.json
[ "$ENV" = "sandbox" ] && [ -n "$BOOKING_ID" ] && [ -n "$REV" ] || echo "REFUSING: ENV is not sandbox, or BOOKING_ID/REV is empty. Nothing was cancelled."
[ "$ENV" = "sandbox" ] && [ -n "$BOOKING_ID" ] && [ -n "$REV" ] && \
curl -sS -w '\n→ HTTP %{http_code}\n' -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" "$BASE/v1/bookings/$BOOKING_ID/cancel?ref_type=booking_id" \
-d "{\"expected_revision\": $REV, \"reason\": \"customer_request\"}" -o /tmp/cancel.json
# Only read a response this run actually produced — without the `rm` and this test, a refusal would
# be followed by the PREVIOUS run's "cancelled", which reads as success.
[ -s /tmp/cancel.json ] && jq '.data.status' /tmp/cancel.json
# What comes back (abridged):
# { "request_id": "req_…", "data": { "booking_id": "b9e2ade0-…", "status": "cancelled", "revision": 2,
# "cancellation": { "fee": { "amount": 0, "currency": "EUR" }, "refund": { "amount": 0, "currency": "EUR" }, "cancelled_at": "…", "reason": "customer_request" }, … } }
expected_revision is the revision you believe you are cancelling. If someone changed the booking
since you read it, you get a 409 instead of silently cancelling something you had not seen. That is
how the writes that CHANGE AN EXISTING BOOKING work — cancel and modify. It is not universal: the
quote and the booking-create above take no expected_revision, because neither has a prior revision
to disagree with.
That really is the integration
Search, quote, book, cancel. Everything else in the pack exists to make those four calls reliable in production — not to make them work in the first place.
What to read next, and what to leave until you need it
Before you go live, in this order:
INTEGRATION-GUIDE.md§2 — sync the reference catalogues, so you are not fetching locations on every search.INTEGRATION-GUIDE.md§5.4 and the contract's §1.8 — idempotency in full, including what a replay returns.WEBHOOKS.md— if you want booking changes pushed to you rather than polled.CERTIFICATION.md— what we check before you go live, and how to rehearse it yourself first.ERRORS.md— every error code and whether to retry it, so a4xxin step 3 is a lookup rather than a guess.samples/— one runnable program per operation incurl, Node and Python (nothing to install), generated from the contract and executed against it before every release; the same programs sit under each operation inREFERENCE.md. SetCHANNEL_BASE_URLandCHANNEL_API_KEYand run one.INTEGRATION-GUIDE.md§1.7 — only if you resell through downstream channels of your own: you name the channel on the search, the quote, the booking and the cancel. This walk-through names none, so it assumes a channel policy ofoff; read yours onGET /v1/whoami.
Faster than reading: the Postman collection shipped beside this file
(channel-api-v1.postman_collection.json) has a runnable request for every operation in the API,
ordered as a flow. Import it, set base_url and api_key — the only two values you fill in — and
press Run: each request captures what the next ones need (the location, vehicle, protection and
extra ids, the quote_id, your booking's reference and revision, the settlement, credential and
delivery ids), so a whole lifecycle executes end to end without your writing a line of code or
copying an id between panes. Run a single request on its own and it uses whatever the requests before
it captured. Two answers on a first run are refusals by design and say so in their own description:
the direct create stops at the price-consent gate because its expected_price is a stand-in, and the
webhook requests need delivery armed.
Generate a client from channel-api-v1.openapi.json, also shipped here. Read the servers list
in the copy served by the host you are pointing at — GET /v1/openapi.json — rather than assuming.
CHANNEL-API.md is a reference manual, not reading. It is long because it
answers precisely, field by field, every question an integration eventually asks. Look things up in
it; do not start with it.
Commercial Overview
Caravel supplies car rental to partners through a direct API: your systems search availability, turn a selection into a firm quote, and create a booking against that quote. Bookings are then modified, cancelled and settled through the same surface. It is one API, one key and one contract — there is no separate search API.
Caravel is the source of truth for availability and price. Every option comes back fully itemised and priced to sell, and a booking is confirmed at exactly the price quoted — if availability moves between the quote and the booking we refuse and tell you why, rather than silently repricing. You never recompute a total, which removes the entire class of dispute about whose arithmetic was right.
What you can sell
You sync a catalogue once and refresh it incrementally: locations, vehicle groups, protection tiers,
extras, fees, and the mileage, fuel and cancellation policies in force for your account. Vehicle
groups carry the industry-standard ACRISS code, so they map onto what you already hold. Protection
tiers carry their own excess and deposit — those are the figures you put in front of a customer. The
currency a deployment prices in, and the languages it can serve, are published live on
GET /v1/capabilities and GET /v1/reference/constants; read them from there rather than from a
document.
A booking is one rental: a vehicle group collected and returned at named locations, one protection tier, any extras you sold, and the itemised total behind them. Bookings are read back, modified, cancelled and reconciled against your own reference.
What the customer pays, and when
Every priced line declares whether the operator has marked it prepaid or pay on arrival in its own product, and the totals carry that same split. Both are payment schedules; neither says who collects. That is the operator's own configuration for its product and is the same for every partner. It is not a schedule for a booking made through you: under a pay-on-arrival agreement none of these rental amounts falls due before pickup, however large the prepaid total is. A cancellation charge is a separate amount with its own basis and timing and may fall due earlier. When the amounts fall due under your agreement, which party collects the money, and on what commercial model, are terms of that agreement rather than things these numbers report. No customer payment is observed anywhere on this API — nothing is collected through it. (Settlement reporting shows what the operator has remitted to YOU, which is a separate business-to-business figure and not a customer payment.) The deposit is a pre-authorisation hold rather than a charge, and sits outside the rental total. Prices are tax-inclusive, with the tax broken out.
That is the shape, not the rule. CHANNEL-API.md §6 is the only statement of how
a total is composed, and it is the one to model against.
What you are owed
Commission is reported through settlement periods: what is expected for the period, what has been
received, any adjustments, and what is still outstanding — outstanding is the other three combined,
not simply expected minus received. The line detail sits behind it, each line carrying your own
reference to join on. Commission rates and the operator's own net are not on this surface.
CHANNEL-API.md §11.
What integrating costs you
JSON over HTTPS, with snake_case fields. The contract ships as an OpenAPI 3.1 document you generate
your client from, and as a Postman collection with every endpoint runnable. You build against a
sandbox running the same code and the same shipped contract as production — with invented test data
only, never a real customer's — then swap the base URL and the key. Which of that contract's routes an
individual host has been switched on for is a property of the host, and each host publishes its own
answer: fetch GET /v1/openapi.json from the host you are integrating against and generate your
client from that copy. Under paths a host lists every contract route it mounts and none it would
refuse. (Two metadata endpoints sit outside that
accounting by design — the document itself and the error catalogue page; CHANNEL-API.md §10 says
which.)
Before going live you certify: a recorded run in which we serve your real client shapes it has not
seen before — an unfamiliar field, an unknown enum member, a re-minted identifier — and observe what
it does. It is your own client exercised, not a form. The run is hosted on your own sandbox account
and must be completed within 24 hours of starting — one session at your pace. You drive it yourself
— there is no window to negotiate and nobody sits in the session with you. A self-serve sandbox
account starts the run from its own account page; with an operator-issued key, email
[email protected] when you are ready, and we start the run on your account and tell you it
is running — your base URL does not change; CERTIFICATION.md has the
procedure. If you would rather we ran it with you, say so and we will (a staffed sitting of up to
120 minutes).
What you build stays built. Within /v1 changes are additive only; anything breaking ships as /v2,
and a field or endpoint is announced deprecated at least 180 days before removal — a notice period
published as a number on GET /v1/capabilities, so you can assert on it rather than on this sentence.
Where to go next
| # | Document | What is in it |
|---|---|---|
| 1 | INTEGRATION-GUIDE.md |
How the integration is built, in order — when to call each endpoint, and why. |
| 2 | CHANNEL-API.md |
The normative contract: every endpoint, every field, every error. WEBHOOKS.md is its companion for delivery behaviour and the signature scheme. |
| 3 | CERTIFICATION.md |
What the certification run involves, every bar you are judged on, and what a pass means. |
| 4 | ERRORS.md |
Every error code, its meaning and whether to retry — readable before you hold a key. |
| 5 | SECURITY-AND-DATA-HANDLING.md |
What we do and do not have on the security and data side, for your procurement review. |
Questions about an integration, an API key or certification: [email protected]. Once you are live, [email protected] for day-to-day booking and operational queries.
The current release of this pack is always online: open https://api.caravelway.com/docs/ and follow the link to this operator's pack.
Caravel Channel API — v1.66.0 Specification
Status: Canonical. This is the authoritative /v1 contract for how the API behaves. Within /v1, changes are additive only — new optional response fields, new enum members, new endpoints — never an in-place breaking change; anything breaking ships under /v2 (§1.2).
Audience: Channel partners integrating with Caravel — brokers, OTAs, agents and metasearch.
Base URL: https://api.caravelway.com
Surface version: 1.66.0 (derived at pack build from the emitted contract) — published live as api_version on GET /v1/capabilities and as info.version in GET /v1/openapi.json. Read it from there rather than from this line. What each version changed is in CHANGELOG.md, newest first, including the additions made under /v1 before this pack was assembled.
This document is the concepts; the lookup is generated. Every operation, object, member and enumeration is rendered from the contract into REFERENCE.md — parameters, request members, each response's data shape, the one example the contract publishes, the errors each operation answers. This document keeps its section numbers so that every § in the pack still lands, and under each heading it says what a schema cannot: what a member means, what absence means, which rules we enforce, and which we merely publish. Where this document and the machine-readable contract could ever differ, the contract (channel-api-v1.openapi.json, shipped with this pack) wins.
Questions while integrating: [email protected] (see INTEGRATION-GUIDE.md for the full support map).
| Document | Normative for |
|---|---|
| This document | Endpoint semantics, the conventions in §1, the rules behind each object, the price breakdown, settlement. |
REFERENCE.md |
Every operation, object and enumeration, rendered from the contract: shapes, members, examples, the errors each operation answers. |
ERRORS.md |
The error.code catalogue with what to do about each, and the details[].issue index. |
WEBHOOKS.md — Webhook Receiver Guide |
Delivery behaviour and the signature scheme: retry schedule and budget, dead-letter and requeue, sequence and gap detection, verification recipes and a static self-test fixture. §7 here defines what an event means. |
CERTIFICATION.md |
Certification: what the recorded run covers, what it deliberately does not, which phases gate the result, and what a pass buys you. |
INTEGRATION-GUIDE.md — Partner Integration Guide |
Build order. It tells you when to call each endpoint and why; it is a reading order, not a second copy of the contract. |
A Postman collection, channel-api-v1.postman_collection.json, ships alongside them with every endpoint as a runnable request, ordered as a flow that carries its own ids from one request to the next (§10).
Contents
- 0. What this is
- 1. Conventions
- 2. Core objects
- 3. Reference data endpoints
- 4. Availability & quoting
- 5. Booking lifecycle
- 6. The price breakdown object
- 7. Webhooks
- 8. Enumerations
- 9. Error catalogue
- 10. Security & operations
- 11. Settlement reporting
0. What this is
Caravel is the source of truth for availability and price. You ask "what is available, and what does it cost?"; Caravel answers with a fully itemised, bookable price — base rate, extras, young and senior driver fees, excess, deposit and taxes — and you book against that exact price. You never recompute a number, which is what removes the entire class of dispute about whose arithmetic was right.
Four layers sit behind that sentence: a reference-data layer you sync once and cache (§3); an availability + quote layer that prices a route and a window and locks the result (§4); the booking lifecycle — create, retrieve, modify, cancel, list (§5); and a webhook layer that, once delivery is armed for your account, pushes your own /v1 writes back to you as signed events, together with the desk-side acts that reach you (§7). Settlement reporting (§11) closes the loop on what you are owed.
Design principles
- Supplier owns price — you never recompute; Caravel returns priced options and re-prices authoritatively at booking.
- Partner-agnostic — the same endpoints, ids and rules serve every integration; nothing here is a special case that can rot.
- Idempotent & retry-safe where it books — quoting, creating, modifying, cancelling and webhook redelivery are safe to retry:
Idempotency-Key+external_refprevent duplicates, and a create response says which mechanism answered (§1.8). The account-management writes are not in that set — §1.8 names the six that are. - Versioned & evolvable —
/v1/path prefix; additive changes only within a major version; a published deprecation policy (§1.2). - ACRISS-based vehicles — the industry-standard 4-char code maps to what you already hold (§8.4).
- Incremental sync for static data —
updated_aton every record,updated_sincewhere a list narrows (§1.10). - Async truth via webhooks — changes made through
/v1, and the desk-side acts §7 lists, are pushed as signed events carrying a monotonicrevision; a desk reinstatement is not, so the list poll (§5.5) stays the loop that catches everything. - Explicit money & time — money is always
{amount, currency}in integer minor units (§1.6); every datetime carries an offset; rental dates are in the location's own timezone (§1.7).
1. Conventions
1.1 Transport
HTTPS only, TLS 1.2+. Content-Type: application/json on every request that carries a body (charset=utf-8 accepted and implied); every response is application/json; charset=utf-8. Field names are snake_case. A body that is not valid JSON, carries the wrong content-type or exceeds the 64 kB body limit is 400 malformed_request.
1.2 Versioning
- Path-based: every endpoint lives under
/v1/…. Breaking changes ship as/v2; within/v1only additive, backward-compatible changes are made — new optional fields, new endpoints, new enum values — and a client MUST ignore unknown fields and tolerate unknown enum values. - Your account is PINNED to a surface version (since 1.40.0) — the version it certified on, reported as
api_version.pinnedonGET /v1/whoami. Three kinds of change exist within/v1and your pin decides which of them reach you:- Additive — a new field, endpoint or enum value. Reaches you regardless of your pin, which is why a client must ignore what it does not recognise.
- A behaviour change — every shape unchanged, a meaning changed (a total that can go up; a list that now carries one entry). Reaches you ONLY when your pin moves, and your pin moves only when you ask us to move it. Every such change is labelled Behaviour change in
CHANGELOG.md. - A removal — does not happen within
/v1at all; see Deprecation below. To move, ask the supplier: they set it, and it can be set back if an upgrade surprises you. Readapi_version.latestfor what is available andupgrade_availablefor whether you are behind — being behind is normal and costs you nothing.
X-API-Versionis accepted for older tooling, never required, and must agree: send1, or send your account's exact pinned version, or omit it. Anything else is409 version_mismatch. It asserts, it never selects — naming a version you are not pinned to does not get you that version, it is refused. It is notapi_version: that is the version of the surface that produced a payload — one to three dot-separated numeric components, onGET /v1/capabilitiesand on every webhook envelope (§7.2) — and is never echoed intoX-API-Version.- A new major is the only thing none of the three above: a rename. A new booking
statusis not one, because that list is open (§8.1). It would appear as a/v2row incapabilities.versions[]before/v1was ever marked deprecated, so you never have to guess whether one exists. - Deprecation: announced at least 180 days before removal via
capabilities.deprecations[]andDeprecation+Sunsetheaders that always agree; the period is published ascapabilities.deprecation_notice_days— assert on the number, not on this sentence. - Responses are open, requests are strict. Response schemas never set
additionalProperties: false— do not re-add it in a generated client or a strict validator. Published enum lists are the values known today, the bookingstatusincluded (§8.1). A request carrying an unknown field or query parameter is422naming the offender, so a typo surfaces on your first call instead of silently mispricing something.
1.3 Authentication
An API key in the X-API-Key header. A key belongs to exactly one account and carries a set of scopes; a call outside them is 403 forbidden — search → quote → book needs read:availability, write:quotes and write:bookings. The scope each operation needs is on its entry in REFERENCE.md.
| Scope | Grants |
|---|---|
read:reference |
The reference catalogues (§3). |
read:availability |
GET /v1/availability (§4.1). |
write:quotes |
POST /v1/quote (§4.2). |
read:bookings |
The booking reads only — the list (§5.5) and the retrieve (§5.2); create, modify and cancel each fail 403 on a key holding only this. |
write:bookings |
The booking lifecycle — create, list, retrieve, modify, cancel (§5); it has always included the reads. |
read:settlements |
Your own settlement statements with this operator (§11). |
read:credentials / write:credentials |
List your own keys (prefix, scopes, status, rotation deadline — never key material) / rotate and retire them. Keep write:credentials off an ordinary booking key. |
read:webhooks / write:webhooks |
Your webhook configuration and delivery log / re-point the callback URL, rotate the signing secret, request redelivery (§7). Neither arms delivery — that stays with the operator. |
read:partner-codes / write:partner-codes |
Read the codes you keep for the supplier's objects / record, change and withdraw them (§3.15). Writing also needs the supplier to have switched self-service on for you; it is off by default. |
- Ask for the narrowest set that does your job. Scopes are not hierarchical: holding one never implies another. One key per job.
- Keys are delivered out of band, never through the API. Two can be active at once for zero-downtime rotation; either can be revoked instantly. A key is issued for exactly one environment: presented to the other environment's base URL it is refused
401 unauthenticatedexactly like an unknown key — when a known-good key is refused, check the base URL before suspecting the key. - Optional source-address restriction: on request the operator attaches IPv4/IPv6 CIDR ranges to your account; a request from outside them is
403 forbidden; an address we cannot resolve counts as outside. Its limits are stated inSECURITY-AND-DATA-HANDLING.md. - Three endpoints need no key, because they carry the contract rather than your data:
GET /v1/capabilities,GET /v1/openapi.jsonand the error catalogue page at/docs/errors. They ignore one you send, so sending it everywhere is safe — and during a hosted certification run it is required: that run serves its changes only on requests that carry your key, capabilities included (CERTIFICATION.md). The published document says the same of capabilities: itssecurityis[{}, {"ApiKeyAuth": []}], no key or yours, so a client generated from it can send the key there.
1.4 Standard request headers
| Header | Required | Notes |
|---|---|---|
X-API-Key |
yes | Your key. |
Content-Type |
on POST/PATCH | application/json. |
Idempotency-Key |
on the six operations §1.8 names — including POST /v1/quote, and NOT on the account-management writes |
A client-generated UUID, unique per logical operation; any UUID version, anything else 422 (§1.8). |
X-Supplier-Id |
optional | Names the supplier a request is about, using a supplier_id from GET /v1/suppliers (§3.10). Never required of a client that never sends it — a promise, not a fact about today: every id you send is unique across suppliers, {external_ref} resolves across every supplier your key reaches (§1.8), and an id-less read or write (a catalogue, a search, a list, your webhook registration, a redelivery) answers for, or applies to, all of them. Send it to narrow such a call to one supplier, or to be explicit. The two 409s that name it (§8 — ambiguous_ref, supplier_required) are reachable only by a client whose key reaches several suppliers and that broke a stated rule first: reused an external_ref across suppliers, or filtered or replayed the delivery log by sequence_from / sequence_to — numbers that count ONE supplier's stream — without naming that supplier. A client that does neither is never asked for the header; a webhook read answers for all of your suppliers (WEBHOOKS.md §7). A value that is not a supplier you sell is 422 unknown_supplier — which is what makes wiring it up now safe rather than risky. |
X-Request-Id |
optional | An opaque correlation id, echoed back; the server mints one if absent (§1.5). Since 1.31.0 the OpenAPI document declares it on every operation as the reusable XRequestId parameter, and declares X-Request-Id and Content-Language as response headers on every response, so a generated client can send and read it. |
Accept-Language |
optional | BCP-47; the languages we honour are locales on GET /v1/reference/constants. Every response states what it served in Content-Language and carries Vary: Accept-Language. Default en. |
1.5 Standard response envelope
Every successful response is { "request_id", "data", "meta" }; the two contract documents, GET /v1/openapi.json and the error-catalogue page, are served raw. request_id is always present (quote it to support) and equals X-Request-Id when you supplied one; data is the payload; meta carries pagination and sync cursors where they apply — an absent key and a null key mean the same thing. Errors use a different envelope (§9).
X-Request-Id MUST be an opaque correlation id — no personal data, no secrets. A request that changes a booking has its request_id written to that booking's append-only audit trail for the life of the record; our data-minimisation sweep can redact the trail's payload fields but never rewrites request_id. A value matching ^[A-Za-z0-9_.:-]{1,128}$ is echoed verbatim; anything else is replaced by a server-generated req_… id. If you cannot guarantee opacity, omit the header.
1.6 Money
Money is always an object, { "amount": 17500, "currency": "EUR" }: amount is a JSON integer in the currency's minor unit (never a decimal, never a string), the exponent comes from ISO-4217 per currency (resolve it per currency rather than hardcoding ×100), currency is ISO-4217 (EUR is the one currency this platform prices in today; yours is currency on GET /v1/whoami, per key — currency on GET /v1/capabilities is only this host’s own operator’s default). Every Money object we return states the currency it is denominated in: pin currency from GET /v1/whoami (§3.8) per credential, and it is the currency every price you receive is in. Prices are tax-inclusive and tax_breakdown decomposes them (§6); since 1.7.0 the convention is on the wire as tax_inclusive — on GET /v1/capabilities, on the policy object and on availability's meta.policies — so branch on it rather than hardcoding either convention. Keep integers end to end; convert to a display value only at the presentation edge. No float arithmetic on money, ever.
1.7 Dates & time
- Every timestamp on the wire —
created_at,updated_at,expires_at,occurred_at,allocated_at,adjusted_at,meta.as_of— is ISO-8601 with an explicit UTC offset (^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$). Parse it as an instant and assume neither a fixed offset nor a fixed precision: responses carry the operator's local offset at the store's precision, a webhookoccurred_atis UTC with exactly three fractional digits, and both satisfy the one pattern. - Rental local times (pickup, dropoff) are experienced in the location's configured timezone (
Location.timezone) — read it, never assume it. A stop takes either an offset-aware instant (at;?pickup=on availability) or thedate+timepair read in the pickup location's timezone — never both. In a body,location_idis required on every stop you send, even a modify that only moves the time (§5.3). dropoffmust be strictly afterpickup, else422 validation_error. The rental duration for pricing is the billed day count of §6.2 in the location's timezone, not a naive subtraction.- The pickup and dropoff instants in this pack's examples are set years ahead so they stay runnable; response-side instants are in the past on purpose.
1.8 Idempotency (retry-safe writes)
Two distinct concepts: the Idempotency-Key header says "this HTTP request may be retried; do not act twice" (a UUID per request attempt group, retained 24h); the external_ref field is your own name for the rental, and how you look a booking up after the key has expired.
Scope, stated exactly — and the one rule you owe us in return. What we ENFORCE is per supplier: an external_ref is unique within one supplier's book ((operator, partner, external_ref)), and an Idempotency-Key replays within one supplier's book ((operator, partner, key)) — and is claimed for your ACCOUNT, so the same value sent for a second supplier is refused, never executed (below). Neither is globally unique — another broker's book may hold the very same external_ref right now, and it does not collide with yours. What we PROMISE is wider than what we enforce: a read, modify or cancel by {external_ref} resolves the value across every supplier your key reaches — in the sandbox, whose one key reaches both of its suppliers, both of them — and never asks you to name the supplier to do it (X-Supplier-Id stays optional, §1.4). That promise holds only if you never reuse an external_ref across suppliers: mint one value per rental, unique across your whole account, exactly as you would for a reference you print on a voucher. A value you did reuse would name two bookings under one key, and that lookup is refused 409 ambiguous_ref (§8) — reachable only with a key that reaches several suppliers, as the sandbox's does. So external_ref alone is a sufficient key for your booking table; store supplier_id beside it because every record carries it (§2.0), not because the pair is needed to tell two bookings apart. Idempotency-Key: one UUID per logical operation, never reused — a value replays only within the supplier's book it was first sent to, and your account claims it there, so the same value sent for a second supplier of your account is refused 422 idempotency_key_reused rather than executed again. (This paragraph supersedes 1.41.0's, which asked you to key on (supplier_id, external_ref) and to mint idempotency keys per supplier: that model would have made every {external_ref} lookup need a supplier selector on grant day — a change on your side, which this contract exists to rule out.)
What we guarantee about external_ref, exactly — because it is a name YOU allocate and we can only promise what we enforce. It is unique per partner account per supplier: within one of your relationships, at most one booking ever holds a given value, and it is bound to that booking for the life of the record — cancelling does not release it, so a later create under the same value returns the cancelled booking (same intent) or 409 booking_conflict (different intent), never a new one. The same value at a different supplier would be a different booking — which is exactly why the rule above forbids you to create one. What is NOT guaranteed is the other direction: a value you sent on an attempt that never created a booking — rejected, abandoned, or lost before it was written — stays free, and nothing stops you binding it later to a genuinely different rental. So external_ref is permanent once bound and only as stable as your own allocation before that. ⇒ Look a booking up by external_ref; CHANGE it by booking_id (§5.3, §5.4), and mint a fresh value per rental rather than recycling one whose attempt you believe failed.
- Six operations require
Idempotency-Key, and they are the list — not a rule you can extend by analogy:POST /v1/quote,POST /v1/bookings,PATCH /v1/bookings/{ref},POST /v1/bookings/{ref}/modify,POST /v1/bookings/{ref}/cancelandPOST /v1/webhooks/redeliver. On each of them the header is declaredrequiredin the OpenAPI document, an absent value is422, and the outcome is stored and replayed. 🪤 The five booking and quote operations additionally require the value to be a UUID (anything else is422);POST /v1/webhooks/redeliverrequires only a non-empty value, which is why the document declares ittype: stringthere andformat: uuidon the other five. Send a UUID everywhere regardless — the looser rule is what that endpoint accepts today, not a shape to build on. The other writes on this API neither require it nor replay a stored result — rotating or revoking a credential, pointing or re-pointing your webhook endpoint, rotating its secret, sending a test event: a retry of one of those does the work AGAIN. Check the operation before you rely on a retry being safe. - A replay of the same key with an identical body returns the stored result without re-executing: the envelope is this request's own,
datais the stored result, and a quote'sstatusis read live (a lapsed quote replays asexpired). The two create endpoints replay200where the first execution answered201; modify and cancel answer200either way, with therevisionthe original produced. - The same key with a different body is
422 idempotency_key_reused; the same key while the original is still executing is409 idempotency_in_progresswithRetry-After— the operation never runs twice concurrently. The same code also answers every write to a supplier, under any key, while that supplier moves your relationship with it into your account (the move that lets one key reach all your suppliers): the write is held until the move completes. Retry it the same way, with the same key and body and with backoff; it runs once the move completes. - After the 24h window, duplicate protection for bookings falls back to
external_ref: a create naming one that exists compares a canonical intent hash — SHA-256 over a sorted-key JSON of exactlypickup.location_id,pickup.at,dropoff.location_id,dropoff.at,vehicle_id,protection_idandextras[]sorted byextra_idas(extra_id, quantity), datetimes normalised to UTC. Customer fields, driver names,notes,flight_number,metaandmodeare excluded. Same hash →200with the existing booking; different hash →409 booking_conflict(modify that booking rather than creating a new one). - Retry only on
429,5xx,409 idempotency_in_progressand network errors — always with the same key. When the response carriesRetry-After, wait at least that many seconds; otherwise back off exponentially with full jitter: before retry n (counting from 0), wait a random time up to 0.5 s × 2ⁿ, capped at 8 s. Never retry another400/401/403/404/409/422. - Overload is
503 service_unavailablewithRetry-After— the service at capacity, its database failing over, a statement it could not answer in time. It is never answered401, so a401is always about the key and never a reason to wait; and never500, which is a fault on our side. A503is not a verdict on your request: retry it as above. - 🚨 A REPLAY SAYS IT IS A REPLAY (since 1.38.0) — read
metaon every create response that CARRIES A BOOKING. Three things can put a booking in aPOST /v1/bookingsresponse and the status code separates only one of them. The envelopemetanames the rest (a replayed error answers in the §9 error envelope, which has nometaat all — there the recorded status and code are the whole answer):- absent /
{}— a first execution. With201, this request wrote the booking. {"replayed": true, "replay_of": "idempotency_key"}— your retry under the same key replayed the stored outcome. Nothing ran twice; no action.{"replayed": true, "replay_of": "external_ref"}— a booking already existed under thisexternal_refwith the same intent, so it was returned and nothing was written. Because the intent hash excludes customer fields, driver names,notes,flight_numberandmeta, anything newer you sent in them was NOT applied. ⇒ Compare the returned booking with what you sent; if they differ, apply the difference withPATCH /v1/bookings/{ref}(bybooking_id). Do not create again — a second create returns the same booking or409 booking_conflict.replayedis absent rather thanfalseon a first execution, so branch on its presence. When one exchange is both — a storedexternal_refmatch replayed under its original key — the value isidempotency_key, because that is what THIS exchange did. Additive: the member set grows within/v1,metastays open, and a client that ignores it behaves exactly as it did before 1.38.0.
- absent /
1.8b meta — reserved namespaces (send these if you have them)
meta is an open pass-through object on both POST /v1/bookings shapes and on PATCH /v1/bookings/{ref} (and its POST …/modify alias): stored verbatim, returned on the booking, excluded from the intent hash, so enriching it on a later modify never causes a 409. Two namespaces are reserved because we read them: meta.billing — the invoicing identity a company customer needs under the operator's e-invoicing regime (tax_id, company_name, address, email) — and meta.partner — your own correlation data, never parsed. Sending them is optional and always will be; reserving them now means nothing is ever asked of you when the fiscal side is switched on.
1.9 Pagination (list & reference endpoints)
Cursor-based, forward-only: ?limit=100&page_token=<opaque> (default limit=50, max 200); meta.next_page holds the next token or null when exhausted; a token is opaque — never construct or parse it.
1.10 Incremental sync (static data)
Eight endpoints are in the sync loop: the six reference collections (§3.2–§3.7), the bookings list (§5.5) and the settlements list (§11.1). Each accepts ?updated_since=<ISO-8601> and returns meta.as_of, the snapshot instant you store and replay as the next updated_since. Three narrow on it — GET /v1/reference/policies, GET /v1/bookings and GET /v1/settlements, each with keyset pagination to follow to exhaustion; the other five accept it and return the full current catalogue in one page, always a superset of the diff, so one loop serves all eight: UPSERT by id, never replace your table with the response, and follow meta.next_page while it is non-null. Seven carry updated_at per record; GET /v1/settlements does not, and meta.as_of is the watermark either way. GET /v1/reference/constants is outside the loop: it takes no query parameter (any is 422 validation_error), its data is one object, and it carries neither updated_at nor meta.as_of — re-read it whole on your own schedule.
1.10a Conditional refresh — ETag and If-None-Match (since 1.55.0)
The full-refresh reads — GET /v1/reference/locations, /vehicles, /extras, /protections, /fees, /terms and /constants (and, since 1.56.0, the rate card, /rates, §3.12, and, since 1.57.0, the stop sales, /stop-sales, §3.13) — answer a conditional request, so polling a page that has not changed costs you no body. Feature code: conditional_get (§3.0).
- The exchange. Every
200carriesETag: W/"…"andCache-Control: private, no-cache. Send the tag back asIf-None-Matchon your next read of the same page. If the page's content is unchanged you get304 Not Modifiedwith no body and the same two headers. A list of tags and*are accepted and the comparison is weak; a tag that does not match, or a header that is not a tag, is simply a200— never an error. A request that is itself refused (422,409,429) is refused as always; a matching tag never turns it into a304. - The tag follows the content, not the clock.
request_id,meta.as_of,meta.changed_as_of(and, under an account key, the two…_by_suppliermaps) and theupdated_atevery row of these pages carries are the instant of the read: they move on every call and are not part of the tag.row_updated_atand every other member are. So the tag holds still while nothing you would store has changed, and changes the moment something has. It is weak for that reason: two200s with the same tag can differ in those clock members. - Per page, per credential. The tag describes the content of the page you asked for: a different key, a different
X-Supplier-Idor achanged_sincewindow returns different content and so a different tag, and a tag from one never confirms another. Under an account key a read of several suppliers has one tag for the union, and if a supplier is withheld (meta.suppliers_unavailable) the tag differs from the complete page's — a partial answer is never confirmed as the complete one. - A
304keeps your copy and your watermarks. It means your stored copy is still current: itsrequest_id,meta.as_ofandupdated_atare those of the read you stored, andX-Request-Idon the304is the one to quote to support. It refreshes neithermeta.as_ofnormeta.changed_as_of: keep replaying the ones you hold, which can only re-read rows, never skip one. - What it saves, and what it does not. It saves the body. It is still a request: it counts against your rate limit and your cost quota exactly as a
200does, and so doesHEAD. Poll at the interval you need, not faster because the answer is cheap. GET /v1/reference/policies(keyset-paged),GET /v1/reference/terms/{document_id}(immutable: a document's bytes never change, so keep it) and the live pricing reads do not take part.
1.11 Rate limiting
Two independent limits, both per account, either of which answers 429 rate_limited with Retry-After: a token bucket for your whole key across every endpoint, and a weighted cost quota (hourly and daily budgets, charged per request by how expensive the call is) checked after the bucket. Every authenticated /v1 response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset — they describe the bucket only, and X-RateLimit-Reset is an absolute Unix timestamp in seconds, not a delay. Pacing off X-RateLimit-Remaining alone is not enough: always honour Retry-After and back off exponentially with jitter.
2. Core objects
Every endpoint composes these objects. Their members — types, constraints, examples — are rendered from the contract in REFERENCE.md › Objects; this section keeps only what a schema cannot say: what absence means, what we enforce, and what we merely publish.
2.0 Supplier
Every rental you sell comes from a supplier — the rental company whose cars, desks, prices and terms you are reselling. Your key reaches the suppliers it was issued for, and GET /v1/suppliers (§3.10) lists them: a key issued for ONE supplier relationship reaches that supplier (production's keys, and a sandbox account approved before one-key provisioning), and a key issued for your ACCOUNT reaches every supplier the account sells for (the sandbox's). That is a fact about how your credential was granted, not a property of the contract: a supplier granted later is one more entry, and nothing in this contract changes shape when it is. (The sandbox has two suppliers, and its one key reaches both — §3.10 says what to do with them.) The contract is written for several, and that is the only thing you need to do differently: read GET /v1/suppliers (§3.10), and store the supplier_id it gives you on every record you keep from this API.
supplier_idis opaque, permanent and yours. It never changes, it survives a key rotation, and it is the same valueGET /v1/whoamireports — asattribution_tokenfor a key that reaches one supplier, and inbooks[]for each supplier of a key that reaches several (§3.8). Two brokers hold different ids for one supplier — deliberate: the id alone links nothing across accounts. (nameis the supplier's real trading name and is the same for everybody, so it is the member that identifies a supplier between people.)- It is on everything you persist: stations, vehicle groups, protections, extras, fees, every availability offer, quotes, bookings (including the summaries
GET /v1/bookingsreturns), settlement statements, and — since 1.42.0 — policies (§2.6) and terms documents (§3.9). - Key your own tables on it. Every id we issue is unique across suppliers, so nothing you hold will collide — but your catalogue, your station mapping, your availability cache and your reconciliation are organised per supplier, and a mapping that assumes one is the thing that breaks when a second arrives. A station
codein particular is unique only within a supplier (§2.1). nameis a label, not a key — free text for your UI, changeable, not unique.- A webhook delivery DOES carry it, since 1.41.0. Every envelope names its supplier in
supplier_id. The member is optional and nevernull: it is absent only on a delivery whose supplier label has not been synced yet, and a delivery is never withheld for lacking it — so group deliveries onoperator_id, which is on every envelope and names the streamsequenceis counted on, and storesupplier_idbeside that counter as the stream's label; a delivery without it is filed byoperator_id, never rejected and never counted as a gap (WEBHOOKS.md§3b says the same, and is normative for delivery). Never key on which callback endpoint the delivery arrived at: one endpoint is one supplier today, but that is a fact about how your credential is granted, not a promise of the contract. (The envelope'sdatais the booking snapshot as it was stored, and it still carries nosupplier_idof its own — read it from the envelope.) - Money facts are per supplier:
currencyandtax_inclusiveare stated per entry, because two suppliers may price in different currencies and under different tax conventions — the sandbox's two already do (EUR and GBP, at different VAT rates).payment_modelsis what your agreement with that supplier permits, and in the sandbox both permit every model (pay_on_arrival,partial,prepaid) and differ in who collects the prepaid part — the operator at one, you at the other — which each booking that prepays any part states in itspaymentobject (§2.8). Where both permit every model, the refusal of apayment_modelyour agreement does not permit (422, issuenot_permitted, §5.1) cannot be produced in the sandbox: you meet it under production terms. A sandbox account opened before the sandbox gained these terms may still list fewer models at a supplier until it is brought up to date; your own entries are what applies to you (CHANGELOG.md, Sandbox fixture data — 2026-10-16). Read these three from §3.10, per supplier.GET /v1/whoami(§3.8) reports the same money facts for your KEY and is correct while a key reaches one supplier; §3.10 is the surface keyed on the supplier itself, so it is the one to build on. statusisactivefor a supplier that is selling — the only value returned today.pausedis reserved and not yet emitted: when it ships, a paused supplier's offers will disappear from search, so what you can sell will still be what search returns and you will not pre-filter on this member.
2.1 Location
A desk or delivery point: an opaque, stable location_id (the only form every other surface accepts), a type (§8.2), address, geo, the timezone every local time at that desk is read in, opening hours, arrival detail (terminal, shuttle_info, pickup_instructions; feature code reference_location_detail) and two service statements, out_of_hours and delivery.
codeis a usable identifier. The operator's own short label ("HER-APT") is set once and never reused — enforced in our database — so you may key a station mapping on(operator, code), never oncodealone; it isnullwherever the operator has not assigned one.iatasays which airport when one has several desks.- Absent and
nullmean "the operator has not stated this" — never "no", never zero, never free.out_of_hours: nullor nodeliverymember is unknown;"available": falseis a positive refusal and carries nofee;deliverywith"available": trueand nofeemeans the price is not stated, not that delivery is free.address.line1is optional per station. - A withdrawn desk leaves the list; its
location_idis then refused exactly like an id that never existed; a re-opened desk returns under the samelocation_idand the samecode, so a code you stop seeing means not currently sellable, never moved elsewhere. "available": falseis a refusal the booking path enforces.out_of_hours: { "available": false }refuses a collection or return outside that desk's operating window.delivery: { "available": false }refuses every end placed at that location if the location prices a delivery at all; if it prices none, nothing there is refused, and the catalogue cannot tell the two apart — so the safe rule is blunt: do not place either end of a rental at a location publishingdelivery: { "available": false }.- The operating window is the desk's own
hoursfor the handover's local weekday, read in thetimezonethis catalogue publishes for that desk. A weekday with no entry is closed all day; aclosebefore itsopencrosses midnight;"00:00"asclosemeans end of day. The refusal, the out-of-hours fee and the billed day count are all judged on that one window — and it is the window that governs, not the charge: a desk that declines out-of-hours handovers without pricing one still refuses them. Wherehoursisnullor empty you cannot derive that desk's window from the catalogue —nullcovers several states, one of which is a schedule we cannot read, where that operator's pricing is failing on the same value. Such a desk still refuses early-morning handovers; a late-evening one is accepted. - A delivery is a property of the location, not of your request, and it is charged per end: each end placed at a location that prices a delivery carries its own
deliveryline at that location's fee, and the two ends add. - The location is the delivery area; the hotel travels in
notes. When an end is at a location that prices a delivery, the hotel's name and address go in the booking's free-textnotes(§5.1), which the desk reads as written. There is no structured hotel or delivery-address field, andmetais not read for it.notesis a string ornullwith no length limit of its own; the 64 kB body limit (§1.1) bounds it (INTEGRATION-GUIDE.md§5.2a). - What you receive. On
POST /v1/quote, a directPOST /v1/bookingsandPATCH /v1/bookings/{ref}the refusal is422 validation_error,details[]naming the offending end —pickup.at/dropoff.atwith issueout_of_hours_not_offered,pickup.location_id/dropoff.location_idwith issuedelivery_not_offered(members inERRORS.md). On a create that consumes aquote_idit is409 availability_changed— judged against the catalogue as it stands when you book. Out-of-hours is judged first, so one response carries one rule's issues; both ends are judged, pickup first. APATCHre-judges out-of-hours on an end whose instant or location moved, and delivery on an end whose location moved plus every unjudged end when the change re-prices a booking into its first delivery charge. - Pre-filter from
GET /v1/reference/locationsand you avoid most of these refusals, never all; keep handling the422and the409. With theavailability_end_termsopt-in (§4.1) search runs the same check.
2.2 Vehicle group (car class)
Rentals are sold as groups ("Fiat Panda or similar"), classified by ACRISS (§8.4), never as a specific vehicle. min_driver_age / max_driver_age are hard gates: a main driver outside the range fails 422 driver_age_out_of_range at quote and at booking. The nine physical attributes and images[] are optional and omitted until the operator has verified them — an absent seats means not stated, never a default. The image host is not part of this contract: fetch each url exactly as given, never construct one.
2.3 Extra (ancillary)
pricing_type is per_day or per_rental; price_cap, always present (null when there is no cap), caps the per-unit charge under both. Countable extras carry quantity bounds and requires_quantity: true; a boolean extra is 0/1. mutually_exclusive_group — e.g. protection upsell extras that can't be combined; string group id, or null. payable — prepaid (the operator has marked this line as prepaid in its own product) or at_pickup (the operator has marked it as pay on arrival). Both are payment schedules; neither names a collecting party. This is the operator's configuration for its own product, the same for every partner, and it is what §6's pay_now / pay_at_pickup sums. It does not say when the rental amounts fall due on YOUR booking — under a pay-on-arrival agreement none of them falls due before pickup however the lines are marked — and it does not say which party collects the money. A cancellation charge is separate, with its own basis and timing. See §6.2. An additional driver is an extra (xtr_additional_driver, per day, countable); an age surcharge is not — young_driver and senior_driver are fees (§2.5) applied per qualifying driver across every entry in drivers[], so send every driver you intend to list or the price you show will be short. supplier_code and ota_code (1.59.0) are two optional codes the supplier states for the accessory, each null until it does. supplier_code is the supplier's own short code (such as CHILD; upper-case letters, digits, ., _ and -, at most 32 characters) for keying your table on something other than our identifier: unique within one supplier, set once and never changed, and never reused for another accessory even after this one is withdrawn. ota_code is the OTA equipment code the supplier maps it to (such as 7); it is the supplier's statement, not checked against any code list, it may be corrected, and two accessories may share one. Neither is code, which still joins a charged line to this record. Both also appear on each extra of GET /v1/availability's meta.extras, which is the same record.
2.4 Protection tier (insurance / excess / deposit)
Excess and deposit are properties of the chosen tier, and a tier belongs to one vehicle group (vehicle_id): read the tiers for the group you are selling, or a protection_id from another group is rejected 422 at quote and at booking. A package also belongs to one price plan (§4.1, Plans): GET /v1/reference/protections (§3.5) lists each group's package on the plan you are served, and another plan's package is published only on that plan's own rows — its availability row and its rate row (§3.12). Take the protection_id from the row you price and send that row's plan with it (§4.2). tier is basic | medium | premium | no_deposit_with_excess, an open list: the first three are nominal — rank a group's options by price and describe them from excess and deposit, never from the word — while no_deposit_with_excess is enforced: deposit zero, excess above zero, and not a rung above premium. Exactly one tier is selected on every booking; protection_id is required on POST /v1/quote and on a direct create — there is no default. price can be a lower bound, and price_basis says so: where it reads "minimum", price is the least that product can cost per billed day — render it as a "from" price and take the exact figure from a quote or a row's totals_for_window; where it is absent (every product today), price is exact. price_percent (since 1.61.0) states the terms behind a minimum price — the share and the per-day bounds the quote applies — so you can work out the charge for a stay yourself; the rule and test vectors are in §3.5. description and coverage detail are not populated. The live figures are always what GET /v1/reference/protections returns — never hardcode them. deposit is a hold, not a charge (§6.4).
2.5 Fee / surcharge rule
Conditional charges resolved at pricing time, computed by Caravel, never by you. Of the eight types a GET /v1/reference/fees record can carry (§8.5), seven are live — young_driver, senior_driver, one_way, out_of_hours, premium_location (a collection surcharge naming its station in applies_when.pickup_location_id), cross_border (the only type that fires on something the customer declares: the optional cross_border parameter on search and the member of the same name on quote and create) and winter_equipment — and additional_location is reserved: published so the enum is stable, never emitted. A live type appears only where the operator configured a rule of it, so an empty result for a kind means this operator does not levy it. applies_when (age band, station, time window) makes every fee deterministic and explainable; an out-of-hours fee is judged per handover, so a rental out of hours at both ends carries the fee twice, as two lines.
2.6 Policies
Served whole by GET /v1/reference/policies (§3.7) — the only place min_rental_days / max_rental_days appear. Availability's meta.policies (§4.1) carries the three display keys (mileage, fuel, cancellation, each source-tagged and resolved with your in-force overrides), deposit_payment_methods, tax_inclusive and the conditions of hire; it is omitted entirely when the operator has stated nothing. A booking carries policies.mileage, policies.fuel and policies.cancellation on every revision as the operator default and without source — empty objects where nothing is stated. So: on availability, branch on whether the object is present; on a booking, on whether a key has a type.
- The booking's copy is the operator default, not your terms.
policies.mileage/policies.fuelbecome your negotiated values on every surface a booking'spoliciesreaches you onceper_broker_booking_display_policiesreadstrue;policies.cancellationis defined as the operator default for the life of/v1— your negotiated cancellation rule reaches a booking ascancellation_policy(§5.1, which names the three cancellation members). Before you sell, read the rule in force from §3.7 ormeta.policies, keyed bysource; where neither reports one, do not read that as free cancellation. - The cancellation detail:
free_untilcarriesfree_until_hours_before_pickupand optionallylate_fee_percent(absent means zero);tieredcarriestiers[{hours_before_pickup, fee_percent}], longest notice first, ending in a band at0;non_refundablecharges 100% of thefee_basisamount.fee_basis—grand_total,pay_noworbase_rate— is what every percentage is a percentage OF, the operator's setting even whensourceisbroker_terms. Notice is counted in whole hours and never below zero.source(operator_default|broker_terms) says whose rule you see; treat absence asoperator_default. - The no-show fee (1.63.0):
no_show—{type: "percent", fee_percent, fee_basis, source}— is present only when a no-show is charged for you:fee_percent(1–100) of thefee_basisamount, the operator's basis even whensourceisbroker_terms. Absent means a no-show costs nothing. Each booking freezes it at creation ascancellation_policy.no_show_fee_percent. - The conditions of hire —
restrictions,licence_rules,deposit_card_rules— are terms, not rules this API enforces: show them to the customer before they pay. Absent and empty are different answers: absent means not stated; an emptyrestrictionsarray states that nothing beyond the general law is prohibited; an emptyinternational_permit_required_forstates a permit is never required.licence_rules(min_years_held, where0is a real answer;international_permit_required_forfrominternational_permit_scopeson §3.1) anddeposit_card_rules(four booleans, all present whenever the object is) are all-or-nothing objects. Therestrictionsvocabulary isrestriction_codeson §3.1; five of its codes are spelled likeProtection.excludesentries and say different things — excluded from cover is not prohibited. Wheredeposit_card_rulesanddeposit_payment_methodsdisagree, treat the question as unanswered and ask us. One statement covers the whole fleet and every partner; it carries nosource. movement_restrictionsis the one member here we enforce.{ policy, forbidden_pairs }or{ policy, permitted_pairs }— exactly one list, andpolicydecides which:forbidden_pairslists the movements this operator does not perform (anything else is bookable);permitted_pairs_onlymeans they perform only whatpermitted_pairslists. Never writeforbidden_pairs ?? permitted_pairs. Every pair is directed — Heraklion → Chania can be refused while Chania → Heraklion is sold — and a rental collected and returned at one location is never restricted, so an emptypermitted_pairsis a real statement (same-location rentals only). A movement the rule forbids is refused atPOST /v1/quote, at both create shapes and on aPATCHthat moves an end:422 validation_error, issuemovement_not_permitted. Absent means the supplier has stated no movement policy and any pair of their published locations can be booked — it does not mean their movements are restricted by some other mechanism, because there is none. This is the cacheable statement to pre-filter routes against;meta.unsellable_movement(§4.1) is the same verdict arriving late.min_rental_days/max_rental_daysare enforced against the billed day count (§6.2) at quote, at a direct create and on a re-scheduling modify; the refusal carriesmin,maxandvalue. A quote minted before the operator tightened the bound fails409 availability_changedon consumption. Whether the bounds reach search is a per-account opt-in, off by default (availability_duration_bounds): with it, an out-of-bounds window returns no vehicles plusmeta.unsellable_window.min_lead_hoursis optional — omitted when the operator publishes no minimum, and absence means none is enforced — and inclusive; it is checked at quote, at a direct create and on a modify that moves the pickup, never on availability, and a quote whose pickup falls inside the minimum before you consume it fails409 availability_changed. Every refusal carriesmin_lead_hours,valueandas_of.tax_inclusiveis a property of our pricing engine, the same on every row, never per-broker, and therefore carries nosource(§1.6).
2.7 Booking (reservation)
The durable object (§5 for the lifecycle). booking_id is our opaque UUID, stable for the life of the booking, carried at the top of every webhook envelope and addressable with ?ref_type=booking_id; external_ref is yours (^[A-Za-z0-9._-]{1,64}$); quote_id is the quote it was created from, or null on a direct booking. revision moves if and only if an event was emitted at that revision: create = 1; each /v1 modify and cancel, a desk-side cancellation (paired with booking.cancelled), an operator-initiated content change (paired with booking.modified carrying change — a shared-fleet displacement, a reason the desk stated, or, since 1.66.0, a desk correction of the booking's price or dates, reason: "operator_adjustment") and a desk-side no_show or completed each add 1. A desk save that changes nothing you were sold — a note, or a car swapped within the class you were sold — adds nothing. A desk reinstatement changes status without a revision or an event, so revision orders what you were sent: apply only what is newer than what you hold, and poll (§5.5) to notice a reinstatement.
2.8 payment — how this booking is paid under your agreement (since 1.27.0)
An optional, additive object stating the schedule for the customer's money on this rental: model (the enum in the contract — pay on arrival, prepaid, partial; the model your agreement set, unless the booking named one you may sell, §5.1); amount_prepaid / amount_on_arrival, which always sum exactly to price.totals.grand_total and are recomputed as the booking's total moves — each a Money object in grand_total's currency when the supplier book answering the request is pinned at 1.65.0 or later, and a bare integer in minor units when that book is pinned below it (a Behaviour change, §1.2; the pin is held per supplier you book, books[].api_version.pinned on GET /v1/whoami for a key that reaches several; see the CHANGELOG's 1.65.0 entry); who_collects, an object keyed by portion (prepaid: operator or partner; on_arrival: always the operator) with each key present only when that portion exists; settlement_basis, gross or net — the basis only, never a commission rate or amount. It is not price.totals.pay_now — that is the operator's product-level split, identical for every partner; this is your relationship's schedule. It is not what either party owes the other — that is GET /v1/settlements; no money moves through this API. Absent means "we are not saying" — a booking made before it existed, sold under no commercial term, or whose term cannot be shown to have been chosen by a person — never "nothing is prepaid".
2.9 distribution_channel — the downstream channel a booking was sold through (since 1.51.0)
For a partner that resells through channels of its own (a comparison site, an agency, another reseller) under an agreement that approves each one. {distribution_channel_id, code, approval}: code is yours, as you sent it (^[A-Za-z0-9._-]{1,64}$); distribution_channel_id is the operator's register entry it matched (§3.11), null only when it matched none; approval is the register's verdict when it was recorded — approved, or, only where your enforcement is flag, unregistered · proposed · suspended · deactivated · not_yet_effective · expired. Present only when one is recorded, on the booking, the list and every webhook payload; a settlement line carries it as distribution_channel, null when none. Whether you must send one is your distribution_channels.policy on §3.8 — off (the default: sending one is 422, issue not_permitted), optional, or required (a create without one is 422, issue required) — and enforcement says what happens to a code the register does not approve for today: refuse (422 distribution_channel_not_approved, nothing written) or flag (booked and recorded with its verdict).
Testing your channels in the sandbox. On your sandbox account page (Test distribution channels) you can register your own test channel codes — each approved at once, in the sandbox only — deactivate them, and set your policy (off · optional · required, with refuse or flag). Your first code at a supplier turns channel codes on there (optional, refuse). Every rule in this section then applies exactly as in production. In production, the operator registers your channels and approves each one in writing.
One key, the channel on every core request, a price per channel (since 1.54.0). Name the channel on the search (§4.1, distribution_channel=<code>), the quote (§4.2, distribution_channel: {code}), the booking and — as an assertion — the cancel (§5.4); a single credential then serves all your channels. The search and the quote are judged exactly as a booking is (the same policy, enforcement, codes and issues), and are priced from the operator's prices for that channel where it keeps separate ones for it, else from your account's own — the figure you receive is the supplier's price for that request either way, and you never need to know which. The page echoes the channel as meta.distribution_channel and the quote as distribution_channel, both in this section's shape and only when you named one. A quote is bound to its price: a booking made from a quote that named a channel must name the same channel. A quote that named none was priced from your account's own prices, so a booking made from it may name a channel priced from them too — one the operator keeps no separate prices for, as in 1.51.0 — but not one it prices separately. Anything else is 422 validation_error, issue quote_channel_mismatch, and nothing is written. A request that names no channel is answered exactly as before 1.54.0.
3. Reference data endpoints
All are GET. GET /v1/capabilities (§3.0) needs no key, though you should send yours (§1.3); GET /v1/whoami (§3.8) needs a key but no scope; every other endpoint here takes read:reference. The catalogue reads vary by credential, so they are Cache-Control: private, no-cache: store them, revalidate every time, with ETag and If-None-Match (§1.10a); GET /v1/reference/policies is not conditional, and a terms document fetched by id (§3.9) never changes. §3.8 varies by credential too and is never stored. The collection endpoints (§3.2–§3.7 and the terms list, §3.9) take updated_since and the pagination parameters (§1.10); GET /v1/capabilities and GET /v1/reference/constants take none, and neither do the rate card (§3.12) and the stop sales (§3.13), which are read whole. Sync the catalogues once, then refresh on your own schedule.
activeis alwaystrue, and you do not filter on it. A withdrawn product is not served at all.- Withdrawal reaches you as an absence, and an absence is not a deletion — except on the rate card (§3.12) and the stop sales (§3.13). A full-catalogue collection returns the complete current set on every call, so a record you hold can simply stop appearing: keep it, keep it quotable, and never read absence at all from a collection that narrows. The authoritative signal is the attempt: an id that has really been withdrawn fails
422 validation_errorat quote or direct create (naming the field and the id), or409 availability_changedon a quoted create whose catalogue moved. Stop offering an item once its quote fails that way — a shrunken refresh on its own is not that instruction. Certification exercises exactly this in both directions (CERTIFICATION.md).
3.0 GET /v1/capabilities — who you are talking to
No key needed and no scope — though send yours (§1.3). Read it once at integration time and again at startup: it names the surface version (api_version), pins the tax convention (tax_inclusive), publishes quote_ttl_seconds, idempotency_retention_hours, deprecation_notice_days, the pagination and row-cap limits, the announced deprecations[], forward_compatibility (how to treat unknown fields and members, and the extensible_enums[] paths), the webhooks block (enabled, signature, event_types[], and schemes[] only during a scheme migration), versions[], and the feature codes this document tells you to detect. api_version, versions, features, webhooks and limits are guaranteed present; treat every other member as optional.
environmentanswersproductionorsandboxand is the cheapest guard against pointing a client at the wrong host: write the check as present-and-wrong ⇒ stop, never missing ⇒ stop — a document assembled in-process omits it.features[].enabledis deployment-wide, not per-account. Ignore a code you do not recognise. Three availability behaviours are additionally enabled per account —availability_duration_bounds,availability_end_terms,availability_movement_restrictions— and their codes answer for you in neither direction;GET /v1/whoami(§3.8) says which is on for you.currencyis the currency this host's own operator prices in — the deployment default, served however many suppliers the host carries (absent only if the host could not read it at boot). It is not necessarily your supplier's: that iscurrencyonGET /v1/whoami(§3.8), per key, and thecurrencyon every Money you receive. Pin the whoami value, per credential.
3.1 GET /v1/reference/constants
The machine-readable, authoritative form of every enumeration in §8 — booking statuses, fee types, extra categories, location types, payable timings, protection tiers, fuel policies, transmissions, fuels, mileage types, currencies, locales, the ACRISS position legends (the same table acriss_decoded is built from), restriction_codes and international_permit_scopes. Build against this, never against prose. It takes no query parameters (any is 422 validation_error), data is one object, and there is no meta.as_of. fee_types is one entry wider than the rules GET /v1/reference/fees serves: delivery is charged through a location's delivery.fee (§2.1), never as a fee record.
3.2 GET /v1/reference/locations
Map your own station identifiers to these location_id values once, then send the location_id everywhere. Membership is not permanent: a closed desk leaves the list and its id is refused everywhere; a re-opened desk returns under the same id (§2.1). Your sync loop upserts and never deletes, so treat "no longer in the current list" as "stop offering it", and keep this list on the §1.10 loop rather than fetching it once.
3.3 GET /v1/reference/vehicles
The fleet as ACRISS-classified groups (§2.2), for display and mapping; price is contextual and lives in §4.
3.4 GET /v1/reference/extras
The extras catalogue with its pricing rules (§2.3).
3.5 GET /v1/reference/protections
The tiers, with excess and deposit, scoped per vehicle group (§2.4) — on your served plan only. Where your account holds several price plans, the packages of the others are not here; each is on its own plan's availability and rate rows, which is where to read the protection_id you quote.
A product priced as a share of the rate — price_percent (since 1.61.0). Where price_basis is "minimum", price_percent states the rule the quote applies: { "share_bps": 1500, "min_per_day": Money, "max_per_day": Money }. share_bps is a whole number of basis points (1500 is 15%); min_per_day and max_per_day are present only where the supplier set that bound: an absent member is no bound at all, and a floor of 0, which can be stored, is present and never binds. It appears on exactly the objects that carry price_basis: this read, the protection of a rate row (§3.12) and the protections embedded in an availability row; a product without price_basis carries none. Take the terms from the rate row you are pricing from. A rate row's protection states the terms of the price list that row prices, which is the one the quote at its stations applies; this read and an availability row state the default price list's, and wherever a station keeps its own price list (§3.12) the same protection_id can carry different terms there. For a stay of N billed days (§6.2) whose base total is B — the base_rate line of the quote's breakdown, before extras and fees, which the rate rows of the stay give you as totals[N] for N up to 7 and N × extra_day beyond, blended as cross_period says (§3.12, read for the row's supplier) where the stay spans more than one rate period — the charge is:
share = round_half_up(B × share_bps ÷ 10000)— in whole minor units, one rounding, applied to the whole stay and never to a day.charge = max(share, min_per_day × N)wheremin_per_dayis present; thencharge = min(charge, max_per_day × N)wheremax_per_dayis present.
The protection line of the quote carries charge as its total; its unit_price is charge ÷ N rounded half up, for display, so quantity × unit_price need not equal the total — read the total. Compute in exact integers. The quote is still the authority, and this does not retire the principle that the supplier owns price (§0): it is arithmetic for showing a figure, and a quote and a booking re-price authoritatively. It lets you show a total without a call, and a stay the rate row does not price (a null cell, a length outside rental_days) is refused at the quote whatever this arithmetic gives. Vectors your implementation must reproduce (minor units; — means the member is absent):
| B | share_bps |
min_per_day |
max_per_day |
N | charge | what it shows |
|---|---|---|---|---|---|---|
| 15269 | 1500 | — | — | 3 | 2290 | a plain share (15% of 152.69 is 22.90) |
| 301 | 5000 | — | — | 1 | 151 | a half rounds up: 150.5 is 151, not 150 |
| 1050 | 1500 | — | — | 1 | 158 | a half rounds up: 157.5 is 158, not 157 |
| 12000 | 1000 | 500 | — | 4 | 2000 | the floor binds: 1200 is below 500 × 4 |
| 60000 | 1500 | — | 700 | 10 | 7000 | the cap binds: 9000 is above 700 × 10 |
| 8000 | 1000 | 500 | 900 | 4 | 2000 | both set, the floor binds |
| 25000 | 2000 | 800 | 2000 | 2 | 4000 | both set, the cap binds |
| 47600 | 1200 | 400 | 1000 | 7 | 5712 | both set, neither binds |
3.6 GET /v1/reference/fees
The surcharge rules (§2.5), so you can say "a young-driver fee may apply" up front; the applied amount is always a figure we compute and you read — on the availability row's fee lines (with type, and fee_id wherever the fee has a record here), on a quote and on a booking.
3.7 GET /v1/reference/policies
The whole policy object (§2.6) and the only place min_rental_days / max_rental_days appear — fetch it here and cache it. Availability's meta.policies is the same values in a smaller envelope, authoritative where it appears.
3.8 GET /v1/whoami — your own account
The one endpoint that answers for you: a key and no scope, no account selector, so a key reads its own account only. Read it when a response surprises you — fewer vehicles than expected, an unexpected 403, a webhook that never arrived. Do not cache it: Cache-Control: no-store (no-store means no cache at all, shared or private) and Vary on the key header (there for an intermediary that ignores the first directive, not a licence to key on it); hold the values in your own state per credential and re-read when we tell you something changed. data is one object, the Account schema in REFERENCE.md; every member is always present: attribution_token (an opaque, permanent identifier for your account with this operator — different for two accounts with the same supplier, so it never reveals that two credentials share an operator; null for a key that reaches several suppliers, whose books[] carries each one's), scopes[] (the exact set the server tests — test membership, never equality), opt_ins (accepts_supplier_price, availability_duration_bounds, availability_end_terms, availability_movement_restrictions, availability_plans — this is where you learn which opt-in is on for you), per_broker_policies[] (the keys where your negotiated terms are in force today; empty is the ordinary case), webhooks (enabled for your account, and first_enabled_at, non-null while enabled is false for an account switched off after having been on), and currency (since 1.35.0) — the ISO 4217 code your supplier prices in, resolved from the key: the currency of every Money you receive and the one to pin, per credential (capabilities.currency, §3.0, is only this host's default); for a key that reaches several suppliers, the code they all price in, or null when they differ (the sandbox's two do) — each Money then states its own, and books[] and GET /v1/suppliers say each supplier's — and api_version (since 1.40.0), the object that says which version of this API your account is served at: pinned (yours), latest (this deployment's newest, the same number capabilities.api_version publishes) and upgrade_available (simply the two differing). Log pinned at start-up. It is the single fact that tells you which behaviour you are getting: a change labelled Behaviour change in the changelog reaches you only once your pin is at or above its version, while everything labelled Additive has already reached you (§1.2). Every member here describes your key. While a key reaches one supplier the money members equal that supplier's, and opt_ins and webhooks are that relationship's. For a key that reaches several (since 1.45.0), each member is the reading true of every one of them — a flag true only where it is true for all, a value null where they differ — and books[] states each supplier's own. Per-supplier truth lives there and on GET /v1/suppliers (§3.10), and nothing on this route changes shape when a supplier is added. Rate-limit and cost-budget numbers are deliberately not published here or anywhere; the X-RateLimit-* headers remain the contract. Feature code whoami.
3.9 GET /v1/reference/terms and /v1/reference/terms/{document_id} — the supplier's terms, as documents
You are the trader your customer contracts with, so you need the supplier's terms before the order and must be able to show, months later, which version you showed. The list returns the current document per language (withdrawn_at always null there, no body); a change always mints a new document_id, never mutates one, so comparing ids tells you what to fetch; language is the language the document is written in, not a translation hint; version is the supplier's own number, not this API's. The single document returns the body and keeps answering after the document stops being current: withdrawn_at null means it is still current for its language; a timestamp means it must not be presented as the terms of a new sale, while remaining perfectly valid as the record of an old one. The bytes for an id never change (ON DELETE RESTRICT, an app role with no DELETE, a trigger that refuses one; erasure does not touch it); content_hash is the SHA-256 of the canonical serialisation; format: clauses/v1 is a tree of numbered clauses of plain-text runs, never markup. Capture the id you SHOWED, when you showed it, and store it against every booking you sell. Falling behind on the list can leave you presenting terms no longer current; it can never leave you unable to produce the terms you sold under.
3.10 GET /v1/suppliers — who you are buying from
The first call of an integration, and the companion to §3.8: that one answers who you are, this one who you are selling for. A key and no scope, no selector in any position — a credential reads its own suppliers and nothing else. data is an array (one entry per supplier your key reaches; in the sandbox, two) of the Supplier schema in REFERENCE.md: supplier_id, name, status, currency, tax_inclusive, and payment_models where your agreement can be stated. Do not cache it: Cache-Control: no-store and Vary on the key header, like §3.8; hold it in your own state per credential. Treat the response as a list even while it holds one entry — read the id from here rather than writing it into your configuration, and a supplier added later is an extra entry and a different supplier_id on the objects it owns, with nothing else in this contract changing. meta.next_page is always null; the collection is not paged. Feature code suppliers states that the route is served — it never says how many suppliers you have; this endpoint is the only thing that answers that. One refusal is worth knowing about: if a supplier's own record is in a state this API cannot describe truthfully (an operator suspended platform-side while its keys still work), this route answers 500 internal_error rather than inventing a status for it — every other endpoint continues to answer normally, so a 500 here and 200s elsewhere means exactly that. Feature code suppliers.
3.11 GET /v1/distribution-channels — the downstream channels your agreement approves (since 1.51.0)
The operator's register of your downstream channels, as it stands: distribution_channel_id, code, name, status (proposed · approved · suspended · deactivated), markets (ISO 3166-1 alpha-2), effective_from / effective_until, and approved_at while approved. Only an approved channel inside its window may sell — read it before you name distribution_channel.code on a booking (§2.9). A key and no scope, like §3.8 and §3.10: a credential reads its own register. Paged and in-loop like the reference lists (§1.9, §1.10): follow meta.next_page, then store meta.as_of as your next updated_since. An entry is never removed; a channel you may no longer sell through is one whose status says so. Not cached: Cache-Control: no-store, Vary on the key header.
3.12 GET /v1/reference/rates — the rate card (since 1.56.0)
The prices a supplier sells from, as a table you can hold and refresh: one row per vehicle group, rate period, plan and set of stations, with the total for 1 to 7 rental days and the price of every day after the seventh. It exists so you can show and compare a price without a search per date. It is a rate card, not a promise: it says what a rental costs if the vehicle is free, which is never a statement of availability (§4), and a stay is priced by POST /v1/quote (§4.2), which is what you book on. Where a station has its own price list, this read and the quote use it; GET /v1/availability currently prices every station from the supplier's general list — so for such a station a search can show a different base price from the rate card and the quote. Book on the quote's figure. The read is metered as three requests' worth of cost quota (§1.11), where a flat reference read counts as one, because its cost grows with the number of stations a supplier has. A row you build a total from agrees with that quote for every length it prices and the supplier accepts: a null cell is refused by the quote, and a length outside the supplier's rental-length limits (rental_days, below) is refused whatever the card says — rows are the card, not the card clipped to those limits.
Replace, do not upsert. This is the one reference read where an absence is a deletion. Rows have no id: a station that moves to its own price list, a season that ends, a group that is withdrawn all leave by not appearing. Replace your whole table for each supplier_id from each response. meta.collection says so on the wire: { "semantics": "snapshot", "replace_by": "supplier_id", "scope": { "plan": …, "distribution_channel": … } }. scope echoes the slice you asked for: replace, per supplier, only the rows of that slice — a read narrowed by plan or distribution_channel is not the whole card, and replacing a supplier's whole table from it would delete the rest. The suppliers read are the keys of meta.pricing_by_supplier: a supplier there with no rows has nothing on sale — clear what you hold for it. Do not replace a supplier that is named in meta.suppliers_unavailable — its rows were not read, and their absence is not a deletion. That includes a supplier that does not price the distribution_channel you named (each supplier holds its own agreement for you; one whose policy for you requires a channel refuses a read that names none): it is named there, and the read is refused (422, naming a supplier) only when every supplier that answered refused. The read takes plan and distribution_channel and nothing else; limit, page_token, updated_since and changed_since are refused 422 (unknown_parameter) rather than ignored, and meta.next_page is always null. Conditional refresh works as for the other catalogues (§1.10a).
A row:
vehicle_id— the group, the same surrogateGET /v1/reference/vehicles(§3.3) publishes. That read lists the groups of your default price list, so a group sold only at a station with its own price list, or only under another plan, can appear here and not there: it is priced here and bookable at a quote.plan,default_plan— the protection package the rate includes (§2.4), and whether the row's stations are served that plan when you name none. Send the row'splanon the quote (§4.2): without it the quote prices the supplier's default plan, which can be a different row. Where a station has its own price list for a plan that is built but not yet the served one, you get two rows for that station and plan: one withdefault_plantrue (what a quote that names no plan charges) and one with it false (what a quote that names the plan charges). A partner that has not opted in to plans (opt_ins.availability_plans, §3.8) is shown its served plan only.valid_from,valid_to— the rate period, inclusive. A period that has ended is not listed. A stay is priced from the period its pickup date falls in unless the supplier prorates (pricing.cross_period, below).stations—{ "all": bool, "location_ids": [...] }: the stations this row is the price at, aslocation_id(§3.2), in ascending numeric order;allis true exactly when the row's stations are every station the supplier sells from. Never "all except": a station with its own price list replaces the general one wholesale, so it is named on its own row, and a group the station does not price is not sold there.totals["1"]…totals["7"]— the total for exactly that many billing days, as Money.totals["7"]is seven timesextra_day.extra_day— the price of each billing day beyond the seventh; a stay of N ≥ 7 days costsN × extra_day.- Any of these is
nullwhen the supplier has no price for that length, and anullis not €0: that length cannot be quoted or booked. Never read an absent price as free. protection— the plan's package:protection_id(the same surrogate/v1/reference/protections, §3.5, publishes — that read, too, lists the default price list's products, so another plan's id can appear here and not there),pricing_type(included: nothing is added to the total;per_day:priceper billed day on top),price,excess,depositand, where the supplier states one,theft_excess. A package priced as a percentage of the rate carriesprice_basis: "minimum"—priceis the least it can cost per day — and, since 1.61.0,price_percent: the share and the per-day bounds of the price list this row prices, which the quote at its stations applies. With the base total of the stay (totals,extra_day) you can compute the charge for a stay by the rule in §3.5; the quote is still the authority and gives the exact figure.
meta.pricing_by_supplier states, per supplier, how to turn a row into a total, and is keyed by supplier_id for a single key as for an account key — read it as meta.pricing_by_supplier[row.supplier_id]: billing_day (24-hour days, grace_hours after which an extra day is billed, counted on the location's own clock), rental_days ({ min, max } in billing days — the limits GET /v1/reference/policies publishes, which a quote enforces; null where the supplier publishes none), lengths_beyond_7, cross_period (per_night_proration: a stay that crosses a rate period is priced night by night — for each night (a calendar date from the pickup date up to the day before the dropoff date) take the card of the period it falls in and the figure for the stay's own length (totals[N], or extra_day for 7 or more), add them, divide by the number of nights and round half up once, for 7 or more days multiplying the day price by the days before the division; pickup_date_card: priced wholly from the pickup date's period; a night with no period, or no price for that length, makes the quote refuse), tax_inclusive and excludes — what no row includes, today extras and fees. Grace hours, currency and proration belong to each supplier: never apply one supplier's to another's rows. Feature code rates_read.
3.13 GET /v1/reference/stop-sales — the days a group is closed (since 1.57.0)
The stop sales that close a vehicle group you can sell, as a table you can hold and refresh. A stop sale closes a whole group, at every station (a group is never stopped at one desk and open at another), so a row carries no station list beyond stations.all, always true, which exists so a later, narrower scope is an addition and never a change. A rental is refused because of a row when its pickup date is on or before end and its dropoff date is after start: a rental that returns on the start day is not refused, one that picks up on the end day is. The dates are the operator's own calendar dates, inclusive: the pickup and dropoff date are read off each instant you send on the operator's clock, not the station's, so a station on another clock does not move a date.
It lists closures, not availability. A day that is not listed can still be refused: fleet occupancy beyond what the supplier has closed, lead time, the rental-length limits, out-of-hours, delivery and movement rules each decide at the quote, which is what you book on (meta.rules_by_supplier[*].also_refused_by says so on the wire). Never read the absence of a row as a promise of a car.
Replace, do not upsert — as on the rate card (§3.12): an absence is a deletion, because a lifted stop leaves by disappearing. Replace your whole table for each supplier_id from each response; the suppliers read are the keys of meta.rules_by_supplier (a supplier there with no rows has nothing closed), and a supplier named in meta.suppliers_unavailable was not read — keep what you hold for it. meta.collection says so on the wire. The read takes no parameter: limit, page_token, updated_since, changed_since and everything else are refused 422 (unknown_parameter), and meta.next_page is always null. Conditional refresh works as for the other catalogues (§1.10a). It is metered as three requests' worth of cost quota, as the rate card is (§1.11).
A row: vehicle_id (the group, the same surrogate the rate card publishes — and, as there (§3.12), /v1/reference/vehicles lists only the groups of your default price list, so a group sold only at a station with its own price list or under another plan can appear here and not there), stations ({ "all": true }), start, end (inclusive), source (manual: the operator stopped it; sold_out (since 1.58.0): the group has no unit left to sell; more reasons will be added, so treat an unknown value as a closure of unknown cause). Only groups you can sell appear — the groups the rate card prices for you, at any station and under any plan. A stop that ended before today is not listed; one already running is listed whole, from its stored start.
Sold out (since 1.58.0). A group with no unit left on a day is closed for you on that day: consecutive such days are one row with source: sold_out, and a day already listed as a stop is not listed a second time. "No unit left" is the engine's own measure — on that day every active model of the group is booked out, stopped, or withheld from partners; a group with no active model is not listed at all, and a group whose models hold no units is closed for the whole horizon. It is computed for meta.rules_by_supplier[*].sold_out_horizon_days (400) days from today on the operator's clock, through sold_out_through: a run that ends on that date may continue beyond it, a run that starts today may have started earlier, and a sold-out day past the horizon is not listed. These rows are for partners only: the supplier's own website keeps selling its fleet. A booking with a date the supplier's system cannot read as a date is left out of the count (the group then looks freer, never fuller).
What a row promises. As of meta.as_of, a new rental that covers a day of a row is refused by the quote and by the booking, so a row never closes a rental the quote would accept at that moment. Three things are outside that: a modification of a booking you already hold can still be confirmed over a closed day; a snapshot is a moment, so a cancellation (or a withheld car being released) reopens a day until your next read; and a row says nothing about a day it does not list.
Where this list is conservative, stated rather than hidden. It can leave open a day the quote refuses; it does not close one the quote accepts. A supplier stops a model; a group is published as stopped over the days on which every active model in it is stopped. (1) A group whose model A is stopped on one day and model B on the next publishes no stop, yet a rental across both is refused at the quote, because each car has a stopped day inside the span. (2) A stop whose date is not a real calendar date, or whose first day is after its last, is left out of the list although the engine still reads it. (3) A model withheld from brokers for a data-coherence reason is not counted as a stopped model (it is not a stop the supplier made); a sold_out row covers such a day. (4) A rental can be refused although every day it covers has a unit free, because no single car is free for the whole span; and bookings the supplier has not assigned to a car are not counted, so a day they exhaust is not listed. The quote decides.
Which groups. Those the rate card prices for you with no distribution_channel. A group priced only inside a channel's own book set is not listed, nor are its closures; the quote refuses it as ever. Feature code stop_sales_read.
3.14 Your own codes — partner_code (since 1.60.0)
A partner that keeps its own table keyed on its own codes — a "Vehicle Code", a "Product Code", a station code, an accessory code — can have the supplier record its code for each of the supplier's objects, and every reference row that carries the object returns it. The codes live in a map the supplier keeps per supplier and per partner: they are the supplier's record of what you call its objects, never a column of its catalogue, and no other partner sees them.
| Where | Member | Names |
|---|---|---|
reference.vehicles |
partner_code |
the vehicle group |
reference.protections |
partner_code |
one protection tier of one group |
reference.extras |
partner_code |
the accessory |
reference.locations |
partner_code |
the station |
reference.rates row |
vehicle_partner_code, plan_partner_code |
the group, and the plan (your "Product Code") |
reference.rates row |
stations.location_partner_codes |
each station of stations.location_ids, in the same order |
reference.stop-sales row |
vehicle_partner_code |
the closed group |
reference.fees row |
pickup_location_partner_code, dropoff_location_partner_code |
the station applies_when names — beside applies_when, never a member of it, because applies_when is the engine's condition vocabulary |
Present means mapped. If the supplier has recorded no code for you, none of these members is emitted — you read the wire you read before 1.60.0, member for member. Once it has recorded any, the members appear on every row they apply to, and null means no code for this object. A code is exactly what the supplier recorded — 1 to 128 characters, no control character, no leading or trailing whitespace — stored as sent, never trimmed or case-folded. The protection tier the operator-wide included cover has no group and is always null.
It never changes identity. vehicle_id, protection_id, extra_id and location_id identify the object and are the only ids you send back; a code is a label you hold and the supplier can correct. partner_code is not code (the supplier's own label, which joins a charged line to its record).
Where the members are, and are not. On the reference reads in the table above, and nowhere else: the accessories and protections embedded in GET /v1/availability, and the protection inside a rates row, do not carry partner_code — key your table from the reference pages.
The grammar of a code. The supplier records it as you gave it: 1 to 128 characters, no control character, no leading or trailing whitespace, case-sensitive, never trimmed or folded. A supplier may give two objects of different kinds the same code (a station and a vehicle group may both be HER); within one kind, one code names one object.
Per supplier. Under an account key each supplier's rows carry that supplier's codes for you — two suppliers may give the same code to different objects, and one may record none while the other records many. Key your table on (supplier_id, partner_code).
3.15 Recording your own codes — /v1/partner-codes (since 1.62.0)
§3.14 describes the codes the supplier holds for you. A supplier can let you keep that map yourself: read every object you can see together with your code for it, and write, change or withdraw your codes by API. It is the same map, the same members on the same rows, and the same grammar of a code; this section is how it is edited.
It is off until the supplier turns it on. The supplier switches self-service on per partner and per supplier; the default is off, and only the supplier can turn it on or off. Until then PUT answers 403 forbidden with the issue partner_codes_not_enabled, whatever the body is — a partner that may not write learns nothing about whether what it sent was well formed. (That 403 is not a missing scope: you hold write:partner-codes and the supplier has not yet said yes.) GET works regardless, and meta.self_service.enabled on its JSON form says which it is. Feature code partner_codes says the deployment serves the endpoints; it does not say your account may write.
| Operation | Scope | Does |
|---|---|---|
GET /v1/partner-codes |
read:partner-codes |
Lists the objects you can read, each with your code (null = none). JSON, or Accept: text/csv. Takes no query parameters. |
PUT /v1/partner-codes |
write:partner-codes |
Sets, changes or withdraws codes. JSON (entries) or Content-Type: text/csv. ?dry_run=true checks the request and writes nothing. |
A row. { kind, id, name, code }. kind is vehicle, protection, plan, location or accessory (an open enum — ignore a kind you do not recognise). id is the id the reference reads publish — /vehicles, /protections, /rates, /locations, /extras: the vehicle_id, the protection_id, the plan name, the location_id, the extra_id. name is the object's published display name, to recognise it by; it is never sent back. code is yours, exactly as stored, or null. The set is the objects those reads publish to you — an object you cannot read is not listed, and cannot be coded. The CSV form carries the same rows, with two differences, both so that a spreadsheet does not run a cell as a formula. A name that begins with =, +, - or @ is prefixed with an apostrophe. A code that begins with =, +, - or @ — or with one or more apostrophes and then one of them — is written with one more apostrophe in front; a code that begins any other way is written as it is. This is done whoever recorded the code, because your supplier can record codes for you in its console as well as you. The JSON form returns every code exactly as stored. The CSV import takes exactly one leading apostrophe off a code cell that begins with one or more apostrophes and then one of those four characters, so the file a GET returns can be sent back as it is and stores the same codes (an unchanged file is 200 and writes nothing). A cell with no apostrophe in front of such a character is a code as written. A spreadsheet that drops the apostrophe when it opens a CSV, and saves the file without it, hands the import the plain code you started from; the one code that cannot cross such a spreadsheet unchanged is one that itself begins with an apostrophe in front of one of those four characters, and it belongs in the JSON form. The CSV has no meta; read meta.self_service from the JSON form.
What you send. { "entries": [ { "kind": "vehicle", "id": "…", "code": "A1" }, … ] }, 1 to 2 000 entries, no other member. code: null takes your code off the object. An object you leave out is untouched; an object may appear once. As CSV the columns are kind,id,code (other columns are ignored, so the file a GET returns can be edited and sent back as it is); an empty code cell withdraws, and the file's first line is the header. A semicolon-separated file saved by a spreadsheet under a European locale, and a leading byte-order mark, are accepted. The body is at most 1 MB; a larger one is 400 malformed_request.
One request, one decision. The request's shape is checked first (the JSON against the published schema, the CSV header and size). A problem there is reported alone, because there are no entries yet to judge. Then every entry is checked and reports all of its own problems, and the request is refused whole (422 validation_error) with every problem found — up to 100 in details[], then one more_problems entry saying how many were left out. Each entry names where it is — entries[3].code for JSON, lines[7].code for CSV, the file's own line — and carries an issue from the index in ERRORS.md › The details[].issue index (§9.1): schema, too_many_entries and the CSV shape issues (missing_column, duplicate_column, empty, unterminated_quote, invalid_kind) report a request that is not the published shape; unknown_object, invalid_code, duplicate_object and duplicate_code report an entry; unknown_parameter and invalid report a query parameter, which is refused and never ignored. unknown_object is one answer for every reason — it does not exist, it is another supplier's, it is withdrawn, it is not yours to read — so a refusal teaches you nothing about an object you cannot see.
A code held by an object you can no longer read is released, not refused. If you give a code to an object you can read and that code is still recorded against an object you can no longer read (a withdrawn station, a group taken off your books), the old holder lets it go in the same save — you can neither see nor withdraw it, and it must not stand in the way of your own table. data.applied.released counts those it really released; they are not entries, so they come on top of the four counts below. The release is conditional: if the old holder's code changed while your request waited, nothing is written and the request is refused (duplicate_code, above). A code held by an object you can read is never taken from it: that is duplicate_code.
It is all or nothing, and you can ask first. A request that is accepted is applied in one transaction — every entry or none — and answers 200 with data.applied = { set, changed, cleared, unchanged, released } and data.dry_run. set, changed, cleared and unchanged always sum to the number of entries sent. unchanged counts entries that restated what you already had (or took a code off an object that had none): a request that restates your map exactly is 200 with only unchanged, writes nothing and costs none of your saves. ?dry_run=true returns the same counts for the same checks of the request and writes nothing. It does not test the cap (below), which depends on other saves, so a dry run that answers 200 can be followed by 429.
Retries. The request states a desired state, so sending the same body again changes nothing further: it is idempotent against its own effect, and no Idempotency-Key is taken (the same is true of PUT /v1/webhooks/endpoint). It is last-writer-wins, with no If-Match: if the supplier, or another of your own jobs, changed an object after your first attempt, a repeat puts your value back. Read with GET before you resend after a failure you cannot explain.
Thirty saves an hour. A request that writes something is a save; you may make 30 in any rolling hour, per supplier. The 31st is 429 rate_limited with a Retry-After in seconds. A dry run, a refused request and a request that restates your map are not saves. (The request quota class is rates — the class of GET /v1/reference/rates, whose walk of your books builds the set you may code — and applies as well.)
What is recorded. Every change is recorded against the key that made it — the key's id, whether it is a single-supplier or an account key, and the request id — in the same transaction as the change; the supplier can read that record.
Per supplier. This surface answers for one supplier at a time. Under an account key that reaches several suppliers, name one with X-Supplier-Id (as elsewhere, §1.6) on GET and on PUT alike; without it the call is 409 supplier_required. A write binds to that one supplier, and the map you read and write is the one that supplier keeps for you.
4. Availability & quoting
Search → quote → book. Search returns the groups free for a route and window with an indicative price; quote firms up one selection at a locked price for a short window; booking consumes the quote at exactly the quoted price (§5.1). You may book directly without a quote, sending expected_price and handling 409 price_changed, but quote-then-book guarantees the price you showed and leaves an audit chain.
sequenceDiagram
participant P as Partner
participant C as Caravel API
P->>C: GET /v1/availability (route + dates)
C-->>P: groups[] with available_units + indicative price
P->>C: POST /v1/quote (selection + drivers + extras)
C-->>P: quote_id + firm itemised price (expires_at)
P->>C: POST /v1/bookings (quote_id + customer + Idempotency-Key)
C-->>P: 201 booking (confirmed, revision=1, at the QUOTED price)
P->>C: PATCH /v1/bookings (modify, expected_revision=1)
C-->>P: 200 booking (confirmed, revision=2)
C->>P: webhook booking.modified (revision=2, signed)
Note over C,P: a DESK reinstatement is NOT pushed — you poll for it
P->>C: GET /v1/bookings?updated_since=… (§5.5)
4.1 GET /v1/availability — search
Scope read:availability; the parameters, with types, are in REFERENCE.md. The two location_ids are required; each stop takes either its instant or its date + time pair; currency, driver_age, cross_border and distribution_channel (your channel code, since 1.54.0 — §2.9) are optional. Any parameter not in the contract, or repeated, is 422 validation_error naming it. The dropoff local date must be after the pickup local date (issue dropoff_not_after_pickup) — local-date granularity, stricter than §1.7's instant rule. The response is Cache-Control: no-store.
driver_agechanges the answer. Omitted, the price is computed for a representative age of 30 clamped into each group's band — outside every age-fee band, so the price is a genuine age-free "from" floor with no extras. Sent, the age's surcharge is insidepriceand on afeeline, and groups whose band excludes the driver are withheld, so supplying an age can legitimately return fewer groups. Additional drivers are a quote-time concern.cross_borderis the one price input we cannot infer. Send the literaltruewhen your customer has told you they will take the vehicle out of the country of hire; the operator's cross-border rule (type: "cross_border"onGET /v1/reference/fees) is then priced intopriceand appears as afeeline, andmeta.cross_borderechoestrue. The value must be the literaltrueorfalse: any other non-empty spelling (1,yes,TRUE) is422 validation_error, issuemalformed, because a surcharge must never be applied or waived by guessing at a truthy string, and repeating the parameter is422issuemalformed_parameter. An empty value (?cross_border=) is read as absent — no declaration — not asfalse. It is not a one-way: a rental that ends abroad is priced by aone_wayrule on the directed pair, while this is the commoner case of collecting and returning at the same desk and crossing a border in between, which leaves no trace in the location pair. Carry the same declaration toPOST /v1/quoteandPOST /v1/bookings, where it is a boolean body member — a search that declared it and a quote that did not are pricing two different rentals. And an absent cross-border fee is not permission: whether the operator allows the movement is a separate fact this API does not publish (§2.6).available_unitsis exact, always ≥ 1, and fleet-wide for the window, not per station. The locations you send are validated (unknown_location) and priced (route, one-way, out-of-hours, delivery) but do not partition inventory: the same three units are counted whichever station you ask about. Never sum counts across stations, never treat a count as reserved capacity. Nothing holds a unit until a booking is created — not a search, not a quote.priceis indicative (price_type: "indicative",requires_quote: true, always): the grand total for a default adult selection with the cheapest applicable cover and no extras. The bookable number is the quote.protectionis the §2.4 object for the cover insideprice, rendered by the same projectionGET /v1/reference/protectionsuses. On a row of your served plan every commercial and identity member therefore matches your synced record exactly, and onlyupdated_atdiffers; a row of another plan carries that plan's package, which the protections read does not list — quote it from the row, with the row'splan.protection_options— exactly ONE entry, always present: a row is priced from one price plan, a plan sells exactly one package, so the list is that package, equal toprotection; another package for the same group is another plan's row (plan, below). The entry carriestotals_for_windowandtax_breakdown_for_window— the whole rental's §6 totals for the window with that cover, from the engine a quote runs — both optional: absent where we could not price that tier for the window, or where two groups sharing this row's ACRISS code disagreed about the same cover's price (then absent for every row under that code). Null-check them and fall back toPOST /v1/quote. Aprotection_idthis plan does not include is refused422 unknown_protection, anddetails[0].messagenames the package this plan includes and theplanto price under instead. Feature codesavailability_protection_ladder,availability_protection_ladder_pricing.line_items,tax_breakdown,totals—line_itemsis the §6 breakdown behindpriceon the row (base rate, the embedded cover, every route/time fee this window triggers; extras once you quote; age fees only if you sentdriver_age);tax_breakdown+totalscomplete the §6 breakdown on the row, so no part of a results card requires arithmetic on your side.totalscarriessubtotal_ex_tax,tax_total,grand_totaland thepay_now/pay_at_pickupsplit (which lines the operator has marked prepaid and which pay on arrival in its own product — operator configuration, the same for every partner, not a schedule for your booking and not a statement of who collects; §6.2 says what it does and does not tell you).grand_totalis byte-identical toprice, which is kept because you may already read it. Fee identity: everykind: "fee"line carriestype(fromconstants.fee_types, one wider than the fee records —deliveryis published on the location, §2.1) and, where the fee has a record,fee_id. Classify ontype, never oncodeorlabel:codeis not unique on a row — an out-of-hours window emits two lines that both readOUT_OF_HOURS, one per end, and two ends that both price a delivery emit twoDELIVERYlines the same way, pickup first, each captioned with its end. Read the tax; never derive it (§6.2).meta.rental_days,meta.extras— the billed day count, and the extras catalogue priced for the window (total_for_window, at the minimum selectable quantity, identical to the extra line a quote carries). Present when at least one group is returned.meta.policies— the §2.6 display keys resolved with your overrides andsource-tagged,deposit_payment_methods(always present once the object is, nevernull),tax_inclusive, and the three conditions of hire (each optional, as on the reference endpoint). Present only when the operator has stated policies; null-check per member inside it.meta.unservable_currency— present when you sent a currency this deployment does not price in:datais empty, the status is still200, and the member carries{field, issue: unsupported_currency, value, supported}. PincurrencyfromGET /v1/whoami(§3.8), per credential — or omit the parameter, which prices each offer in its own supplier's currency.The vehicle description —
min_driver_age,max_driver_age(emitted on every row, published optional) and the nine physical attributes andimages(optional per member until the operator has verified them; render what is present), the same valuesGET /v1/reference/vehiclespublishes. The rule for a response'srequiredset: a member added to a response that already exists is added optional (a newly-required member would make a strictly generated client regenerate — a change on your side); a new object'srequiredset is fixed when the object is born; and once you have generated against arequiredset it never shrinks within/v1(response-property-became-optionalis the breaking change). 1.42.0 applied the first clause to thesupplier_idmembers 1.36.0 had published as required on pre-existing objects, before any client had generated against them. Feature codeavailability_vehicle_attributes.meta.withheld— why the page is not the whole catalogue. Always present; one entry per reason withvehicle_groups, how many groups it withheld (no_partner_cataloguecarries no count).data: []withwithheld: []means no supplier the search reached has anything for it. Whenever a catalogue was loaded the accounting closes:data.length+ the sum ofvehicle_groups(+meta.truncated.rows_omittedwhen present) equals the number of groups in the book(s) the search reached — one book per supplier, and alocation_idbelongs to one supplier's book. No per-vehicle detail rides this member — naming groups the catalogue also excludes would disclose inventory.reasonis an open set; treat an unknown one as withheld, cause unknown to me.reasonMeaning What to do driver_age_out_of_rangeThe driver_ageyou sent is outside those groups' bands — the one most often mistaken for missing inventory.Re-search without driver_age; tell the customer which groups their age excludes.no_units_for_windowSellable, but no unit free for the whole window. Offer other dates. no_rate_for_windowNo rate card covers those dates for those groups. Offer other dates. unservable_currencyEvery group withheld because of the currencyyou sent;meta.unservable_currencyhas the detail.Fix the parameter. no_sellable_coverThose groups have no protection tier you can book. Nothing on your side. not_published_to_channelThe supplier has not finished publishing those groups; they are absent from GET /v1/reference/vehiclestoo.Nothing — the supplier is alerted automatically. acriss_shared_code_incoherentGroups of one supplier sharing one ACRISS code publish different money under it, so the whole shared-code group is withheld. Judged within a supplier, never across two: two suppliers may share a code freely. Raise it with the supplier. unpriceable_selectionThe engine refused the default selection for some other reason. Nothing — the supplier is alerted automatically. rental_duration_out_of_rangeThe window is outside the published bounds; meta.unsellable_windowcarries them. Appears only when it actually withheld a group — read the detail member for the verdict.Offer a window inside the bounds. out_of_hours_not_offered(1.11.0)An end falls outside a desk's window and its operator does not perform out-of-hours handovers there; meta.unsellable_endsnames it.Move the handover into the desk's hours, or use another desk.delivery_not_offered(1.11.0)An end is at a location that prices a delivery and does not deliver there; meta.unsellable_endsnames it.Choose another location. movement_not_permitted(1.25.0)This operator does not perform the movement between the two locations; meta.unsellable_movementnames both ends and the policy.Choose another return or collection — or pre-filter against movement_restrictions(§2.6).no_partner_catalogueYour account has no sellable book with this supplier. Contact us. meta.truncated— the page hitlimits.availability_rows_max(500 by default; per-plan rows count):{ rows_omitted, rows_max }, the rows kept are the first in(vehicle_id, plan)order, and the accounting still closes. Absent when nothing was cut. Narrow the search, or ask us to raise the bound.meta.unsellable_ends— present when an end of your rental is one this operator will not handle (§2.1's two refusals);datais empty, status200. An array, one entry per offending end, pickup before dropoff and out-of-hours before delivery, carrying the same detail object the quote422raises except thatfieldnames the query parameter you can change (pickup/dropofffor an out-of-hours end,pickup_location_id/dropoff_location_idfor a delivery end);local_weekday/local_timeare the reading at the desk, in the desk's owntimezone. Absent is not a clean bill of health: it never appears without the opt-in, and an end whoselocation_idis not in this operator's priced catalogue is skipped rather than judged, so the member can be present and one entry short. Per-account opt-in, off by default; readopt_ins.availability_end_termsonGET /v1/whoami.meta.unsellable_movement— an object, present when this operator does not perform the movement between the two locations (dataempty, status200): the quote's detail object withfieldnaming the dropoff parameter (usually the cheaper half to move — not a claim that the pickup is fine), bothlocation_ids andpolicy, the member to branch on:forbidden_pairs— try another return desk;permitted_pairs_only— readmovement_restrictions.permitted_pairs(§2.6) or search a same-location rental. Absent when the movement is served — and also without the opt-in, when either location is not in the priced catalogue, or when the operator has published no policy. Per-account opt-in, off by default (opt_ins.availability_movement_restrictions); the refusal at quote and booking applies to every account regardless.Which empty page am I looking at? The detail members are the verdict on your request;
withheldis the accounting for the catalogue.unsellable_ends: change an end.unsellable_window: change the dates.unsellable_movement: change an end or pick a served pair.unservable_currency: fix the parameter.unsellable_endsandunsellable_windowcan be present at once — then both are true and both are worth acting on (unservable_currencynever joins them: that page is answered before the rental is judged). No detail member andwithheldnon-empty: ordinary withholds. No detail member,withheld: [],data: []: nothing for your search. A whole-page withhold is counted against the groups it actually removed, so the count can be zero while the detail member is present and correct.🚨 Without the
availability_end_termsopt-in, a row indatadoes not promise the window is bookable: at a desk publishingout_of_hours: { "available": false }ordelivery: { "available": false }the page still returns priced rows, and the refusal arrives at quote, at create and on a modify that moves an end. Pre-filter fromGET /v1/reference/locations(§2.1).Plans — one row per price plan. A supplier may hold more than one price plan for your account — each with its own rate card and exactly one package. This endpoint returns one row per vehicle group per plan, each carrying
plan, ordered(vehicle_id, plan);meta.planslists them withservedmarking the one you are priced from when a request names none; each per-planwithheldentry carriesplan. Key your rows on(vehicle_id, plan). On by default (availability_plansonGET /v1/whoami); switched off, you receive one row per group from the served plan and noplanmember. Send a row'splanback onPOST /v1/quote.GET /v1/reference/protectionspublishes the served plan's catalogue; another plan's figures are on its rows. So take theprotection_idyou quote from the row you price: for a row of your served plan it is the id that read lists, and for another plan's row only the row has it.The full catalogue stays on
/v1/reference/*(§3), the source of the stable ids you map once; the row's embedding removes the per-search join, not the catalogue.
4.2 POST /v1/quote — firm price for a chosen option
Scope write:quotes; requires Idempotency-Key — a quote is a created resource and a retry must return the same quote_id. pickup, dropoff, vehicle_id, protection_id and drivers are required; extras, currency, plan (the row's plan; omitted, you are priced from the served plan; unknown, 422 with issue unknown_plan) and distribution_channel (since 1.54.0 — §2.9; the quote records it, echoes it, and binds the booking made from it) are optional; the body is strict.
A quote guarantees price, not inventory. The §6 breakdown is returned flat at the top of data beside quote_id (an opaque UUID, pattern-checked on the create so a re-shaped id is 422, never 404), expires_at (the end of the price lock — the window is quote_ttl_seconds on GET /v1/capabilities; size your re-quote cadence against it) and status (active | expired at least, read live, so a lapsed quote reports expired before anything writes to it). The breakdown is stored with the quote and binds any booking created from it; no car is held, so a group that sold out in between fails 409 availability_changed — re-quote; the selection itself is usually still fine. The response answers 201; an idempotent replay 200. A booking made from a quote that named a plan carries it, frozen, so a later modify re-prices on it.
5. Booking lifecycle
Scope write:bookings. Create, retrieve, modify, cancel and list — all priced authoritatively by Caravel, all retry-safe. Shapes and examples: REFERENCE.md › Bookings.
A shared fleet, and what a confirmation is worth on it. The cars you book are the operator's whole fleet, also sold at their own desk and websites, and those channels are deliberately allowed to overbook it; the oversell guard that refuses your create when a group is full (409 availability_changed) is enforced on this API alone. So confirmed means accepted, priced and final in its commercial terms — not a reservation of a specific unit, and not a guarantee of fulfilment in the class you sold. If the operator cannot honour that class, the system carries the change: a booking.modified event whose change.reason is overbooking, re-stating vehicle.allocated_vehicle_id as the class the booking now sits in (§7.1); until delivery is armed for you it reaches you by polling (§5.5), where revision does move. The operator's practice is to phone or email you first — practice, not a commitment. vehicle_id, acriss and display_name never move — they record what you sold; only the allocated group states where the booking sits now. A displacement is not a re-allocation you can resell, and it never changes the price.
5.1 POST /v1/bookings — create
One endpoint, two strict request shapes, and the presence of quote_id selects the shape. The contract publishes both, CreateQuotedBookingRequest and CreateBookingRequest, as the alternatives of the request body; a body carrying quote_id and a vehicle_id is 422 validation_error, not a merge. Idempotency-Key is required on both.
- Quoted (preferred):
quote_id+external_ref+customer+drivers, plus the optionalflight_number,notes,meta— and nothing else: route, dates, vehicle, protection and extras are rejected, because the quote holds them.driversre-states the priced driver set (the{age, role}multiset must equal the quote's — names may differ — else422; re-quote to change it). An unexpired quote books at exactly the quoted price; there is noexpected_priceon this path (sending one is422). Failures:404 not_found(no suchquote_id),409 quote_expired,409 quote_consumed,409 booking_conflict(§1.8),409 availability_changed(the group, the protection, an extra or the free unit moved since minting — re-quote). - Direct: the full selection inline plus
expected_price— the supplier grand total and currency you are consenting to — a quote'stotals.grand_totalwhere you hold one, otherwise the supplier price you were last given for this selection — never a figure you computed or added fees to. Omit it and the call is422 validation_errornaming the field, unless your account is configured to accept the supplier price without a consent gate (accepts_supplier_priceon §3.8). A computed grand total that differs fails409 price_changedwith the full new breakdown — re-consent and resubmit; there is no silent repricing.quote_id: nullis not a way onto this path; omit the member. mode("quoted"|"direct") is optional for the whole life of/v1, never selects the branch, and is not in the intent hash; it buys you a422at the field you got wrong rather than two fields later. There is deliberately no OpenAPI discriminator over it.payment_modelis optional on both shapes: send it only if your agreement lets you sell more than one model; a model your agreement does not permit is422 validation_error(issuenot_permitted), never a silent fallback; it is not accepted on a modify, because commercial terms are frozen at sale. The result is the booking'spaymentobject (§2.8).distribution_channel({code}) is optional on both shapes: the downstream channel this sale came through, judged against the operator's register under your policy (§2.9). A refusal writes nothing, and a retry with the sameIdempotency-Keyreplays the booking made, whatever the register says since.planis accepted on the direct shape as onPOST /v1/quote; the quoted shape takes the plan from the quote. A booking carriesplanonly when priced from a named plan; absence means the served default of its day. Frozen: every modify re-prices on it.
Responses. 201 with the booking (status: confirmed, revision: 1); a replay 200. Duplicate external_ref with the same intent hash → 200 + the existing booking; a different hash → 409 booking_conflict. Missing or invalid data → 422 validation_error with per-field details[]. No free unit at create time, on either path → 409 availability_changed, nothing booked. Both paths can answer 503 service_unavailable with Retry-After under write contention — nothing written, retry with the same key.
vehicle.allocated_categoryis deprecated (2026-08-22); readvehicle.allocated_vehicle_id. Both name the operator-side group the booking was allocated to; the identifier resolves onGET /v1/reference/vehicleswhile the group is on sale and is the same value at every revision, while the label is the operator's internal name and can change between revisions for a reason you cannot see. Nothing is removed within/v1; the booking operations carryDeprecation,SunsetandLinkheaders andcapabilities.deprecations[]lists it — read theSunsetdate as a floor, not an appointment.cancellation_policy— the rule FROZEN onto this booking:{type, source, fee_basis}plusfree_until(an absolute, inclusive deadline in the pickup's own offset) andlate_fee_percentonfree_until, ortiersontiered.sourceisoperator_default,broker_termsorlegacy_zero_fee(a booking made before cancellation charging existed — free at any notice). The rule you display is the rule we charge under;fee_basisis always the operator's. Present on every revision minted through this API since 1.21.0 and carried forward by a desk-minted revision; absent only on an untouched pre-1.21.0 booking. Display-only — the authoritative charge iscancellationat cancel time. A re-priced modify that moves the pickup re-statesfree_untilagainst the new pickup. Feature codeper_broker_cancellation_policy.no_show— what a no-show cost (1.63.0):{fee: Money, recorded_at}, present only whilestatusisno_showand the rule frozen onto the booking charges one (a booking the desk reinstates from no-show no longer carries it):cancellation_policy.no_show_fee_percentof thefee_basisamount, rounded to the minor unit. It is an amount owed to the operator, not money moved through this API.cancellation_policy.no_show_fee_percentis likewise present only when the frozen rule charges.change— why a snapshot changed without a status transition:{actor: operator | partner, reason: breakdown | overbooking | force_majeure | operator_adjustment | vehicle_replaced | cannot_fulfil | other, changed_at, note?}, per revision, not cumulative. Produced by the shared-fleet displacement (actor: "operator",reason: "overbooking", inferred from the act), by a reason the desk stated with a note —vehicle_replaced,cannot_fulfil(expect a call),breakdown,force_majeure,operator_adjustment,other— and, since 1.66.0, by a desk correction of the price or dates with no reason stated (actor: "operator",reason: "operator_adjustment"). One desk save that does several of these publishes ONEbooking.modified, itsreasonthe most specific: a stated reason, elseoverbooking, elseoperator_adjustment. Branch onreason, keep the set open, never onnote. An operator-initiated change does not itself movestatus— but one desk save can both displace a booking and mark itno_show, so readstatusfrom the event or the point read. Feature codeoperator_initiated_change.- Three names:
cancellation_policyis the rule to display for a booking already made;cancellation(§5.4) is the applied fee, present after a cancellation;policies.cancellationis the operator-default classification, present on every booking and never the negotiated rule (§2.6). Before you sell, read the rule in force from §3.7 ormeta.policies; on a booking, readcancellation_policy— the two agree at the moment of booking by construction. - Declared but not populated:
special_requests(child_seats:infant/child/booster→ 1–4) prices nothing and is not emitted while its feature codestructured_special_requestsreadsfalse; sending it on a create is422.
5.2 GET /v1/bookings/{ref} — retrieve / status
{ref} accepts external_ref (default) or booking_id (?ref_type=booking_id). The response is the stored snapshot overlaid with live lifecycle state: the snapshot — rewritten by every /v1 write in the same transaction — is authoritative for content (breakdown, parties, stops, policies, frozen per revision); status, updated_at and revision are always served live, and wherever the two could differ, live wins. Price changes only at a revision: the snapshot's money is what the booking stated at its latest revision — your last /v1 write, or, since 1.66.0, an operator's desk correction of the price or dates, which mints a revision and is pushed to you as booking.modified carrying change (reason: "operator_adjustment"). Nothing rewrites the figure without telling you. The basis a settlement bills on is carried separately (§11), and a settlement line names the revision whose snapshot states it.
Desk-side changes show here as §2.7 describes. A desk cancellation, no_show or completed mints a revision, rewrites the snapshot (a desk cancellation's cancellation block carries no reason) and is pushed as its event, so this read agrees with what you were sent. A desk reinstatement — and any terminal set before 1.21.0 — changes only the live record: this read reports the live status with an advanced updated_at and an unmoved revision, exactly as the list does, which is why the list poll comparing status, not revision, remains the required detection loop.
Take your reconcile watermark from the list's meta.as_of and nowhere else — one booking's updated_at is not a stream watermark. At matching revision this read and the webhook payload are the same JSON value in every member except status and updated_at, where this read is live-authoritative; at the same revision, this read's status supersedes the event's copy.
5.3 PATCH /v1/bookings/{ref} — modify
POST /v1/bookings/{ref}/modify — the modify alias — is the same operation. Send only the fields that change (dates, vehicle, protection, extras, a driver's age, role or country — all pricing-relevant) plus expected_revision, the revision you last saw, in the body (there is no header form). external_ref, currency and quote_id cannot change and are rejected 422; a patch with no content field is 422 (issue empty_patch). Every stop you send carries its location_id, even when only the time changes, and takes either at or the date + time pair.
- A pricing-relevant change also needs
expected_price— the same consent gate as a direct create, on the same terms and with the sameaccepts_supplier_priceexception; a mismatch is409 price_changedwith the new breakdown; a metadata-only patch neither needs nor uses it. - No-op: fields resolving to exactly the current snapshot short-circuit to
200with the booking unchanged —revisionreused, no event,expected_revisionnot enforced — so a client that always sends the full object cannot generate phantom revisions or webhook noise. - A real change re-prices through the same engine as quote and availability and returns the new breakdown with
revision += 1. Inventory is re-checked only when the vehicle group or an operator-local pickup/dropoff date changes; a group no longer free for the new window is409 availability_changed, nothing written. distribution_channelis a metadata change (noexpected_price): send{code}to correct it ornullto remove it (refused underrequired). It is judged only when it CHANGES — omitting it, or sending the recorded code back, never re-judges a booking already made (§2.9).- Optimistic concurrency: a stale
expected_revision— another of your writes, or an operator displacement, moved the booking — is409 revision_conflictcarrying the current booking inerror.details[], so you re-read, re-decide and resubmit without another call; thechangeblock on that booking tells you which. The terminal check runs beforeexpected_revision, so a modify against a cancelled,no_showorcompletedbooking is409 invalid_transition, neverrevision_conflict.
5.4 POST /v1/bookings/{ref}/cancel — cancel
Idempotent. A body is required and carries expected_revision; reason is optional, and so is distribution_channel (since 1.54.0): an assertion, never a change — when sent it must be the channel recorded on the booking, or the cancel is 422 validation_error, issue booking_channel_mismatch, and nothing changes. A cancel is never refused for lacking it. A cancel sets status: "cancelled", revision += 1, and returns a cancellation object; a second cancel of a cancelled booking returns 200 without bumping revision.
fee is computed from the rule in force when the booking was created — the operator's default, or your negotiated override where one applied — against the notice you gave: the band decides the percentage, the percentage applies to the fee_basis amount taken from the booking's latest priced snapshot, rounded to the minor unit, so an early cancellation under a non-zero rule can be free and a small basis can round to zero. Read the fee we return; do not re-derive it. refund is always zero, and that is a different statement: nothing is collected through this API, so this platform has nothing to give back; fee is what is OWED, settled through your existing billing relationship with the operator.
expected_revision is enforced only when the call would change something: a repeat cancel is 200 whatever it says; a no_show / completed booking is 409 invalid_transition before the revision is compared; a stale value on a live booking is 409 revision_conflict with the current booking. The example in the reference is a free cancellation — do not read its zeroes as the shape of every response.
5.5 GET /v1/bookings — list (reconciliation)
?updated_since=…&status=…&limit=…&page_token=…. Your reconciliation and catch-up surface: it narrows on updated_since with keyset pagination (§1.10), each row a summary (booking_id, external_ref, status, revision, updated_at, grand_total), with meta.as_of to store as the next updated_since; its status filter matches the live column, so ?status=no_show finds desk-transitioned bookings across your whole book. Fetch the full booking via §5.2 for anything whose revision is ahead of what you hold, and compare status for what has no revision.
6. The price breakdown object
One object, returned by POST /v1/quote, carried on every booking, and on every availability row as an indicative breakdown of the rental itself — base rate, the embedded cover and the route/time fees, never extras, which are priced separately in meta.extras[] at their minimum quantity and may be summed with totals.grand_total without losing a cent. It is complete and self-explaining: render an itemised receipt without deriving anything.
6.1 Shape
currency, rental_days, line_items[] (each kind, code, label, quantity, unit_price, total, payable, taxable), tax_breakdown[] (per rate: code, rate, taxable_base, amount), totals (subtotal_ex_tax, tax_total, grand_total, pay_now, pay_at_pickup) and deposit (amount, type: pre_authorization, refundable, collected_at: pickup). A full example is under POST /v1/quote in REFERENCE.md.
6.2 Rules
kind∈base_rate | protection | extra | fee | discount;taxis deliberately not a kind — tax lives only intax_breakdown. Discounts are negativediscountlines.payable∈prepaid | at_pickup.pay_now= Σ prepaid (tax-incl);pay_at_pickup= Σ at_pickup (tax-incl).grand_total = pay_now + pay_at_pickup.
What the split is, and what it is not.
payableis the operator's own configuration for its product — which parts of a rental the operator has marked prepaid and which pay on arrival. Both are payment schedules; neither names a collecting party. It is the same for every partner of that operator, it is not derived from your agreement and does not change when your agreement does, and it is what these two sums add up. It is not a schedule for a booking made through you: under a pay-on-arrival agreement none of these rental amounts falls due before pickup however largepay_nowis, so a non-zeropay_nowis never on its own evidence that any of them is due, or has been taken, before pickup. (A cancellation charge is a separate amount with its own basis and timing, and may fall due before pickup whatever this split says.) It is not a statement of which party collects the customer's money, not a per-partner value, and not a record that any customer has paid anything: no customer money is collected through this API and no customer payment event is observed here at all. (What the operator has remitted to YOU is a separate figure, reported onGET /v1/settlements— see below.) When the amounts fall due under your agreement, which party collects the money, and on what payment model, are terms of that agreement and are published only aspayment(§2.8) where it is present. What the operator owes YOU is a separate surface with its own meaning —GET /v1/settlements(§11) — and it is not derivable from these two numbers either.
- Tax algorithm (normative — the single source of cent-exactness): all arithmetic in integer minor units; per line,
tax = total − round_half_up(total × 10000 ÷ (10000 + rate_bps))— extracted per line, never on the aggregate, never in floating point; per-line taxes are summed per rate intotax_breakdown, withtaxable_base= Σ (linetotal− linetax);tax_total= Σ per-line tax;subtotal_ex_tax = grand_total − tax_total, derived by subtraction, never rounded independently. The rate is always carried on the response (tax_breakdown[].rate), and the direction is stated on the wire astax_inclusive— read the flag per supplier; adding VAT on top of a tax-inclusive amount overstates the price by the whole rate. Partners display tax; they never recompute it. - Line totals are authoritative:
quantity × unit_priceneed not equaltotal— caps truncate it and tier-card pricing derivesunit_pricefor display. Display line fields; never derive one from another. - Rate model (normative): base rates are tier cards —
t1–t6are exact totals for rentals of exactly 1–6 billing days,t7is a daily rate for 7+ days. A rental inside one card's validity prices from that card; a rental spanning card periods is prorated per night: every occupied night takes the full-duration column (t{N}for 1–6 days,t7for 7+) from the card covering that night, and the base-rate total isround_half_up(Σ_night t{N} ÷ nights)for 1–6 days orround_half_up(days × Σ_night t7 ÷ nights)for 7+. Every night must be covered by a card: a window reaching past the published coverage is refused —no_rateat quote,no_rate_for_windowin availability's accounting — never extrapolated. rental_daysis the billed day count, measured on wall-clock time in the location's timezone:max(1, ceil((wall_clock_hours − grace_hours) ÷ 24)).grace_hoursis operator configuration, not a wire constant, and is already applied in therental_daysa quote returns — read it from the quote; §2.6's bounds are enforced against it.
6.3 Age fees (young / senior) — precise handling
Resolved at quote and at booking from the drivers[] you send — and on a search row when you send driver_age (§4.1). young_driver and senior_driver are fee lines from §2.5 rules per qualifying driver across every driver, main and additional alike — each qualifying driver produces its own line (the two YOUNG_DRIVER lines in the §6.1 example); pricing only the main driver understates the total. A main driver outside the group's min_driver_age..max_driver_age fails 422 driver_age_out_of_range naming the field.
6.4 Deposit vs charge
deposit is a pre-authorisation hold on a card at pickup (zero on some premium tiers), outside grand_total and never in pay_now / pay_at_pickup, surfaced so you can warn the customer before they book.
7. Webhooks
Once delivery is armed for your account — which capabilities.webhooks.enabled cannot tell you, being deployment-wide; GET /v1/whoami (§3.8) does — every act that mints a revision (§2.7) is pushed to you as a signed event carrying revision + occurred_at; apply an event only if its revision is greater than what you hold for that booking. Since 1.66.0 a desk correction of the price or dates is pushed as booking.modified carrying change (actor: "operator", reason: "operator_adjustment", §5.1). Not pushed: a desk reinstatement, a terminal set before 1.21.0, and a desk edit that changes nothing you were sold (a note, a car swapped within the class) — poll for those (§5.5). One desk save, at most one content event, and the terminal last: a save that changes the content and also sets a terminal status sends booking.modified (carrying change) and then the terminal event at the next revision, and the terminal event carries the corrected content too — a cancellation's fee is resolved on the corrected price, and its notice is counted to the corrected pickup. The booking.modified states the status the booking held before the save; the terminal event states the terminal together with its own members — cancellation and its fee, or no_show — so no revision states cancelled or no_show without them. Read the outcome from the terminal event. status never moves backwards.
7.1 Events
| Event | When |
|---|---|
booking.confirmed |
Created through /v1 — always revision 1. |
booking.modified |
You or the operator changed the content: a /v1 modify that changed something (a no-op emits nothing), or an operator-initiated change carrying change (actor, reason) — the block's presence is the discriminator, not a second event type. |
booking.cancelled |
A /v1 cancel or a desk cancellation — indistinguishable on the wire; a desk-entered one carries no cancellation.reason. |
booking.no_show / booking.completed |
The desk marked your booking a no-show or the rental completed — a new revision, the payload's status accordingly. |
webhook.test |
Only when you ask (POST /v1/webhooks/test, WEBHOOKS.md): signed like every other event over a synthetic booking (external_ref starts WEBHOOK-TEST-, data.test is true, revision 1), delivered once, never retried, never in your delivery log; a self-serve sandbox account also sees it in the inspector on its account page. |
Every member of the type enum GET /v1/capabilities publishes has an emitter; the enum stays open (§7.3). The desk-side terminals carry no capability code of their own — webhooks gates delivery as a whole.
7.2 Delivery contract
A POST to your registered HTTPS endpoint; the envelope is the WebhookEvent schema in the contract's webhooks section — generate your receiver from it. The fields you route and order on — booking_id, external_ref, revision — sit at the top level, each equal to its counterpart inside data, which is the booking snapshot at that revision, the §5.1 shape with the §6 breakdown, published as an open object because a payload is frozen at its revision (model it as Booking, validate fresh responses, not stored payloads). event_id, operator_id and booking_id are opaque UUIDs; occurred_at is the commit instant in UTC with three fractional digits; api_version is the surface that produced the payload (never the X-API-Version value); sequence and supplier_id are the envelope's two optional members, and neither is ever sent as null — an absent value is an absent MEMBER; every other member is always present. sequence is a strictly consecutive delivery counter per supplier stream (the supplier_id on the envelope names which), so a hole is a real gap; supplier_id is absent only on a delivery whose supplier label has not been synced yet (1.41.0). Signature X-Caravel-Signature: t=<unix-seconds>,v1=<hex>, hex = lowercase HMAC-SHA256 over "{t}.{method}.{path}.{body}" with your secret; several v1= values during rotation, accept if any verifies, verify before you trust the body. Dedupe on event_id; order by revision, not arrival. Delivery is at-least-once with exponential backoff and a multi-day budget before dead-letter, written atomically with the change it describes and tracked per endpoint; the schedule, the budget, the replay window, sequence's protocol and the reference verifiers are in the Webhook Receiver Guide, which is normative for delivery behaviour. Catch-up after downtime is the list poll (§5.5).
7.3 Registration
Delivery is gated per environment and per account: capabilities.webhooks.enabled reports the environment (sandbox is typically armed first), GET /v1/whoami reports your account; Caravel tells you when delivery is armed for you — register your endpoint and validate your verifier whichever state you find. Your endpoint URL and signing secret are configured during onboarding; secrets are stored encrypted, never hashed, because signing needs the plaintext. Validate your verifier before you register, against the static self-test fixture the pack ships (the Webhook Receiver Guide). The event type enum is open: capabilities.webhooks.event_types is the accepted set — treat an unknown type as unknown, skip it and keep processing, never reject the delivery. While delivery is not armed for you, the polling paths are your only source of booking state.
8. Enumerations
The authoritative lists are GET /v1/reference/constants (§3.1) and the Enumerations section of REFERENCE.md; this section states what the values mean. Every list is open within /v1: a value you do not recognise is not an error.
8.1 Booking status (lifecycle)
pending → confirmed → terminal ∈ { cancelled, no_show, completed }. Direct API bookings start confirmed; pending exists for a flow needing manual approval. There is no modified status: a content change is expressed by revision and a booking.modified event — which is not a promise that status is unchanged on the event you receive, since one desk save can both displace and terminate a booking; read status from the event or the point read. A desk reinstatement moves status back to confirmed without a revision or an event.
The list is open (since 1.64.0). These five are the only values a booking carries today, and a later release may add a state within /v1. Nothing about today's values or transitions changed when the list was opened. A new state is announced in CHANGELOG.md before any booking carries it, together with its meaning and transitions. A strict generated client must not reject one. The published schema lists the five as x-extensible-enum, and booking.status, bookings[].status and reference.constants.booking_statuses are in capabilities.forward_compatibility.extensible_enums. Until you know a value, treat the booking as neither confirmed nor finished:
- do not tell your customer it is confirmed, and do not treat it as cancelled or release what it holds;
- keep the booking and its last known value, and never reject the payload or drop the record;
- read it again —
GET /v1/bookings/{ref}, the list (§5.5), or the next webhook — until it carries a value you know.
Write that as the default branch of any switch on status. The status filter on GET /v1/bookings is a request value, so it stays strict: it accepts a new state from the release that first emits it, never before.
A new state reaches only an account pinned at 1.64.0 or later (§1.4, Behaviour change). If your account is pinned at 1.63.0 or earlier, every surface keeps serving the five you certified on: the point read, the list, its status filter, reference.constants.booking_statuses, a cancel or modify answer and a webhook's data. A booking in a new state is served as the existing state that the announcement names as its equivalent, and the equivalent is chosen so that your integration stays correct. A state that is still live is never shown as terminal, and a terminal one is never shown as live. Moving the shown value only ever follows a transition you already know. A booking you can cancel today stays cancellable. The status filter matches the value you are served. Move your pin to 1.64.0 when your default branch is in place.
8.2 Location type
airport | port | office | city | hotel | address | other; hotel / address may support delivery; other is a value you will receive — handle it.
8.3 Payable timing
prepaid | at_pickup (§6.2).
8.4 ACRISS (vehicle code, 4 chars)
Industry standard (ACRISS/SIPP): position 1 category (M Mini … L Luxury, X Special, "Elite" variants), 2 body type (B 2/3-door, C 2/4-door, D 4/5-door, W Wagon, V Van, F SUV, P Pickup, L Sedan, T Convertible …), 3 transmission/drive (M Manual, A Automatic, with 2WD/4WD/AWD variants), 4 fuel & A/C (R Petrol + A/C, N Petrol no A/C, D Diesel + A/C, H Hybrid + A/C, E/C Electric …). MCMR = Mini, 2/4-door, Manual, Petrol + A/C. Store the literal code plus acriss_decoded (§2.2); the legends are on §3.1. Canonical reference: acriss.org.
8.5 Fee type
young_driver | senior_driver | one_way | out_of_hours | premium_location | additional_location | cross_border | winter_equipment — the eight a GET /v1/reference/fees record can carry, seven live and additional_location reserved (§2.5). constants.fee_types carries one more, delivery, charged through the location (§2.1).
8.6 Fuel policy
full_to_full | full_to_empty | same_to_same | prepaid.
8.7 Mileage type
unlimited | limited (then included_km_per_day).
8.8 Extra category
child_seat | additional_driver | navigation | connectivity | winter | protection_upsell | other.
9. Error catalogue
Errors use their own envelope — { "error": { "code", "message", "request_id", "doc_url", "details": [ { "field", "issue", … } ] } } — never the success envelope. Branch on code, never on the HTTP status alone: 409 covers more than a dozen conditions with different remedies. The catalogue — every code with its status, meaning, retry answer and what to do next — is ERRORS.md, rendered from the same source as the live page every doc_url resolves to (https://api.caravelway.com/docs/errors#<code>). The code list grows additively within /v1; treat an unrecognised code as an unknown error of its HTTP status. Retry only 429, 5xx, 409 idempotency_in_progress and network errors — at least Retry-After when the response carries it, otherwise with exponential backoff and full jitter (§1.8). Overload is 503 service_unavailable, never 401 and never 500.
supplier_not_enabled (403) and supplier_paused (409) are reserved too, and for the same reason as this whole release: a client generated today has them in its enum before anything can return one. Nothing returns either yet. When they arrive they mean your agreement does not cover that supplier and that supplier has stopped selling. Two more, published since 1.42.0, are returned since 1.43.0 to a key that reaches several suppliers — the sandbox's one key reaches both of its: ambiguous_ref (409) — an external_ref you reused across suppliers names two bookings under one key (§1.8) — and supplier_required (409) — a request that counts ONE supplier's stream (the delivery log's or a redelivery's sequence_from / sequence_to) sent without X-Supplier-Id (Webhook Receiver Guide §7). A webhook-configuration read is never refused (since 1.48.0). A client that follows §1.8 and names the supplier whenever it filters by sequence can never meet either. The other supplier codes you can meet are 422 unknown_supplier, from X-Supplier-Id (§1.4), and 422 supplier_mismatch, for a request whose ids belong to two different suppliers.
rate_expired is reserved and never returned today; it is one character from rate_limited, whose remedy is the opposite (429, retry with backoff, against 410, re-search) — branch on the code, never on a prefix.
9.1 details[].issue — the index (1.31.0)
details[].issue is a finer vocabulary than code, specified at the surface that raises it and not among the extensibility guarantees GET /v1/capabilities declares — treat a value you do not recognise as "this field was rejected, reason unknown to me". The index of every value any /v1 endpoint can raise, with the other members each entry carries, is in ERRORS.md › The details[].issue index, held to the source by a test. The two location-level refusals are the easiest to miss because they arrive under codes you already handle: out_of_hours_not_offered and delivery_not_offered inside 422 validation_error on quote, direct create and modify, and inside 409 availability_changed when you consume a quote (§2.1).
10. Security & operations
- Keys, idempotency, rate limits: §1.3, §1.8 and §1.11 are the contract; a key is never written to a log in plaintext.
- Traceability: every request is recorded against its
request_id, and every price computation with its inputs and the rule ids it resolved, so any pricing question can be reconstructed months later from the reference you quote us. - Sandbox: a separate environment with a seeded fixture fleet and two suppliers, both reached through one key (an account approved before one-key provisioning holds one key per supplier). Its fixture data — prices, ids, desk schedules, availability — is illustrative and may change at any time (changelogged, never frozen): read every value from the API at run time and never hardcode a sandbox value into a client or a test. That is a statement about the sandbox only. Test data only: every name, e-mail address, phone number and address you send to the sandbox must be made up, and a real customer's data must never be sent there. Data that looks real may be flagged for review; a flag never blocks a request (
INTEGRATION-GUIDE.md§1.1a). The bookings and quotes you create there may be reset at any time, without notice. - Timeouts: we publish no latency target and make no capacity promise; size your client timeouts to your own tolerance and retry idempotently on a timeout (§1.8).
- Machine-readable contract: OpenAPI 3.1 at
GET /v1/openapi.json, live discovery atGET /v1/capabilities, the error catalogue at everydoc_url. Underpathsa host lists every route it mounts and none it would refuse — fetch the document from the host you integrate against and generate your client from that copy.GET /v1/openapi.jsonitself and the catalogue page are deliberately not operations;GET /v1/capabilitiesis listed, unauthenticated, with no security scheme; every other path is key-authenticated.features[].enabledis not a routing signal —pathsis. Thewebhookssection carries the envelope you receive (WebhookEvent, §7.2). The Postman collection shipped with this pack is generated from the full contract, not per host, so on a host that has not switched on a surface it contains calls that host does not answer.
11. Settlement reporting
Scope read:settlements. Two GET endpoints let you read, in your own reference space, what this operator owes you: the settlement periods, and the per-booking lines inside one. §5.5 tells you what was booked; §11 tells you what was earned on it and how much has been paid. Scoping is structural: both endpoints resolve the counterparty from your key — there is no partner selector, partner_id is rejected as unknown — and figures are never aggregated across operators. Commission rates and the operator's own net are never on the wire; you read your own money: owed, remitted, outstanding.
11.1 GET /v1/settlements — your settlement periods
Optional status (open | partial | settled | disputed, an open list), limit, page_token and updated_since; any other parameter is 422 validation_error. When page_token is present, status and updated_since are ignored — the token carries the filters the walk started with; limit is still yours to vary per page. Each period carries period_start (inclusive), period_end (exclusive), amount_expected, amount_received, amount_adjusted, amount_outstanding = amount_expected + amount_adjusted − amount_received (the number this surface exists to publish), and bookings_allocated.
Ordering and what a walk guarantees. period_start descending, settlement_id ascending — a pair, because a corrective batch may re-cover a period; neither is ever rewritten. An unfiltered walk to exhaustion returns every period that existed when it began exactly once. A filtered walk is not a census: the filter is applied per page against the status at that moment, so a period that changes status mid-walk can be missed — reconcile unfiltered and use status for triage. meta.as_of is minted once per walk and every page returns it: store it and replay it as updated_since; frozen at the start and a short way behind real time, the worst case is seeing a period twice, never losing one — it closes the gap for periods created after the walk began, not the status gap. A malformed, foreign or stale page_token is 422 validation_error, always with the same message.
Disputed periods. disputed is an explicit hold the operator places: the automatic reconciliation pass files no clawback and no true-up against it and does not advance past it. There is no verb for raising a dispute, by design — raise it with the operator through the channel in your commercial agreement, quoting the settlement_id and the external_ref of each line you disagree on; the operator corrects the period (an adjustments[] line and a moved amount_outstanding) or marks it disputed. Watch for the transition with updated_since.
11.2 GET /v1/settlements/{settlement_id} — one period, with its lines
The same header plus allocations[] (one line per booking that earned commission, carrying your own external_ref — join on it where present — and booking_ref, the operator's own reference, opaque, never the booking_id) and adjustments[] (signed corrections whose reason is one of two system-generated values you may branch on — auto-reconcile: booking cancelled/no_show after settlement — commission clawed back, auto-reconcile: booking modified after settlement — commission trued-up to current terms — or the neutral manual adjustment by the operator; an adjustment in another currency is excluded from the header's amount_adjusted and visible here). booking_ref and external_ref may be null on an anonymised booking; the money line is still shown. A settlement that is not yours is 404 not_found, identically to one that does not exist. Line ordering is total and stable, exactly as §11.1's is — allocations by the booking they settle, adjustments by when they were filed — and a ledger line is never rewritten or removed, so a walk returns every line that existed when it began exactly once. A token minted for a different settlement, a different array or a different partner is rejected 422 validation_error.
Paging the two arrays. Each is served 1000 entries at a time with allocations_truncated / adjustments_truncated and allocations_next_page / adjustments_next_page (a truncated array always has a next page; pass it back as allocations_page_token / adjustments_page_token). The two page independently and rarely finish together; an array whose token you omit is served from its first page again. ⚠️ Once an array's *_next_page is null, that array is finished — stop summing it; keep a terminal flag per array, never a loop on "both cursors null". Sum the lines across your walk, never the headers: a completed walk of allocations[] sums to amount_expected, the period-currency adjustments[] to amount_adjusted. The header is re-read on every page, not frozen — reconcile against the last page's header. A sum that does not close is inconclusive until you have re-walked; the one cheap tell is a line count short of the last page's bookings_allocated (a fast NO, never a slow YES; no such count exists for adjustments). Only a second, complete walk reproducing the mismatch is a real discrepancy.
11.3 What to do with it
- Poll §11.1 after each period closes and match
amount_outstandingagainst your own receivable; storemeta.as_of, replay it asupdated_since, followmeta.next_pageto exhaustion. - When the two disagree, pull §11.2 and join the lines to your book on
external_ref, marking each array finished the first time its cursor returnsnull. - Treat the last page's header as the period total and
bookings_allocatedas the check that your walk finished and the period did not move. - If they still disagree with every line in hand, raise it as §11.1 describes.
End of specification. Everything you see is composed from §2 objects; everything you do is one of §3–§5 verbs; everything you change is pushed back to you via §7 once delivery is armed; everything the desk changes is read from the list via §5.5; what you are owed is read via §11.
Partner Integration Guide
Status: Explanatory. This guide is a reading order, not the contract:
CHANNEL-API.md is normative for every endpoint, field and error, and
channel-api-v1.openapi.json is the machine-readable form of it. Where this guide and the contract
disagree, the contract is right — and please tell us, because that is a defect in this document.
Audience: Channel partners building against /v1 — brokers, OTAs, agents and metasearch.
Surface version: this pack documents one /v1 surface version; read the running value from
GET /v1/capabilities (api_version) rather than from any line in this pack — see §1.4.
This guide takes you from a fresh API key to a certified, live integration. It is sequential: it
tells you when to call each endpoint and why. Field-by-field request and response shapes live in
the API reference, CHANNEL-API.md, and machine-readably in the
channel-api-v1.openapi.json shipped with this pack.
The integration you are building is search → quote → book:
GET /v1/availability— which vehicle groups are bookable for a route and date window, with an indicative "from" price you can put on a results page.POST /v1/quote— turn one selection into a firm, persisted total with aquote_id.POST /v1/bookings— book against thatquote_id. The booking is confirmed at exactly the quoted price.
Bookings are then read, modified, cancelled and reconciled through /v1/bookings — and, once
delivery is armed for your account, your own writes are pushed back to you as signed webhooks (§7)
— and settled through /v1/settlements.
Everything here is one API, one key, one contract. There is no separate "search API".
1. Setup
1.1 Base URLs
| Environment | Base URL |
|---|---|
| Production | https://api.caravelway.com |
| Sandbox | https://sandbox-api.caravelway.com |
Build and certify against sandbox, then swap the base URL and the key — but not before we confirm
production is serving the surface you certified against. Whether an environment serves the
operational endpoints (GET /v1/availability, POST /v1/quote, the settlement reads) is a
deployment state, not a contract: until a go-live step is taken those routes are absent from that
host's GET /v1/openapi.json and answer 404 not_found. It is a property of the deployment,
identical for every partner on it — never a fault on your account. We tell you directly when it
changes.
GET /v1/openapi.json is the authority on what the host you point at serves right now. Read it
from that host before you certify and again before you swap. Do not substitute features[] on
GET /v1/capabilities: that answers which capability groups are switched on, a different question
from which routes this host serves.
The channel-api-v1.openapi.json shipped with this pack lists production first under servers, so
a generated client defaults there. Set your base URL explicitly to sandbox while you build.
1.1a Test data only: no real customer goes into the sandbox
The sandbox takes invented test data and nothing else. Every name, e-mail address, phone number
and postal address you send to it — in customer, in drivers[], in notes or meta, or anywhere
else — must be made up, and so must any licence or document number you put in a free-text member.
Never send a real customer's data to the sandbox. That includes
a real booking you would like to "try first" and traffic replayed from production: the sandbox is where
an integration is built and certified, not where a customer is served. The contract states the rule
(CHANNEL-API.md §10); this section is how to follow it.
- Invent every value, and keep every e-mail address on a reserved test domain. The domains
example.com,example.organdexample.netare reserved for documentation and testing, so an address on one of them is never a real person's; the examples in this pack use invented customers with addresses onexample.com. Treat phone numbers the same way: use a made-up one, never a colleague's or a customer's own. - Data that looks real may be flagged for review. If a sandbox booking looks as though it holds a real person's details, we may flag it so that someone on our side can check it. A flag is a note for review: it never blocks a request or a test run.
- If real data has gone in by mistake, tell
[email protected]and quote thebooking_id, not the customer's details, so that we can look at it. - Nothing you create in the sandbox is kept for you. The bookings and quotes you make there may be reset at any time, without notice. Never rely on one persisting.
1.2 Your API key
Caravel issues your key during onboarding — one key per environment, reaching the suppliers it was issued for: one supplier relationship, or every supplier of your account, as the sandbox's does (§1.6). Send it on every
/v1 request. GET /v1/openapi.json and GET /v1/capabilities answer without one (so does the
error catalogue at /docs/errors, a plain web page) and ignore one you send, but send it to those two
as well: a hosted certification run (§12) serves its changes only to requests that carry your key, and
capabilities is where two of them arrive.
GET /v1/reference/locations HTTP/1.1
Host: api.caravelway.com
X-API-Key: <your key>
- A missing or invalid key is
401 unauthenticated. Unknown, revoked, suspended, and wrong-environment keys all answer identically — the response never says which. Keys are environment-scoped, so if a known-good key is refused, first check your base URL against §1.1. - A valid key that lacks the scope a call needs is
403 forbidden. - Two keys can be active at once, so rotation has no downtime: get a second key, deploy it, have
the first revoked — or do it yourself through
/v1/credentials(contract §3.8).
Keys are scoped to your partner account. Every response is already narrowed to your own data —
there is no partner selector anywhere in this API, and the only supplier selector is the optional
X-Supplier-Id header (§1.6), which can name only a supplier your key reaches.
1.3 Scopes
A call needing a scope your key lacks returns 403 forbidden.
| Scope | What it opens |
|---|---|
read:reference |
GET /v1/reference/* — constants, locations, vehicles, extras, protections, fees, policies, terms. |
read:availability |
GET /v1/availability. |
write:quotes |
POST /v1/quote. |
read:bookings |
GET /v1/bookings (list and by reference) — and nothing else; create, modify and cancel all answer 403 forbidden. |
write:bookings |
POST /v1/bookings, GET /v1/bookings (list and by reference), PATCH /v1/bookings/{ref}, POST /v1/bookings/{ref}/modify, POST /v1/bookings/{ref}/cancel. |
read:settlements |
GET /v1/settlements, GET /v1/settlements/{settlement_id}. |
read:credentials |
GET /v1/credentials — your own keys, never their secret half. |
write:credentials |
POST /v1/credentials/rotate, POST /v1/credentials/{key_id}/revoke. |
read:webhooks |
GET /v1/webhooks/endpoint, GET /v1/webhooks/deliveries. |
write:webhooks |
PUT /v1/webhooks/endpoint, POST /v1/webhooks/secret/rotate, POST /v1/webhooks/redeliver. |
read:partner-codes |
GET /v1/partner-codes — the objects you can read, each with the code you keep for it (§2.1f). |
write:partner-codes |
PUT /v1/partner-codes — record, change and withdraw your own codes (§2.1f). Needs the supplier to have switched it on for you. |
The booking flow needs read:availability, write:quotes and write:bookings; the catalogue sync
(§2) needs read:reference; settlement reconciliation (§9) needs read:settlements. The
account-management scopes (credentials, webhooks, partner-codes) belong on the one key that manages the integration, not on the key that
carries booking traffic. A reconciliation-only job should hold read:bookings, not
write:bookings — it lists and retrieves exactly as before and cannot cancel a booking even if its
code is wrong or its key leaks (the OpenAPI document lists both scopes on the reads as
x-accepted-scopes). Scopes are additive within /v1: one may be introduced, none is ever removed,
renamed or widened, and there is no hierarchy — a key's reach is exactly the list it was issued with.
1.4 Discovery: GET /v1/capabilities
Unauthenticated, no database behind it — and still called with your key, like everything else (§1.2). Read it once during onboarding and cache it; it carries the values you would otherwise hardcode:
| Field | Use it for |
|---|---|
api_version |
The running surface version — the same value webhook events carry. |
versions[] |
Which major versions this deployment answers, and their lifecycle status. |
limits.list_limit_default, limits.list_limit_max |
Sizing limit on list calls. |
quote_ttl_seconds |
How long a quote stays valid — size your re-quote cadence off this. |
idempotency_retention_hours |
The window within which a same-key retry is guaranteed to replay rather than re-execute. |
deprecation_notice_days, deprecations[] |
Announced changes and the minimum notice before any removal. |
webhooks.enabled |
Whether the environment you point at is delivering webhooks at all — read it, never assume it. Deployment-wide, not per-account (contract §7.3). |
webhooks.signature, webhooks.event_types |
The signing scheme your receiver verifies against, and the event types it must tolerate (§7). |
currency |
This host's own operator's pricing currency — the deployment default, present however many suppliers the host serves. Your supplier's currency is currency on GET /v1/whoami, per key: pin that one, per credential. |
forward_compatibility |
The tolerance contract your client must implement (§11). |
features[] and event_types grow additively. Ignore codes you do not recognise.
1.5 Envelopes, money and request ids
Every success response is { "request_id": "req_…", "data": …, "meta": { } }. data is an object
or an array depending on the endpoint. meta carries as_of on the reads that snapshot, and
next_page on the lists that page — the bookings list, the settlements list and
/v1/reference/policies; the full-refresh catalogues return it as null. Every error is a different
envelope (§10).
All money is { "amount": <integer minor units>, "currency": "EUR" }. Never scale by 100 in your own
code; read the exponent from the currency.
Every response carries an X-Request-Id header. If you send your own ([A-Za-z0-9_.:-], up to 128
characters) it is echoed back and appears in our logs — quote it in support and we can find the
exact call. It must be an opaque correlation id — a UUID, a ULID, your own counter — never a
name, email, phone, licence number or session token: the value is recorded in an immutable audit
trail and cannot be taken out again (contract §1.5).
1.6 Suppliers, and the one column to add to your schema
GET /v1/suppliers (a key, no scope) returns the suppliers you are selling for. Read it before you
sync anything, and store the supplier_id it returns alongside every record you keep from this API.
GET /v1/suppliers
→ { "data": [ { "supplier_id": "acct_…", "name": "…", "status": "active",
"currency": "EUR", "tax_inclusive": true, "payment_models": ["pay_on_arrival"] } ] }
One entry comes back per supplier your key reaches — two in the sandbox, whose one key reaches both
of its suppliers — and a supplier granted to your account later is one more entry, with nothing else
changing shape. The request to make of
your own design is that nothing you build may depend on there being only one supplier in the
world: iterate the array, never index it; key your catalogue, station mapping, in-flight search
results and reconciliation tables on (supplier_id, …); put the value in a column rather than a
constant.
🚨 THE SANDBOX GIVES YOU TWO SUPPLIERS UNDER ONE KEY, SO THIS IS TESTABLE RATHER THAN THEORETICAL.
A self-serve sandbox account is issued one key that reaches both suppliers, and the two are
deliberately unalike: different currency, different VAT rate, different payment terms, different
desks, one publishing iata/geo and one not. They also share a station code and a vehicle
category code under different ids — which is exactly the case that makes a code-keyed client
observably wrong: a catalogue keyed on code alone merges two suppliers' desks into one, and a
request that pairs one supplier's station with the other's car is refused 422 supplier_mismatch.
Run your catalogue sync, your search and one booking at
each supplier before you certify. (An account approved before one-key provisioning holds one key
per supplier instead; run them under each key.)
Take currency, tax_inclusive and payment_models from HERE, per supplier — not from
GET /v1/whoami. whoami describes your KEY: while a key reaches one supplier its top-level members
are that supplier's, and for a key that reaches several they are the reading true of all of them —
so whoami.currency is null for the sandbox's key, whose suppliers price in EUR and GBP, and
whoami.books[] states each supplier's. This list is the surface that keys on the supplier itself,
so it is the one that is right whatever your key reaches. whoami is not deprecated and its members
are not going away. There are three currencies on this API and they are three different facts:
capabilities.currency is this host's own operator's default, whoami.currency is your key's
(null when its suppliers differ), and suppliers[].currency is each supplier's — build on the
last one.
Why it earns the column even while there is one supplier:
- The objects already carry it. Stations, vehicle groups, protections, extras, fees, availability
offers, quotes, bookings, booking summaries and settlement statements each report the
supplier_idthey belong to. If you do not store it, you are discarding the only thing that will tell them apart later. - A station
codeis unique per supplier, not globally (contract §2.1). Mapping stations oncodealone is the specific bug this column prevents; map onlocation_id, or on(supplier_id, code). The sandbox's two suppliers both publish a desk codedsbx-aptunder differentlocation_ids, so a client with this bug fails there — deliberately. - Money differs per supplier.
currencyandtax_inclusiveare stated per entry here, so a display or accounting path that reads them from one global setting is wrong the day they differ — and in the sandbox they already differ (EUR at one supplier, GBP at the other, at different VAT rates).GET /v1/capabilities.currencyis the HOST's own default and is not your supplier's;GET /v1/whoami.currencyis your KEY's, which is a supplier's only when the key reaches one (andnullfor a key whose suppliers price differently). This list is the one that answers per supplier. payment_modelstells you what your agreement with that supplier permits; sendpayment_modelon a create only when there is more than one (§5.1). Where the member is absent we are not stating the agreement, which is not the same as "nothing is permitted".nameis for your interface only. It changes;supplier_iddoes not.
supplier_id is the same value attribution_token reports on GET /v1/whoami, so if you already
pin that per credential you are holding this id under another name. Two brokers receive different ids
for one supplier, so it identifies a supplier to you — an id from another broker will never match
yours even for the same supplier, which is why name (the supplier's actual trading name, the same
for everyone) is what you show a human and supplier_id is what you key on.
X-Supplier-Id is an optional request header naming the supplier a call is about. You never have
to send it — the ids in your request identify the supplier, a lookup by external_ref resolves
across every supplier your key reaches (§5.3), and an id-less call (the catalogue, a search, the
bookings list, your webhook registration) answers for, or applies to, all of them. Send it to narrow
such a call to one supplier; a value that is not one of yours is refused 422 unknown_supplier. Echo
back an id you were served; do not invent one.
1.7 Selling through downstream channels
Skip this section unless you resell through downstream channels of your own — a comparison site, an
agency, another reseller — under an agreement that approves each one. If you do not, send nothing: the
default policy is off, and a request that names no channel is answered exactly as it was before
channels existed.
Read the rules that apply to you first. GET /v1/whoami states your channel policy (off,
optional or required) and enforcement (refuse or flag) for each supplier in books[], and at
the top level where your suppliers agree (each top-level member is null where they differ).
GET /v1/distribution-channels lists the channels each supplier's register holds for you, with the
status and window each may sell in (contract §2.9, §3.11). A code is yours: letters, digits, dot,
underscore and hyphen, 1 to 64 characters, matched exactly, case included.
Name the channel on every call that prices or sells — one key serves all your channels:
| Call | How you name it |
|---|---|
GET /v1/availability |
The query parameter distribution_channel=<code>. |
POST /v1/quote |
The body member "distribution_channel": { "code": "<code>" }. |
POST /v1/bookings, both shapes |
The same member. |
PATCH or POST …/modify |
The same member; null removes it. It is judged only when it changes. |
POST …/cancel |
The same member, as an assertion: it must be the channel recorded on the booking, or the cancel is 422 validation_error, issue booking_channel_mismatch. Leaving it out is always fine. |
One rule on a search, a quote and a booking — a search and a quote are judged exactly as a booking is:
- Policy
off: naming a channel is422 validation_error, issuenot_permitted. - Policy
required: a search, a quote or a create that names none is422 validation_error, issuerequired, and so is a modify that removes the channel. - A code the register does not approve for today — unregistered, proposed, suspended, deactivated, not
yet in effect or expired — is, under enforcement
refuse,422 distribution_channel_not_approved(issueunregistered,not_approvedornot_in_effect), and nothing is priced or written. Underflagit is served, and the register's verdict comes back asapprovalin the echo. - Two calls do not judge. A modify judges a channel only when it changes it: sending the recorded code back is not judged. A cancel never consults your policy or the register; it only asserts, as the table says, and is never refused for lacking a channel.
The price is the channel's. A search, a quote and a direct create that name a channel are priced
from the operator's prices for that channel where it keeps separate ones, and otherwise from your
account's own; a modify re-prices on the channel the booking has after the change; and a code that
flag lets through with no register entry is priced from your account's own prices. Nothing on the
wire says which set priced a figure: what you receive is the supplier's price for that request either
way (§3.3). So a direct create that does not name the channel you searched with is priced from your own
set, and may answer 409 price_changed. The echo is meta.distribution_channel on a search page (in a
search by place, distribution_channel inside each leg of meta.suppliers[].searches[]) and
distribution_channel on a quote, present only when you named a channel.
Keep a quote and its booking together. A booking made from a quote that named a channel must name
the same one (422 validation_error, issue quote_channel_mismatch). A quote that named none was
priced from your account's own prices, so it can be booked under a channel only where the operator
keeps no separate prices for that channel. Send the channel on the quote you make from a row you
searched with it: a quote that does not name it is priced from your account's own prices, and its
plan is looked up there — 422 validation_error, issue unknown_plan, where your own prices hold no
such plan.
A search by place judges the channel supplier by supplier: one that refuses it is listed in
meta.suppliers with no searches and a distribution_channel_refusal, and the search itself is
refused only when every supplier with a desk at the place refuses it.
The sandbox follows the same rule. Read your sandbox account's policy on GET /v1/whoami. It
starts off, so naming a channel there is refused until you register test channel codes on your
sandbox account page — contract §2.9, Testing your channels in the sandbox.
2. Sync the reference catalogue
Before you search, quote or book, cache the catalogue. Eight endpoints, all GET, all
read:reference:
GET /v1/reference/constants
GET /v1/reference/locations
GET /v1/reference/vehicles
GET /v1/reference/protections
GET /v1/reference/extras
GET /v1/reference/fees
GET /v1/reference/policies
GET /v1/reference/terms
What you need them for. locations gives you the stable location_id values that
/v1/availability, /v1/quote and /v1/bookings all take — map your own station identifiers to
these once and send ids, never names. The list is the operator's current selling estate: a desk
the operator closes leaves it and its id is then refused like an unknown one; a re-opened desk
returns under the same id — keep locations on the same re-sync loop as the rest and stop offering
stations that drop out (contract §3.2). vehicles gives you vehicle_id, the ACRISS code and its
decode, the display name and the permitted driver-age band. protections gives you protection_id
per vehicle group with the tier's excess, deposit and price — the figures to show the customer —
for the price plan you are served. A package on any other plan is not in it; that plan's
availability and rate rows carry it.
policies carries the mileage, fuel and cancellation rules in force for your account (each one
source-tagged, so you can tell an override negotiated with you from the operator's default),
deposit_payment_methods, tax_inclusive, the rental-length bounds min_rental_days /
max_rental_days that quote and booking enforce, min_lead_hours, and movement_restrictions
(contract §2.6). terms is the supplier's terms and conditions as versioned documents (contract
§3.9): sync the list like any other catalogue and fetch a document_id's body once — it is your
evidence of what the customer was shown.
Three rules that apply to every account, opt-in or not:
movement_restrictionssays which (pickup, dropoff) movements the operator performs at all. A movement it forbids is refused at quote, at both booking creates and on aPATCHthat moves an end, with422 validation_errorand issuemovement_not_permitted. Pre-filter your routes against it and you will never see that422.payment_modelis an optional member on both create shapes. Send it only if your agreement lets you sell more than one payment model; omit it otherwise (contract §4.2, §5.1).plan: availability may return one row per vehicle group per price plan, each carryingplan. Key rows on(vehicle_id, plan)and send the row'splanback onPOST /v1/quotetogether with the row's ownprotection_id— each plan sells one package, and the row is where that package's id is (contract §4.1, §4.2).
Ids are opaque. vehicle_id, protection_id, extra_id, fee_id and location_id are stable
strings with no internal meaning. Store them, send them back verbatim, never parse or construct them.
An id is never reused. Every id in the examples below is illustrative — take real ones from the
catalogue.
2.1 How to build the sync loop
GET /v1/reference/constantstakes no query parameters — any parameter is422. It is the static enum document: the code lists you must never hardcode./v1/reference/policiesnarrows. Passupdated_sinceand it returns only what changed, with real keyset pagination throughmeta.next_page./vehicles,/protections,/extras,/fees,/locationsand/termsreturn the complete current catalogue in one page.meta.next_pageisnull;limitandpage_tokenare accepted for uniformity and do nothing.- Follow
meta.next_pageto exhaustion, passing the token back verbatim. A token is opaque and endpoint-specific: never construct one, never replay one against another endpoint (422). Whenpage_tokenis present,updated_sinceis ignored. - Treat every response as an UPSERT keyed by the record's own id, never a destructive replace.
- Record the
supplier_idon every row you upsert (§1.6). It is on each record we return — since 1.36.0 you no longer have to fetch it separately, though it is the same opaque, permanent valueattribution_tokenreports onGET /v1/whoami(contract §3.8). Carrying it into your own table is what keeps a second supplier additive for you as well as for us. Key a station on(supplier_id, code)or on itslocation_id— acodeis unique only within one account, so a mapping oncodealone breaks the day you connect a second one. Two tokens are two accounts and not necessarily two suppliers (§3.8): if you ever hold two keys with the same supplier, their stations are two sets to you, so match them onlocation_id. updated_sinceis a full ISO-8601 datetime with an explicit offset (2026-07-04T10:00:00Z); date-only and offsetless values are422. Store each response'smeta.as_ofand pass it as your nextupdated_since— it is deliberately set a short lap behind the server clock so a concurrent write is never permanently missed.
2.1a Narrowing the full-refresh catalogues: changed_since
On /vehicles, /protections, /extras, /fees and /locations, updated_at is the instant of
your call — one snapshot value across the page — so there was nothing on those responses to diff.
Every record on those five now also carries row_updated_at, the instant that record itself
last changed, and the endpoints accept changed_since (same shape as updated_since; a misspelling
is 422 unknown_parameter; on /policies it is 422, because updated_since already narrows there):
GET /v1/reference/vehicles?changed_since=2026-08-22T09:14:00Z
- Send your stored watermark as
?changed_since=. You get the records whoserow_updated_atis at or after it — inclusive, so a boundary change is never lost. - UPSERT them by id.
- Store that response's
meta.changed_as_ofas your next watermark — nevermeta.as_of, never your own clock.changed_as_oftrails the read by a short lap so a write still in flight when your page was built is caught on the next call.
⚠️ On a changed_since call an absent record means UNCHANGED, never withdrawn. Keep making the
call without changed_since on your own schedule; that is how you notice what left (§2.2).
🪤 protections and fees are coarse: every record of an operator shares one stamp, so
changed_since there returns the whole collection or nothing. Treat a hit as "something in here
changed" and re-UPSERT the set. locations, vehicles and extras are genuinely per-record.
2.1b Polling cheaply: ETag and If-None-Match
If you re-read a catalogue on a timer, send back the tag you were given. On /vehicles, /extras,
/protections, /fees, /locations, /terms and /constants every 200 carries an ETag; echo it as
If-None-Match and an unchanged page is answered 304 Not Modified, with no body:
GET /v1/reference/extras
If-None-Match: W/"q3Zx…"
304 Not Modified
ETag: W/"q3Zx…"
- Store the
ETagbeside what you keep for that read. Send it on the next read of the same page (same key, same supplier selection, same query): the tag describes the content of the page you asked for, so achanged_sinceread has a tag of its own and must not borrow the full catalogue's. - On
304keep what you hold — including youras_of/changed_as_ofwatermarks. A304is "your stored copy is still current": itsrequest_id,meta.as_ofandupdated_atare those of the read you stored, so quote theX-Request-Idheader of the304itself when you contact support. It does not refresh your watermarks, and replaying the older ones can only re-read a row, never skip one. - On
200merge what arrives exactly as §2.1a and §2.2 say — UPSERT by id, an absent record is not a deletion — and store the new tag and watermarks beside it.
The tag follows the page's content, not the clock: the read instant that moves on every call is not in it,
so an unchanged catalogue keeps its tag, and any change to what you would store gives it a new one. ⚠️ A 304 saves the body, not the call. It counts against your
rate limit and cost quota exactly as a 200 does, so choose your interval for how fresh you need the data,
not for how cheap an unchanged answer is. Under an account key that reaches several suppliers, a supplier we
could not read is named in meta.suppliers_unavailable and the tag then differs from the complete page's,
so a partial page is never confirmed as the complete one. (Contract: §1.10a.)
2.1c Pulling the rate card: replace, don't upsert
GET /v1/reference/rates is the one read in this section where an absence is a deletion. Its rows have
no id; a station that gets its own price list, a season that ends or a group that is withdrawn leaves by not
appearing. So the loop is the opposite of §2.1a: read the page, and for each supplier_id in it replace the
rates you hold for that supplier. meta.collection repeats that on the wire.
GET /v1/reference/rates
-> data[]: { supplier_id, vehicle_id, plan, default_plan, valid_from, valid_to,
stations: { all, location_ids }, totals: { "1": Money|null … "7": Money|null },
extra_day, protection: { protection_id, pricing_type, price, price_basis?, price_percent?, excess, deposit, theft_excess? } }
-> meta.pricing_by_supplier[supplier_id]: { billing_day, lengths_beyond_7, cross_period, tax_inclusive, excludes }
- A supplier named in
meta.suppliers_unavailablewas not read. Leave its rates as they are: its absence from this page is not a deletion. This is the one place that rule is not optional; replacing it would empty a supplier's rate card for the length of an outage. A supplier that does not price thedistribution_channelyou named is named there too. Replace only the slice you asked for (meta.collection.scope): a read narrowed byplanordistribution_channelis not the whole card. - A stay of N days costs
totals[N]for N up to 7 andN × extra_daybeyond. Anullis no price, never €0, and that length cannot be quoted. Read the plan's package fromprotectionand add itspriceper day when it isper_day; when it carriesprice_basis: "minimum"that figure is only the floor.price_percentgives the share and the per-day bounds — apply them to the stay's base total by the rule and vectors in the contract (§3.5) to show a total without a call, taking them from the row you price from; the quote stays exact and is what you book on. - Read the rule from
meta.pricing_by_supplier[row.supplier_id], never from a neighbour. Grace hours, currency, the rental-length limits (rental_days: a quote outside them is refused whatever the card says) and the way a stay across two rate periods is priced belong to each supplier. - A rate is not availability. It says what the rental costs if the vehicle is free. Price the stay you will sell with
POST /v1/quote(§4) and book on that.
It takes plan and distribution_channel only; limit, page_token, updated_since and changed_since are
refused rather than ignored, and it is conditional like the other catalogues (§2.1b). (Contract: §3.12.)
2.1d Pulling the stop sales: replace, don't upsert
GET /v1/reference/stop-sales is the second read in this section where an absence is a deletion. A row is one closure
of one vehicle group, at every station; when the operator lifts a stop its row simply stops appearing. So, as for the rate card (§2.1c),
replace the closures you hold for each supplier from each response.
GET /v1/reference/stop-sales (no query parameter: every one is refused)
-> data[]: { supplier_id, vehicle_id, stations: { all: true }, start, end, source }
-> meta.rules_by_supplier[supplier_id]: { refused_when, dates, stations, also_refused_by, sold_out_horizon_days, sold_out_through }
- The suppliers read are the keys of
meta.rules_by_supplier. A supplier there with no rows has nothing closed: clear what you hold for it. A supplier named inmeta.suppliers_unavailablewas not read — leave its closures as they are. - A rental is refused because of a row when its pickup date is on or before
endand its dropoff date is afterstart— a rental that returns on thestartday is not refused, one that picks up on theendday is. Dates are the operator's own calendar dates. - It is a list of closures, not of availability. Fleet occupancy, lead time, rental length, out-of-hours, delivery and movement rules can each refuse a rental the list says nothing about. Quote (§4) and book on the quote.
- It can miss a closure, never invent one. A
manualrow is a group whose every active model is stopped; one model stopped on Monday and another on Tuesday publishes no stop, yet a Monday-to-Tuesday rental is refused at the quote. A group priced only inside a channel's own book set is not listed. source: sold_out(since 1.58.0) marks days on which the group has no unit left to sell (booked out, stopped or withheld that day), formeta.rules_by_supplier[*].sold_out_horizon_daysdays from today, throughsold_out_through(a run ending there may continue beyond it; one starting today may have started earlier). It is derived for partners only and never overlaps amanualrow. As ofmeta.as_of, a new rental covering such a day is refused by the quote and the booking — a modification of a booking you hold, and a cancellation since your read, are the exceptions — and a rental can still fail where every day has a unit but no single car is free for the whole span. Treat an unknownsourceas a closure of unknown cause.
It is conditional like the other catalogues (§2.1b). (Contract: §3.13.)
2.1e Your own codes: partner_code (since 1.60.0)
If your system keys its table on your codes — a vehicle code, a product code, a station code, an accessory code — ask the supplier to record them; the reference rows then carry them (contract: §3.14).
GET /v1/reference/vehicles, /protections, /extras, /locations -> data[].partner_code
GET /v1/reference/rates -> data[].vehicle_partner_code, plan_partner_code, stations.location_partner_codes[]
GET /v1/reference/stop-sales -> data[].vehicle_partner_code
GET /v1/reference/fees -> data[].pickup_location_partner_code, dropoff_location_partner_code
- No map, no member. Until the supplier has recorded a code for you, none of these members appears: you read exactly what you read before.
- Once mapped,
nullis an answer. The members appear on every row they apply to, andnullmeans the supplier has no code of yours for that object — ask it for one, do not guess. - Per supplier. Key your table on
(supplier_id, partner_code). Two suppliers can reuse a code for different objects. - Identity does not move. Keep joining on
vehicle_id/protection_id/extra_id/location_id; send those back. A supplier can correct a code, and the new value arrives on the next read with the same id. - Only on the reference reads. Availability's embedded accessories and protections, and the
protectioninside a rate row, carry nopartner_code: key your table from the reference pages and join availability to it by id.
2.1f Keeping your own codes yourself (since 1.62.0)
Rather than asking the supplier to type your codes in, a supplier can let you record them (contract: §3.15). It is off until the supplier switches it on for your
account; ask it to, and have it mint a key with read:partner-codes and write:partner-codes.
GET /v1/partner-codes -> data[] = { kind, id, name, code } (Accept: text/csv for a file to fill in)
PUT /v1/partner-codes?dry_run=true { "entries": [ { "kind": "vehicle", "id": "…", "code": "A1" } ] } -> data.applied = { set, changed, cleared, unchanged, released }
PUT /v1/partner-codes the same body (or text/csv with the columns kind,id,code) — applies it
- Start from the GET. It lists exactly the objects you may write a code for, with your code where you have one and
nullwhere you do not. The CSV form is the file to work in: fill thecodecolumn, delete nothing, send it back astext/csv. An emptycodecell withdraws that object's code. A code that begins with=,+,-or@is written with one apostrophe in front, so a spreadsheet shows it as text; leave it there, the import takes it off again (contract §3.15). - Dry-run first.
?dry_run=truechecks the request and writes nothing, and returns the counts a real request would. It does not test the hourly cap, so a dry run that passes can still be followed by a429. - A refusal is the whole file. The shape is checked first; then each entry reports all of its own problems, with its entry (
entries[3].code) or its file line (lines[7].code), up to 100 and amore_problemsentry for the rest. Fix them and send again. Nothing in a refused request is applied. - Retry knowing what it does. Sending the same body again changes nothing further, and a request that restates your map is
200with everythingunchanged, writing nothing and using none of your saves. But the write is last-writer-wins: if the supplier changed an object after your first attempt, a repeat puts your value back.GETfirst when you do not know why a request failed. - Thirty saves an hour, per supplier. A request holds up to 2 000 entries; send your changes in as few requests as you can. The 31st save in an hour is
429 rate_limitedwithRetry-After. unknown_objectis one answer. An id that does not exist, that belongs to another supplier or that you may not read all read the same; list with the GET and use the ids you are given.- A code left on an object you can no longer read is released, not refused, when you give it to an object you can read;
applied.releasedcounts them. Which codes are released and which are refused is the contract's (§3.15).
2.2 Withdrawal
extras, protections and fees carry an active flag that is true on every record served: a
withdrawn product is dropped from the response, so withdrawal reaches you as an absence. An
absence is not a deletion — merge every refresh into what you hold, keep the record quotable, and
treat an id that reappears as the same record (ids are never reused). The authoritative signal is
the attempt: a withdrawn id fails POST /v1/quote or a direct create with 422 validation_error naming the field and the id; a quoted create whose catalogue moved between quote
and book fails 409 availability_changed. Stop offering an item once its quote fails that way;
never delete it on absence alone. Certification exercises exactly this, in two phases that must both
pass: a refresh arrives with a vehicle missing and you keep quoting it from your records; the next
phase puts it back under its original id (§12).
Refresh cadence. Daily is sufficient. Priced totals are not in the catalogue — every priced total comes from availability and quote.
3. Search availability
GET /v1/availability?pickup_location_id=loc_her_apt&dropoff_location_id=loc_her_apt&pickup=2030-08-14T10:00:00%2B03:00&dropoff=2030-08-18T10:00:00%2B03:00¤cy=EUR
X-API-Key: <your key>
Scope read:availability. A GET with query parameters; no body. The instants in this guide are
set years ahead so the examples stay runnable as written.
3.1 Parameters
| Parameter | Required | Notes |
|---|---|---|
pickup_location_id |
yes, unless you name a place | A location_id from /v1/reference/locations. |
dropoff_location_id |
yes, unless you name a place | Same. One-way is simply a different value. |
pickup |
one form required | Offset-aware ISO-8601 instant, e.g. 2030-08-14T10:00:00+03:00. |
dropoff |
one form required | Same. |
pickup_date + pickup_time |
alternative form | YYYY-MM-DD + HH:MM, interpreted in the location's timezone. |
dropoff_date + dropoff_time |
alternative form | Same. |
currency |
no | Three uppercase letters. Defaults to the operator's currency. |
driver_age |
no | The main driver's age in whole years, 16–120. Changes what you get back (§3.3). |
cross_border |
no | The literal true when your customer will take the vehicle out of the country of hire — the one price input we cannot infer (contract §4.1). |
distribution_channel |
no | Your channel code, if you resell through downstream channels (§1.7). |
pickup_iata, or pickup_city with pickup_country_iso2; dropoff_iata, or dropoff_city with dropoff_country_iso2 |
no | Search a place instead of one desk: every desk at an airport or in a city, of every supplier your key reaches. Name the desks or name the places, never both — a place with a location id is 422, and a dropoff place needs a pickup place. GET /v1/reference/locations narrows to the same desks with iata, city and country_iso2. REFERENCE.md lists each parameter. |
For each stop send either the instant form or the date+time pair. The dropoff local date must
be after the pickup local date. Any parameter not in that table is 422 with
details[].issue = "unknown_parameter"; a repeated parameter is 422 too.
3.2 What comes back
{
"request_id": "req_…",
"data": [
{
"vehicle_id": "veh_9c1a4f22",
"display_name": "Compact Automatic",
"acriss": "CDAR",
"available_units": 3,
"price": { "amount": 21400, "currency": "EUR" },
"price_type": "indicative",
"requires_quote": true,
"protection": { "…": "the CHANNEL-API.md §2.4 tier the price was computed with" },
"protection_options": [ { "…": "the one package this plan sells (since 1.32.0); it IS `protection` above" } ],
"line_items": [ { "…": "one CHANNEL-API.md §6.1 line per base rate / protection / fee" } ],
"tax_breakdown": [ { "…": "one entry per tax rate" } ],
"totals": { "…": "subtotal_ex_tax, tax_total, grand_total, pay_now, pay_at_pickup" }
}
],
"meta": {
"as_of": "2026-07-29T09:41:02.114+00:00",
"withheld": [],
"rental_days": 4,
"extras": [ { "…": "the CHANNEL-API.md §2.3 catalogue, each entry priced for this window" } ],
"policies": { "…": "mileage / fuel / cancellation, each `source`-tagged, plus deposit_payment_methods and tax_inclusive — CHANNEL-API.md §4.1" }
}
}
A flat array of bookable vehicle groups — every group with at least one free unit and a computable
price, so available_units is always at least 1. No pagination. Cache-Control: no-store: do not
cache it.
available_units is fleet-wide, not per station. The station ids you send are validated and
priced (they decide delivery and out-of-hours charges) but do not partition inventory: the same three
cars answer 3 at every station. Never add counts across stations.
What a row carries, and the capability code that gates each member (features[] on
GET /v1/capabilities; the codes are deployment-wide, not per-account):
| Member | Code | What it is |
|---|---|---|
protection, line_items, meta.rental_days, meta.extras[], meta.policies |
availability_enrichment |
The tier the price was computed with (excess and deposit included), the priced lines whose total is price, the billed day count, the extras catalogue priced for this window, the policies in force. |
tax_breakdown, totals |
availability_full_breakdown |
The full §6 breakdown on the row — nothing on the card needs arithmetic on your side. Read the tax rather than deriving it: VAT is extracted per line then summed (contract §6.2). |
protection_options |
availability_protection_ladder |
The row's sellable protection list; the first entry is protection — and since 1.32.0 it is that one package and nothing else, because each price plan sells exactly one (another package is another plan's row). meta.policies.deposit_payment_methods ships under this code. |
meta.withheld |
availability_withheld_reasons |
One entry per reason a group is missing from the page, with a count — empty when nothing was suppressed, so a short page explains itself. |
meta.policies.tax_inclusive |
— | No code; read the member. |
meta.policies.cancellation carries the rule in force for you, not just its label — for a
free_until rule the notice deadline and the optional late fee; for a tiered rule the band ladder;
on every type the basis each percentage is measured against — the same object
GET /v1/reference/policies serves, with source naming whether it is the operator's default or an
override negotiated with you (contract §2.6 is authoritative). The fee the operator charges you
on cancellation (§6.3) is computed from this rule as it stood when the booking was made, so a results
page that says "free cancellation" without reading it can promise something the operator will bill
you for.
⚠️ meta.policies can be absent altogether — where the operator has stated no policy at all the
member is not there. Test for it before reading it, and do not take its absence for free
cancellation: terms negotiated with you can still be in force.
deposit_payment_methods is worth wiring in early: it is what the desk accepts for the security
deposit, so a results card can warn "credit card required" before the customer picks a car.
Two per-account opt-ins change this page — duration-bounds and end-terms screening (§10.2b),
both off by default. features[] cannot tell you whether either is on for you; GET /v1/whoami
can: one authenticated call returns your opt_ins, scopes, negotiated policy keys and webhook
state (contract §3.8). Ask us to switch either on; you never have to ask which is on.
The catalogue you synced in §2 is still where the stable ids and full detail live. The embedding removes the per-search join; the applied amounts for a specific customer still come from the quote.
3.3 What price is, exactly
price is the indicative grand total for the whole rental — not a per-day rate — for your route,
dates and times (one-way and out-of-hours charges included), that vehicle group, the cheapest
protection applicable to it, no extras, and a single main driver at a representative age outside
every age-fee band — unless you send driver_age, in which case it is that driver. price_type is
"indicative" and requires_quote is true always: a "from" price for a results page, not an
offer.
Whose price it is. price — and totals.grand_total on a quote — is the supplier's price for the
rental, tax-inclusive, from the price book that serves your account (the channel's own prices, when you
name a channel the operator prices separately — §1.7). It is not what your customer pays you: any fee
you add for your own customers or channels is yours to add, it is not on this API, and we never see it.
Whether the total is what the operator receives (net) or what you are owed a commission off
(gross) is stated as settlement_basis in the payment block of a booking, where that block is
present (contract §2.8).
Send driver_age if your form collects it. The age surcharge then moves inside price (as a
fee line in line_items), and groups the driver may not rent disappear from the page — an
age-filtered search returns fewer groups, which is the correct page. meta.driver_age echoes the age
the response was priced at, and is absent when you sent none.
Why a quote can differ. Your inputs carry straight into the quote; the assumptions do not — the protection the customer chooses, any extras, each driver's real age and each additional driver are priced at quote. And the indicative price is not binding: pricing, protections and availability can all move between search and quote, and nothing is wrong when they do. Only the quote is authoritative, and only until it expires. Never treat the indicative price as the price of a booking — get a quote (§4) and book against it.
4. Get a firm price: POST /v1/quote
POST /v1/quote
X-API-Key: <your key>
Content-Type: application/json
Idempotency-Key: 6a3f0e2c-1d4b-4a97-9c02-0f2b8e5d1a77
{
"currency": "EUR",
"pickup": { "location_id": "loc_her_apt", "at": "2030-08-14T10:00:00+03:00" },
"dropoff": { "location_id": "loc_her_apt", "at": "2030-08-18T10:00:00+03:00" },
"vehicle_id": "veh_9c1a4f22",
"protection_id": "prot_3d70b18e",
"extras": [ { "extra_id": "xtr_baby_seat", "quantity": 1 } ],
"drivers": [ { "age": 34, "role": "main", "country": "DE" } ]
}
Scope write:quotes. Content-Type: application/json and an Idempotency-Key are both required.
4.1 The request body
Required: pickup, dropoff, vehicle_id, protection_id, drivers. Optional: currency,
extras, plan, cross_border and distribution_channel.
- Each stop requires
location_idand carries eitherat(offset-aware ISO-8601) ordate+time. planis the price plan to price from. Send theplancarried by the availability row you are pricing; a plan your account holds no price book for at the pickup location is422 validation_error, issueunknown_plan.cross_borderis a JSON boolean: sendtruewhen your customer declared they will take the vehicle out of the country of hire. Carry the same declaration you searched with — a search that declared it and a quote that did not are pricing two different rentals (contract §4.1).distribution_channelis{ "code": "<your channel code>" }(§1.7).protection_idis required — no default, no implicit cover. Send the one on the row you are pricing — the availability row'sprotection, or a rate row's — with that row'splan./v1/reference/protectionslists only your served plan's packages, so its id is the right one only for a served-plan row of the samevehicle_id: under the served plan another plan's package is422 validation_errorwith issueunknown_protection, and a tier id from another group is422.driversis 1–9 entries, each with an integerage(16–99) and aroleofmainoradditional;countryis an optional two-letter code. Ages drive the young-driver fee — send the real ones.extrasis up to 20 entries of{ extra_id, quantity }, quantity 1–9.- The body is strict: any field not listed above is
422.
4.2 The response
201 on a new quote, 200 on an idempotent replay. data is the full price breakdown with the
quote fields alongside it:
{
"request_id": "req_…",
"data": {
"currency": "EUR",
"rental_days": 4,
"line_items": [
{ "kind": "base_rate", "code": "…", "label": "…", "quantity": 4, "unit_price": { "…": "…" }, "total": { "…": "…" }, "payable": "…", "taxable": true },
{ "kind": "protection", "code": "…", "label": "…", "quantity": 4, "unit_price": { "…": "…" }, "total": { "…": "…" }, "payable": "…", "taxable": true },
{ "kind": "extra", "code": "…", "label": "…", "quantity": 1, "unit_price": { "…": "…" }, "total": { "…": "…" }, "payable": "…", "taxable": true }
],
"tax_breakdown": [ { "code": "VAT", "rate": 0.24, "taxable_base": { "…": "…" }, "amount": { "…": "…" } } ],
"totals": {
"subtotal_ex_tax": { "…": "…" }, "tax_total": { "…": "…" },
"grand_total": { "amount": 24900, "currency": "EUR" },
"pay_now": { "…": "…" }, "pay_at_pickup": { "…": "…" }
},
"deposit": { "amount": { "…": "…" }, "type": "pre_authorization", "refundable": true, "collected_at": "pickup" },
"quote_id": "3f2a91c4-8e77-4a1e-b5d0-6c9f0b2a1d33",
"expires_at": "2026-07-29T10:11:02+00:00",
"status": "active"
},
"meta": { }
}
totals.grand_totalis the supplier's price for the rental, and the amount a booking made from this quote is confirmed at (§4.3); §3.3 says whose price it is. Prices are tax-inclusive: tax is extracted from each line, sogrand_totalalready containstax_total. Do not add tax on top.distribution_channel— present only when the quote named a channel — is the echo of §1.7:{ distribution_channel_id, code, approval }.tax_breakdown[].rateis a decimal fraction, grouped by rate. Never hardcode a rate.line_items[].payableisprepaidorat_pickupper line;totals.pay_now/totals.pay_at_pickupare those lines summed. The split is operator configuration — read it.depositis the refundable pre-authorisation taken at pickup. It is not part ofgrand_totaland never insidepay_noworpay_at_pickup.statusisactiveorexpired; a lapsed quote reportsexpiredon replay.line_items[].kindcan also bediscount(a negative line) when a promotion applies (contract §6.1).
rental_days is the billed day count: 24-hour periods between pickup and dropoff in
wall-clock time in the location's timezone, with an operator-configured grace allowance before a
partial period rounds up, minimum 1 — so a daylight-saving transition never changes it. Read it from
the quote; never re-derive it (contract §6.2 has the formula).
4.3 What a quote guarantees
A quote guarantees the price. It does not hold inventory. Booking against a valid quote_id is
confirmed at exactly the quoted grand_total — there is no price-consent exchange on that path.
What can still fail is availability: a sold-out group fails the create 409 availability_changed
and nothing is booked. Quotes are valid for capabilities.quote_ttl_seconds; expires_at is
authoritative, and booking against a lapsed quote is 409 quote_expired.
Failures on the quote call: 422 validation_error (fields, unknown parameters, a rental duration
outside min_rental_days/max_rental_days — details[].issue = "rental_duration_out_of_range"
with min, max and value — a movement the operator does not perform, a pickup inside
min_lead_hours), 422 no_availability, 422 driver_age_out_of_range, 422 not_sellable,
409 idempotency_in_progress.
5. Create the booking
POST /v1/bookings, scope write:bookings, Content-Type: application/json, Idempotency-Key
required. Two shapes, selected by whether the body carries quote_id.
5.1 Quoted (the path this integration uses)
{
"quote_id": "3f2a91c4-8e77-4a1e-b5d0-6c9f0b2a1d33",
"external_ref": "PARTNER-12345",
"customer": {
"first_name": "Anna", "last_name": "Weber", "email": "[email protected]",
"phone": "+49 30 1234567", "country": "DE", "locale": "de-DE"
},
"drivers": [ { "first_name": "Anna", "last_name": "Weber", "age": 34, "role": "main", "country": "DE" } ],
"flight_number": "XY 4471",
"notes": null,
"meta": { "partner_channel": "web", "partner_booking_url": "https://example.com/b/12345" }
}
Required: quote_id, external_ref, drivers, customer. Optional: flight_number, notes,
meta, payment_model, distribution_channel (§1.7) and mode (quoted, which says the shape you
mean so that a mismatch is caught at the field you got wrong).
The priced selection and the priced driver set live in the quote. Sending any selection field
again is 422 — a booking can never disagree with the quote it references. The drivers named here
must match the quote's priced drivers on {age, role} as a multiset; names may differ. To change the
priced driver set, re-quote. There is no expected_price on this path: the quote's total is written
without re-pricing.
5.2 Direct (no quote)
Send the full selection inline — external_ref, pickup, dropoff, vehicle_id, protection_id,
drivers, customer, optional currency, extras, cross_border, flight_number, notes, meta,
plan, payment_model, distribution_channel (§1.7) and mode (direct) — plus
expected_price:
{ "expected_price": { "grand_total": { "amount": 24900, "currency": "EUR" } } }
Send expected_price on every direct create: the supplier total you are consenting to — a
quote's totals.grand_total where you hold one, otherwise the supplier price you were last given for
this selection — never a figure you computed yourself or added fees to (§3.3). If our computed price differs by even one cent the create fails 409 price_changed and
nothing is booked — details[] carry expected, computed and the full new breakdown, so you can
re-consent and resubmit in one round trip. The one exception, and why the field is not in the
schema's required list: an account can be configured to accept the supplier price without a
consent gate, and there the field is optional and ignored. Send it either way — the same client code
is correct under both (contract §5.1).
Use the direct path only where you cannot quote first. The quoted path moves the price agreement to before the customer pays.
5.2a A delivery booking: the hotel goes in notes
A location that prices a delivery (delivery: { "available": true, … } on
GET /v1/reference/locations; a hotel or address location may offer one) is a delivery area: it decides
whether the car can be brought there and what each end costs (§10.2b). It does not say where your
customer is staying. When either end of a rental is at such a location, put the hotel's name and
address in notes. That is the one place for it, for every partner: there is no structured hotel
or delivery-address field, and meta is not read for it.
- What the operator does with it. The desk reads
notesas written and brings the car to that hotel, or collects it from there. Nothing parses it, so write it for a person: the hotel's name, street, town and anything that helps find the reception. If the two ends are at different hotels, say which is which. - The limits, exactly.
notesis a string ornull, and nothing more is checked: no length limit of its own, no pattern, no character set beyond JSON's. The only bound is the whole request body, 64 kB; a larger body is400 malformed_requestand nothing is booked.null, or leaving it out, means no notes. - It is stored as you sent it and echoed back on the booking (
GET /v1/bookings/{ref}, the list, every webhook snapshot). It is not part of a booking's intent (§5.3), so a create replayed under the sameexternal_refdoes not apply a newernotes: compare, thenPATCHit. - The hotel changes:
PATCHnotes(§6.2). It replaces the stored value rather than appending to it, so send the whole text again. It never re-prices: a different delivery area is a change ofpickupordropoff, not ofnotes. - It is personal data. It is kept, and erased, together with the rest of the booking's customer
details, under the retention that applies to bookings made through the API: that is set by the
operator's configuration and your partner agreement, and is not necessarily the schedule for
direct bookings (
SECURITY-AND-DATA-HANDLING.md, "Data we hold, and for how long"). In the sandbox, use an invented hotel and address like every other value (§1.1a).
5.3 external_ref and duplicate protection
external_ref is your reference — up to 64 characters of A-Z a-z 0-9 . _ -, unique within
one supplier's book (the uniqueness we enforce is over operator + partner + ref) and, by the one rule
you owe us, never reused across your suppliers: GET /v1/bookings/{ref} resolves it across every
supplier your key reaches without a supplier selector, and a value you reused would be refused
409 ambiguous_ref on the day that matters (contract §1.8; reserved, never returned today). It is
bound to its booking for the life of the record, cancelled bookings included. A value whose create
never succeeded stays free for you to use again, so it is permanent once bound and no earlier; look a
booking up by it and CHANGE a booking by booking_id (contract §1.8). Settlement lines, webhook events and GET /v1/bookings/{ref} are keyed on it from your
side. Repeating a create with the same external_ref: same booking intent → 200 with the
existing booking; different intent → 409 booking_conflict, nothing created — modify (§6) or use
a new reference. Intent is exactly pickup and dropoff (location and instant), vehicle_id,
protection_id and extras; customer and driver names are not part of it.
5.4 Idempotency
Idempotency-Key is a client-generated UUID, required on six operations: POST /v1/quote,
POST /v1/bookings, PATCH /v1/bookings/{ref}, POST /v1/bookings/{ref}/modify,
POST /v1/bookings/{ref}/cancel and POST /v1/webhooks/redeliver — a network-retried replay must
not spend the redelivery budget twice. On the five booking and quote operations anything that is
not a UUID is 422; POST /v1/webhooks/redeliver accepts any non-empty value, so send a UUID
there too rather than relying on the looser rule. Those six are the
list. The account-management writes — rotating or revoking a credential, pointing or re-pointing
your webhook endpoint, rotating its secret, sending a test event — neither require the header nor
replay a stored result, so a retry of one of those does the work again (contract §1.8).
- One key per logical operation; reuse the same key for every retry of that operation, and never for anything else — a value replays only within the supplier's book it was first sent to.
- An identical replay within
capabilities.idempotency_retention_hoursreturns the original snapshot indataas the same JSON value (member order may differ — compare parsed values) inside a fresh envelope. A first create answers201, its replay200; a modify or cancel answers200either way. A lapsed quote's replay readsstatus: expired. - The same key with a different body is
422 idempotency_key_reused. - A retry that arrives while the original is still executing is
409 idempotency_in_progresswithRetry-After. Retry after it with the same key and body, and again with backoff and jitter while it persists: a retry can meet the original still executing. Once the original finishes, a retry receives its result. The same code also answers every write to a supplier, under any key (one you never sent included), while that supplier moves your relationship with it into your account so that one key reaches all your suppliers. Keep retrying with backoff; the write runs once the move completes.
5.5 The response
201 on a new booking, 200 on a replay or an identical-intent duplicate. data is the full
booking snapshot: booking_id, external_ref, quote_id (null on a direct booking), status,
revision, created_at, updated_at, pickup, dropoff, rental_days, vehicle, protection,
extras, drivers, customer, policies, price (the same breakdown shape the quote returned),
payment, cancellation_policy (the rule frozen onto this booking), whatever optional fields you
sent, and cancellation once cancelled. A booking created through this API is confirmed at
revision 1.
status is one of pending, confirmed, cancelled, no_show, completed today. The list is
open (since 1.64.0), like every other enum (§11): a later release may add a state, announced in the
changelog before any booking carries it. Give your switch on status a default branch that treats
a value you do not recognise as neither confirmed nor finished — do not show the booking to your
customer as confirmed, do not treat it as cancelled, keep it, and read it again (GET /v1/bookings/{ref}, the list, or the next webhook) until it carries a value you know. Never reject
the payload for it. A new state reaches only an account pinned at 1.64.0 or later. Pinned earlier,
you are served its announced equivalent, one of the five, on every surface including webhooks.
Contract §8.1.
6. Modify and cancel
6.1 revision and expected_revision
Every booking carries a revision: 1 at create, +1 on every change that emits an event — your own
modify and cancel, a desk-side cancellation, an operator-initiated change (since 1.66.0 including a
desk correction of the price or dates), and the desk-side no_show / completed (contract
§7.1). The rule is exact in both directions: revision moves if
and only if an event was emitted at that revision. A reinstatement (a terminal moved back to
confirmed) changes the booking without an event and without a bump — §8's poll is how you see it,
and why that poll is required. Do not use revision as your only change detector.
Send the revision you last saw as expected_revision on every modify and cancel. If the booking
has moved since, you get 409 revision_conflict and details[] carry the current booking — re-read,
re-decide, resubmit; nothing was written. A booking the desk has already closed is protected by the
lifecycle rules instead: against a no_show, completed or cancelled booking a modify fails
409 invalid_transition whatever you send, and a repeat cancel is a 200 no-op. expected_revision
is checked only when there is something to overwrite: a modify that resolves to no change and a
cancel of an already-cancelled booking both return 200 without comparing it — do not write a test
that asserts "stale revision ⇒ 409" against a no-op.
6.2 Modify
PATCH /v1/bookings/{ref} — or POST /v1/bookings/{ref}/modify, identical behaviour. {ref} is
your external_ref by default; add ?ref_type=booking_id to address by booking_id. Send only
the fields that change, plus expected_revision:
{ "expected_revision": 1, "dropoff": { "location_id": "loc_her_apt", "at": "2030-08-19T10:00:00+03:00" },
"expected_price": { "grand_total": { "amount": 30800, "currency": "EUR" } } }
- You may change
pickup,dropoff,vehicle_id,protection_id,extras,drivers,customer,flight_number,notes,metaanddistribution_channel(§1.7;nullremoves it). Every other member is422:external_ref,currencyandquote_idare immutable, andplan,cross_borderandpayment_modelare fixed at the sale. - A patch carrying only
expected_revision(and/orexpected_price) is422; a patch that resolves to no actual change is a200no-op — revision unchanged, no event. - A change to a pricing-relevant field re-prices and requires
expected_priceon the same terms as a direct create (§5.2); a mismatch is409 price_changedwith the new breakdown. A metadata-only change does not re-price. - A real change bumps
revisionby one and emitsbooking.modified. If the new selection is not available, the modify fails409 availability_changedand the booking is left as it was.
6.3 Cancel
POST /v1/bookings/{ref}/cancel. The body is required and carries expected_revision; reason
and distribution_channel (§1.7, an assertion) are optional:
{ "expected_revision": 2, "reason": "customer_request" }
A first cancel returns 200 with the cancelled snapshot, bumps revision and emits
booking.cancelled; a second cancel returns 200 with the same snapshot, no bump. Cancelling a
no_show or completed booking is 409 invalid_transition. The snapshot carries a cancellation
object with fee, refund, cancelled_at and your reason:
feeis computed and can be non-zero: the charge produced by the cancellation rule frozen onto the booking when it was created, evaluated against the notice you actually gave — under the same rule an early cancellation can be free and a late one charged. The basis is whichever ofgrand_total,pay_noworbase_ratethe operator'sfee_basisnames (contract §2.6), so read thefeewe return rather than deriving it.refundis always zero, and that is a different statement: nothing is collected through this API, so there is nothing to give back.feeis what is owed; settle it through the billing relationship you already have with the operator.
6.4 Read one booking
GET /v1/bookings/{ref} returns the booking snapshot, by external_ref by default or by
booking_id with ?ref_type=booking_id. Booking content (the priced breakdown, parties, stops,
policies) is the record written by your last /v1 write, frozen per revision; lifecycle state
(status, updated_at) is always served live. Detection belongs to the list: this endpoint
tells the truth about one booking, not which of your bookings changed — that is §8.
⚠️ Do not read your cancellation terms off a booking. A booking's policies.cancellation
carries a type and nothing further — or an empty object where the operator has stated no
default — and it never shows negotiated terms of your own even when those govern. The rule itself is
on GET /v1/reference/policies and availability's meta.policies (§3.2), and the rule frozen onto
the booking is cancellation_policy (contract §5.1).
7. Receive webhooks
Webhooks push to you every change that bumps revision (§6.1): your own /v1 writes
(booking.confirmed, booking.modified, booking.cancelled), a cancellation entered at the
operator's desk (booking.cancelled, identical on the wire), an operator-initiated change
(booking.modified carrying change — since 1.66.0 also a desk correction of the price or
dates, reason: "operator_adjustment"), and the desk-side terminals booking.no_show /
booking.completed. A desk reinstatement emits nothing — §8's poll is how you learn of it.
Build and prove your verifier first, offline, against the static fixture that ships with this
pack (fixtures/webhook-hmac-self-test.json, recipe in WEBHOOKS.md §5). For the
end-to-end proof, two things must hold: capabilities.webhooks.enabled reads true for the
environment you point at (deployment-wide, not per-account), and your endpoint is registered and
armed with us — Caravel tells you directly when delivery is armed for you; until
then a quiet endpoint is configuration, not a defect. Every delivery names its supplier in
supplier_id (contract §7.2), and a registration you make yourself applies to every supplier your
key reaches unless you narrow it with X-Supplier-Id (WEBHOOKS.md §6). Then
create a booking on your own account and
its booking.confirmed arrives, signed as WEBHOOKS.md §2 describes.
On a self-serve sandbox account nothing is armed, and nothing is sent to your URL. Your booking
events are captured instead, as the exact signed requests a receiver would have seen, in the
inspector on your account page, and they are listed in your delivery log. So the end-to-end proof
there is: send a test event (it creates your signing secret if you have none), create a booking, and verify the
booking.confirmed capture with your receiver code. WEBHOOKS.md §7 says when
your stream starts and what a capture cannot exercise.
Events are POSTed to your HTTPS endpoint as a signed JSON envelope:
{
"event_id": "…",
"type": "booking.cancelled",
"occurred_at": "2026-07-10T12:30:00.000Z",
"api_version": "1.66.0",
"operator_id": "…",
"booking_id": "…",
"external_ref": "PARTNER-12345",
"revision": 3,
"sequence": 4187,
"data": { "…": "the full booking snapshot at this revision" }
}
booking_id, external_ref, revision and sequence are at the top level, so routing, dedupe,
ordering and gap detection never require parsing data. data is the same shape
GET /v1/bookings/{ref} returns. Generate the receiver type from WebhookEvent in the OpenAPI
document (its webhooks section) rather than hand-writing it.
The rules that matter:
- Verify the signature before you trust anything.
X-Caravel-Signaturecarries a timestamp and one or more HMAC-SHA256 values (several during secret rotation — accept if any verifies). The byte construction, replay window, rotation and reference verifiers are inWEBHOOKS.md. - Dedupe on
event_id. Delivery is at-least-once. - Order on
revision, per booking. Apply an event only if itsrevisionis higher than the last you applied for that booking; a lower one is superseded — discard it. - Track
sequenceacross the stream, and treat the member as optional. A number that never arrives is a delivery that did not reach you — your cue to reconcile (§8). An event without asequenceis not a gap: skip the consecutiveness check for it.WEBHOOKS.md§3b is normative for what a hole means and how long to wait before alarming. - Tolerate unknown
typevalues. Every member ofcapabilities.webhooks.event_typesis produced, and the list grows additively — skip a type you do not recognise. - Respond 2xx quickly and process asynchronously. A slow receiver becomes a retried receiver.
🚨 Scope your de-duplication. (booking_id, revision) is the right key for webhook-versus-poll
overlap on an emitted change. A reinstatement mints no event and no new revision, so polled
state keyed on revision alone will discard the very case the poll exists for — include status
(and updated_at) in your idempotency key for polled rows.
A /v1 write and its event commit in one transaction — no event for an uncommitted change, no
committed write without its event — and the same holds for a desk-side cancellation. The converse
does not hold for the whole booking (a reinstatement commits with no event), which is why §8 is a
required part of the integration.
WEBHOOKS.md is normative for delivery behaviour (retry schedule and budget, dead-letter and
requeue, sequence gap detection) and for the signature scheme. Take those numbers from there.
8. Reconcile
Run a scheduled poll. It is not only a safety net: it is the sole way you learn of a desk-side reinstatement, and your only source of state while delivery is not yet armed for you. It also catches anything a delayed or missed delivery would otherwise lose.
GET /v1/bookings?updated_since=2026-07-28T00:00:00Z&limit=200
Scope read:bookings or write:bookings (§1.3). Returns your bookings as summaries — booking_id,
external_ref, status, revision, updated_at, grand_total — narrowed by updated_since,
with keyset pagination through meta.next_page. Optional status filter.
- Call with your stored
updated_since(start frommeta.as_ofof the last successful run). - Follow
meta.next_pageto exhaustion. - Compare each row's
statuswith the one you hold, and apply a difference from the list row itself. Do not gate this onrevision: a reinstatement leavesrevisionuntouched. - For any booking whose
revisionis higher than the one you hold, fetchGET /v1/bookings/{ref}for the full snapshot — the path for content changes. - Store this run's
meta.as_ofas the nextupdated_since.
Use the meta.as_of we return as your next watermark — never your own clock, and never an
updated_at read from one booking. updated_at is stamped when the writing transaction
started, not when it committed, so a scan against your own clock can skip a write forever while
looking healthy; meta.as_of trails the server clock by a deliberate lap, so a write committing
during your walk reappears as a harmless duplicate. So expect duplicates and make applying a row
idempotent: key on booking_id, apply only what moves you forward. Nightly is a sensible cadence;
hourly costs little.
The list is your change feed; the point read is one booking's full record. Both serve live status
and updated_at, so they never disagree about lifecycle state.
9. Settlements
GET /v1/settlements, scope read:settlements: what the operator owes you — commission expected,
received and outstanding, per settlement period.
GET /v1/settlements?status=open&limit=50
Parameters: status (open, partial, settled, disputed), limit (1–200, default 50),
page_token and updated_since (the paging every list uses, §2.1); any other parameter is 422.
A key issued through the sandbox portal carries no read:settlements scope, so this section cannot
be exercised with one; CERTIFICATION.md says how commission is reconciled
before go-live. A key sees its own settlements only — one statement per supplier, each
stamped with its supplier_id, never aggregated across suppliers.
| Field | Meaning |
|---|---|
settlement_id |
Identifier for the period. |
period_start, period_end |
The window, half-open: period_end is exclusive. |
status |
open, partial, settled or disputed (the operator has placed a hold on the period's economics). Not a closed list — treat an unrecognised value as unknown. |
amount_expected |
Commission owed to you for the period, before adjustments. |
amount_received |
How much has been remitted so far. |
amount_adjusted |
Net signed corrections in the settlement currency; negative is a clawback. |
amount_outstanding |
expected + adjusted − received. |
bookings_allocated |
How many bookings contributed commission to the period. |
Amounts are money objects in the period's currency. Commission rates and the operator's own net
are never returned. GET /v1/settlements/{settlement_id} adds allocations[] (the commission earned
on one booking, each carrying your external_ref — the one to join on, unique across your
suppliers by §5.3's rule — and the operator's
opaque booking_ref, which is not a join key), adjustments[] (signed corrections, each in its own
currency; one that differs from the period's is excluded from amount_adjusted), and
allocations_truncated / adjustments_truncated — each array is capped at 1000 entries and says so;
bookings_allocated stays the authoritative count. Either reference may be null where the booking
has since been anonymised under retention rules — keep them nullable in a generated client. A
settlement that is not yours answers 404, identically to one that does not exist.
10. Errors
10.1 The shape
Every /v1 error is one envelope — different from the success envelope, deliberately:
{
"error": {
"code": "price_changed",
"message": "Computed price differs from expected_price — re-consent the customer and resubmit.",
"request_id": "req_…",
"doc_url": "https://api.caravelway.com/docs/errors#price_changed",
"details": [ { "field": "expected_price.grand_total", "issue": "price_changed", "expected": { "…": "…" }, "computed": { "…": "…" }, "breakdown": { "…": "…" } } ]
}
}
Branch on error.code, never on the HTTP status alone — 409 alone covers more than a dozen
conditions, each with its own remedy. details[] is present where field-level or recovery
information exists: a 422 names every failing field and its issue; a 409 price_changed carries
the new breakdown; a 409 revision_conflict carries the current booking. The full catalogue is
ERRORS.md in this pack and the live page every doc_url resolves to; the
details[].issue vocabulary is indexed in the contract's §9.
10.2 The ones you will meet
| Status | Code | What it means | Retry? |
|---|---|---|---|
| 401 | unauthenticated |
Missing, invalid, or wrong-environment X-API-Key — never load: a busy service answers 503. |
No. |
| 403 | forbidden |
Valid key, missing scope. | No — request the scope. |
| 409 | booking_conflict |
external_ref already used with a different intent. |
No — modify, or a new ref. |
| 409 | price_changed |
Computed price ≠ expected_price; details carry the new breakdown. |
No — re-consent, resubmit. |
| 409 | quote_expired / quote_consumed |
The quote_id has lapsed, or already produced a booking. |
No — re-quote. |
| 409 | availability_changed |
Inventory or catalogue moved between quote and book. Nothing was booked. | No — re-search or re-quote. |
| 409 | revision_conflict |
expected_revision is stale; details carry the current booking. |
No — re-read, re-decide. |
| 409 | invalid_transition |
The booking's state does not allow this operation. | No — re-read the booking. |
| 409 | idempotency_in_progress |
The original request with this key is still executing — or the supplier is moving your relationship with it into your account, which holds its writes until the move completes. | Yes — after Retry-After, with backoff while it persists. |
| 422 | validation_error |
Field or parameter validation failed; details[] names each one. |
No — fix the fields. |
| 422 | idempotency_key_reused |
Same Idempotency-Key, different body. |
No — fresh key. |
| 422 | no_availability / driver_age_out_of_range / not_sellable |
The selection cannot be priced or sold for this request. | No — change the selection. |
| 422 | distribution_channel_not_approved |
The channel you named is not approved for you today; details[].issue says unregistered, not_approved or not_in_effect. Nothing was priced or written (§1.7). |
No — name an approved channel. |
| 429 | rate_limited |
Too many requests, or a cost budget exhausted. | Yes — honour Retry-After. |
| 500 | internal_error |
A fault on our side, and we are alerted. | Yes — backoff with jitter. |
| 503 | service_unavailable |
Overload or maintenance: at capacity, a database failover, a statement past its timeout. Never a verdict on the request or the key. | Yes — after Retry-After. |
Retry only 429, 5xx, 409 idempotency_in_progress and network errors; never any other 4xx.
When the response carries Retry-After, wait at least that many seconds; otherwise back off
exponentially with full jitter — before retry n (from 0), a random wait up to 0.5 s × 2ⁿ, capped at
8 s. On a network error where you never saw a response, retry with the same Idempotency-Key —
that is what it is for. walkthrough.mjs in this pack has the waiting rule in code (retryDelayMs),
and its settleWrite replays a write under its own key until the API states an outcome.
10.2b Two refusals that are not codes of their own
An operator can declare, per desk, that it performs no out-of-hours handovers or no
deliveries there, and the booking path enforces it. Both arrive as a details[].issue —
out_of_hours_not_offered and delivery_not_offered — under a code you already handle: on
POST /v1/quote, a direct create and a PATCH as 422 validation_error naming the end you can
change; on a quoted create as 409 availability_changed on quote_id (the desk's answer moved after
your quote — re-quote). Branch on details[].issue and one handler covers both arrivals; branch
on the code alone and the 409 reads as ordinary inventory drift.
Pre-filter, judged in the location's own published timezone: GET /v1/reference/locations
publishes out_of_hours, delivery and hours per location. Do not place an end at a location
publishing delivery: { "available": false }, and at one publishing out_of_hours: { "available": false } keep both handovers inside its hours. That avoids most refusals, not all (a location with
hours: null publishes no window). Whether search screens these ends out is a per-account opt-in,
off by default (§3.2) — ask us to enable end-terms screening before you go live; until then the
pre-filter is the only thing between you and selling a window we will later refuse. Contract §2.1
and §4.1 are authoritative.
10.3 Rate limits
Every authenticated response carries X-RateLimit-Limit (burst capacity), X-RateLimit-Remaining
(tokens left) and X-RateLimit-Reset — an absolute Unix timestamp in seconds, not a duration:
compute reset − now before sleeping. Limits are per account, whatever the number of suppliers your
key reaches. They are set in your agreement and onboarding — a sustained rate in requests per second
and a burst capacity (the number X-RateLimit-Limit reports) — and this guide publishes no figures:
they belong to your account, not to the API. Alongside the bucket, your account
carries hourly and daily cost budgets charged per request by weight (contract §1.11), so a
429 rate_limited can arrive while X-RateLimit-Remaining is non-zero; error.message names which
budget tripped. Either way, honour Retry-After, and pace down as Remaining falls rather than
waiting for the first 429.
11. Versioning and forward compatibility
Read this before you generate a client. It is the part of the contract that protects you.
A TypeScript client template ships in this pack under sdk/ — openapi-typescript over the
channel-api-v1.openapi.json beside it, with a 38-line factory that binds one origin and one key.
Its README.md is three commands. Other languages: generate from the same document.
Everything under /v1 is additive-only. New optional response fields, new endpoints and new enum
members may appear at any time; breaking changes ship as /v2. That promise only holds if your
client absorbs additions. Two rules:
1. Responses are lenient. Ignore what you do not recognise. The published response schemas
deliberately do not set additionalProperties: false — do not re-add it in a generated client, and
do not point a strict validator at a response. Open response enums are published as
x-extensible-enum, so a conformant generator emits an open string; treat an unrecognised value as
unknown and degrade gracefully. error.code has grown several times and will grow again.
capabilities.forward_compatibility.extensible_enums names the wire paths known to grow. There is
no exception: the booking status is open too (since 1.64.0), and §5.5 says what an unknown status
means.
2. Requests are strict. A typo fails on your first call. Request bodies and query parameters
are validated closed: an unknown field or parameter is 422, never silently ignored. Certification
exercises both directions.
Deprecation. Nothing in /v1 is removed without a minimum of 180 days' notice, announced in
three places at once: capabilities.deprecations[], Deprecation and Sunset response headers on
the affected route, and this documentation. X-API-Version: 1 is accepted for older tooling; if you
send it, send 1 or the exact version your account is pinned to (GET /v1/whoami) — anything else
is 409 version_mismatch. It asserts the version you expect; it never selects one.
12. Before you go live: certification
Certification is a recorded run against your sandbox key in which we serve your real client shapes
it has not seen before — a new optional field, an unfamiliar enum member, a re-minted identifier —
and observe what it does. It is your own client, exercised; not a form. What the run covers, every
bar you are judged on, and what a pass means: CERTIFICATION.md.
13. Support and incidents
Two mailboxes, both shared (read by more than one person — not a pager, and nothing here routes to a phone):
| Mailbox | For |
|---|---|
[email protected] |
The integration: keys and scopes, sandbox access, booking a certification run, anything about this contract or the pack. |
[email protected] |
Once you are live: a particular booking, customer or rental. |
If a message could belong to either, send it to both.
What is not published, said plainly. No response time — read that as written: the absence is
not a commitment to answer quickly and not a commitment to answer slowly, and neither is implied by
anything else in this pack. No severity or escalation scheme: two mailboxes, and no ladder above
them. No service-level agreement, and no availability figure of any kind — no target, and no
historical percentage on the status page; service commitments are a separate document and are not
made in this pack. No coverage window in this pack either: the hours during which someone is reading
belong on the status page, and a window describes staffing, not speed — it says when someone is
reading, and no response time is published. The status page and the incident channel are being
stood up; until you have their addresses from [email protected] you do not have them.
Ask for both while you integrate and keep them where whoever is on call can reach them. If your
integration needs any of the above stated, raise it before you go live rather than during an
incident.
What to put in a message: the environment (§1.1), the request_id of one representative failure
(and roughly how many looked the same), the error.code and the time with its UTC offset, and for a
booking its reference or your external_ref.
If the API is not answering, in order:
GET /v1/capabilitieson the base URL you use. No key needed; a200proves that host is up and answering on/v1— not that every route you need is mounted (that is itsGET /v1/openapi.json) and not that a search or booking will succeed.GET /healthon the same base URL. No key; returnsok,api_version,exposedand nothing else.ok: false(served503) means the host that answered cannot reach its own store, or is being taken out of service, and a retry reaches one that is not;exposed: falsemeans its/v1surface is switched off and the authenticated routes answer404whatever key you send. It is what the process measures about itself now; the status page is what a person has recorded, and the two can legitimately disagree in both directions.- The status page, if you have its address — a component reads healthy until a person marks it otherwise.
Everything else about waiting is already specified so that an outage costs you delay, not data:
retry 429 and 5xx with backoff (§10.2), a timed-out write with the same Idempotency-Key
(§5.4); webhook delivery retries for days and a dead-lettered event can be requeued with the same
event_id and sequence (WEBHOOKS.md §1b, §3b); and the reconcile poll (§8) re-reads everything
that moved while you were not receiving. If it persists, write — [email protected] if live,
[email protected] if integrating — quoting a request_id and the times.
Where to go next
| Document | What is in it |
|---|---|
CHANNEL-API.md |
The normative contract — every endpoint, every field, every error. |
WEBHOOKS.md |
The receiver guide, normative for delivery behaviour and the signature scheme. |
ERRORS.md |
Every error code, its meaning and whether to retry — readable before you hold a key. |
CERTIFICATION.md |
What certification involves, every bar, and what a pass means. |
SECURITY-AND-DATA-HANDLING.md |
What we do and do not have on the security and data side. |
sdk/ |
A TypeScript client template — generate from channel-api-v1.openapi.json beside it. |
GET /v1/openapi.json |
The machine-readable contract as served by the environment you point at. Generate your client from the channel-api-v1.openapi.json shipped with this pack. |
channel-api-v1.postman_collection.json |
Every endpoint as a runnable request, generated from the OpenAPI document — ordered as a flow (search → quote → book → read → modify → cancel), each request capturing the ids the next ones need. Set base_url and api_key and press Run. |
GET /v1/capabilities |
Live limits, TTLs, signing scheme, deprecations. |
Questions during integration: [email protected]. Once you are live,
[email protected]. §13 is the full split and what to do when the API is not answering.
Webhook Receiver Guide
Status: Normative for delivery behaviour and for the signature scheme; explanatory elsewhere. The
split is stated precisely in the next paragraph — read it before treating any number here as binding.
Audience: Channel partners building a /v1 webhook receiver.
How to receive, verify, and reconcile Caravel Channel API webhook events.
CHANNEL-API.md §7 is normative for the envelope and the event
types. This guide is normative for delivery behaviour — retry schedule and budget, dead-letter and
requeue, sequence and gap detection — and for the signature scheme: the full verification recipe,
the replay window, secret rotation, with reference implementations and a static self-test fixture
(§2 and §5).
Caravel pushes /v1 booking changes to the HTTPS endpoint you register with us, signed with
your own webhook secret. GET /v1/capabilities publishes the same signature scheme described here —
its tag, header name, algorithm and signed-payload template — plus the event-type enum, so your
client can feature-detect rather than hardcode.
Read two flags in GET /v1/capabilities before you build against this guide.
webhooks.enabled— whether the environment you point at is delivering at all. Read it, never assume it: the two environments can legitimately differ — sandbox is typically armed first, so you can prove your receiver end to end before go-live, while production arming is a go-live step. It is a deployment-wide value, not a per-account one, so do not poll it waiting for your own turn — Caravel tells you directly when delivery is armed for you.webhooks.event_types— thetypeenum the envelope accepts. Since 1.21.0 every member of it is produced (§1):booking.no_showandbooking.completedare sent when the operator's desk marks your booking a no-show or completed (CHANNEL-API.md§7.1). Treat the enum as open regardless (§1) — a type you do not recognise is skipped, never rejected.
Everything below describes how delivery behaves once it is on for you, and it is worth building and
self-certifying against before it is. What is pushed: every change that bumps a booking's
revision — your own /v1 writes; an operator-initiated change (booking.modified carrying
change: a shared-fleet displacement changes the class the booking is allocated to while the
identifiers you sold — vehicle_id, acriss, display_name — stay frozen, and since 1.66.0 a
desk correction of the price or dates carries reason: "operator_adjustment"; see
CHANNEL-API.md §5 and §5.1); a desk cancellation (booking.cancelled,
identical on the wire to a /v1 one); and the desk-side terminals booking.no_show /
booking.completed. What is not: a desk edit that changes nothing you were sold (a note, a car swapped within the
class), and a desk-side reinstatement (a no_show or completed moved
back to confirmed) — it emits nothing and does not bump revision, before or after arming; poll
for it (CHANNEL-API.md §5.2 and §5.5, §7.1 for the authoritative list). Polling
is also your only source of state while delivery is not yet armed for you.
Build and self-certify your verifier against the static fixture in §5 before your endpoint goes live: it proves your signing and verification code end to end with no traffic involved.
1. What you receive
One event per POST to your registered HTTPS endpoint — deliveries are never batched:
{
"event_id": "0c9d5a71-6f3e-4a2b-9c81-7d2e4f60ab13",
"type": "booking.modified",
"occurred_at": "2026-07-10T12:30:00.000Z",
"api_version": "1.66.0",
"operator_id": "5b8e2f14-3c07-4d6a-8e9b-1f2a3c4d5e6f",
"booking_id": "9c1a4f22-0d5e-4b83-a7f1-2e6c8d40b915",
"external_ref": "PARTNER-12345",
"revision": 2,
"sequence": 4187,
"data": { "…": "the full booking snapshot at this revision, same shape as GET /v1/bookings/{ref}" }
}
Generate your receiver type rather than hand-writing it. The envelope above is published as a
machine-readable schema: WebhookEvent, in the channel-api-v1.openapi.json shipped with this pack
(and served live at GET /v1/openapi.json). It is referenced from the document's webhooks section
— OpenAPI 3.1's section for payloads we send you — so a generator emits a receiver model from it
exactly as it emits your client from the request and response schemas. It is the same schema the
type enum in capabilities.webhooks.event_types is published from, so the two cannot disagree.
Two things that schema deliberately does not do, both of them so it stays true rather than
convenient. It does not close the envelope to members we may add later, and it does not close the
type enum — building a receiver that rejects either is how a valid delivery becomes a retry and
then a dead letter. And data is left structurally open: an event payload is the booking snapshot
frozen at that revision, so a constraint published on it would be a claim about every snapshot ever
stored, not about the current shape. Model data as the Booking schema from the same document, and
treat an older event's payload as state to apply rather than to re-validate.
occurred_at is the instant the change committed — never the time of the delivery attempt, which
moves on every retry. On the envelope it is always rendered in UTC with millisecond precision
(...T12:30:00.000Z); parse it as an instant, and see
CHANNEL-API.md §1 for the timestamp rule that holds across the
whole wire, where responses carry an offset rather than Z.
api_version is the version of the surface that produced the payload — the same value
GET /v1/capabilities reports. Treat it as an opaque version string: do not parse it as
major.minor.patch, and do not compare it numerically. Its exact shape rule is stated once, in
CHANNEL-API.md §1.2.
The events we send
type |
Sent when |
|---|---|
booking.confirmed |
a booking is created through /v1 — always revision 1 |
webhook.test |
you asked for it (POST /v1/webhooks/test, §7): a signed delivery over a synthetic booking — external_ref WEBHOOK-TEST-…, data.test: true. Never a real booking; nothing to fulfil |
booking.modified |
a /v1 modify changes something (a patch that resolves to no change is a no-op: no event, and revision is not bumped), or the operator changed the booking — a displacement, a stated reason, or (since 1.66.0) a desk correction of the price or dates; the payload then carries change (CHANNEL-API.md §5.1). One desk save sends at most one, before any terminal event from the same save |
booking.cancelled |
a booking is cancelled — through /v1, or at the operator's desk. Both forms are identical on the wire and both bump revision by 1 as the event's pair; a desk-entered one carries no cancellation.reason |
booking.no_show |
since 1.21.0 — the operator's desk marked the booking a no-show; the payload's status is no_show |
booking.completed |
since 1.21.0 — the operator's desk marked the rental completed; the payload's status is completed |
Two further types are published and never sent: supplier.added and supplier.paused (1.36.0).
They are in the enum so a client you generate today already tolerates them, and nothing in this build
mints either: a supplier joins or leaves your account through provisioning, which announces nothing
on this stream yet. When they arrive, supplier.added will mean a supplier became sellable on your account and supplier.paused
that one stopped selling; the members they carry ship with them, so branch on type before reading
any booking member of the envelope.
GET /v1/capabilities publishes the type enum the envelope accepts. Treat an unrecognised type
as unknown and degrade gracefully (skip it) rather than crashing — new types are additive within
/v1, and two members of that enum are reserved rather than emitted (above).
The same goes for the booking's status inside data (since 1.64.0). It is an open list, and
a later release may add a state there too. Store a delivery whose data.status you do not recognise,
acknowledge it, and treat the booking as neither confirmed nor finished. Do not show it to your
customer as confirmed, and do not cancel or release it. Then read it again until it carries a value
you know (CHANNEL-API.md §8.1). Never answer such a delivery with an error status: we would retry it
and you would read it again unchanged.
A new state is delivered only to an account pinned at 1.64.0 or later. A delivery to an account pinned
at 1.63.0 or earlier carries, in data.status, the existing state that the announcement names as the
equivalent. This is the same value GET /v1/bookings/{ref} serves that account. The pin is read when
each delivery is attempted, so a retry after you move your pin carries the value for the new pin.
Request headers
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Caravel-Signature |
t=<unix-seconds>,v1=<hex>[,v1=<hex>...] — see §2 |
X-Caravel-Event-Id |
the same event_id as the body |
X-Caravel-Event-Type |
the same type as the body |
X-Caravel-Delivery-Attempt |
which attempt this is, counting from 1 |
User-Agent |
identifies Caravel as the sender (Caravel-Webhooks/1.0) |
The last four are conveniences for your logs and your routing. Only the timestamp, the method, the request-target and the body are covered by the signature — no header is. Once the signature verifies, read every value you act on out of the body.
1b. How a delivery behaves
What your endpoint must return. Any 2xx acknowledges the delivery; we read the status line and
discard the response body, so an empty 200 is ideal. Anything else is a failure and will be
retried — including a 3xx, since we never follow redirects. Acknowledge as soon as you have
durably recorded the event and do the rest of your processing afterwards.
Timeout. Each attempt is given 10 seconds. A response that trickles without finishing is cut off at the same bound, so answer inside the window rather than holding the connection open while you work.
Endpoint requirements. The callback must be an https:// URL whose certificate validates against
its hostname, resolving to a public address — private, loopback, link-local and other reserved
ranges are refused — with no credentials embedded in the URL. Its path and query string, if it has
one, are part of the signed payload (§2).
Retries. Delivery is at-least-once. A failed attempt is scheduled with exponential backoff: about
30 seconds, then 1, 2, 4, 8, 16 and 32 minutes, then hourly, each wait carrying ±15% jitter so a
backlog does not arrive in lockstep. By default, retrying continues for roughly 3.7 days (96
attempts) before we give up on that event. Every attempt carries the same event_id and the same
sequence; only the signature and its t are fresh — which is why you dedupe on event_id and
never on the signature.
If you need us to slow down, say so with Retry-After. On a 429 or a 503 we read your
Retry-After (delta-seconds or an HTTP-date) and use it as that attempt's wait when it is longer
than the backoff step the attempt would have had anyway, capped at your account's backoff ceiling
(the hourly cap above unless we agreed otherwise). So Retry-After: 120 on the first failure makes
the next attempt due in ~2 minutes instead of ~30 seconds; Retry-After: 86400 gets the hourly
cadence, not a day; Retry-After: 5 changes nothing. The wait is when the attempt becomes DUE, subject
to the same once-a-minute rounding as every other wait above. On any other status the header is
ignored and the schedule above applies. A throttled attempt still counts against the attempt budget,
exactly like any other failure.
⚠️ That schedule is when an attempt becomes DUE, not when it is sent — the sender wakes once a minute. A due attempt goes out on the first pass after it falls due, so every wait is effectively rounded up to the next whole minute, and the first retry reaches you about a minute after the failure rather than 30 seconds after it. With the jitter on the 30-second wait, the earliest a first retry can arrive is ~25 seconds and the latest ~95, so size a "have they retried yet?" alarm above 95 seconds — not on the 30 in the schedule above, and not on a bare 60 either. The same rounding applies to every later wait; it only ever makes the 3.7-day budget longer, never shorter, so the give-up figure is a floor.
⚠️ And a catch-up drains at a bounded RATE. Each pass claims at most 25 deliveries for your account with one supplier (and at most 100 per supplier across all of their partners), and up to 8 of those are in flight to your endpoint at once (see Ordering on the wire below). So the ceiling on one relationship is about 25 events a minute, ~1,500 an hour — a replay of a wide window arrives as a steady stream over minutes rather than as one burst. If you take our events from several suppliers, each relationship has its own 25, and they add.
Three things can hold the real rate BELOW that ceiling, and a catch-up plan should assume them. (1) Only what is DUE is claimed. Past the first few attempts the backoff sits at its hourly cap, so a long-standing backlog offers roughly one attempt per event per hour, not 25 a minute — the ceiling binds a burst of fresh or replayed events, not an aged one. (2) One booking at a time. A delivery is skipped while an earlier revision of the same booking is unfinished (Ordering on the wire), so filling a pass takes 25 distinct bookings, not 25 events. (3) The breaker. While you are failing broadly we send a single trial delivery per cool-down before letting the rest through, so a recovery starts at one event a minute and opens up, rather than at 25.
That budget is a default, not a platform constant. Both halves of it — the number of attempts and
the ceiling the backoff climbs to — are configurable per account, exactly as the two rate limits
are (CHANNEL-API.md §1.11). Yours is agreed with us at
onboarding, and the figures above are what you get unless we agreed otherwise. So take the number
from your onboarding terms rather than from this paragraph before you size an alert on it, and if you
have not been told a different one, 3.7 days is yours. Everything below that reasons about the budget
— when a sequence hole stops being provisional (§3b), how long a dead-letter takes to declare
(above) — moves with your figure, not with this one.
If we give up. An event that exhausts its retries is dead-lettered and alerts us. It can be
requeued, which re-sends the same event_id and the same sequence with a full fresh retry budget
— that is how a hole in sequence can be filled days late. A dead event is not requeued when a
later revision of the same booking has already been delivered: handing you a stale state after a
newer one is worse than the gap, so that hole is permanent and you resolve it by polling the
booking (§4).
If your endpoint is failing broadly. When five or more of your bookings have deliveries failing within a few minutes, we stop sending to you for a short cool-down (about a minute) and then resume with a single trial delivery before letting the rest through. Your backlog is preserved and drains in order once you recover — nothing is discarded for being backed up.
Ordering on the wire. Deliveries for one booking are strictly sequential: revision N is delivered
(or given up on) before revision N+1 is attempted. Different bookings are delivered concurrently —
up to 8 requests to your endpoint may be in flight at once — so your handler must be safe to run in
parallel across bookings. Retries and dead-letters can still leave you holding events for one
booking out of order, so apply the revision rule in §3 regardless.
When your stream starts. Only changes that occur at or after your endpoint is switched on are
delivered; nothing is back-filled from before that instant. Sync your current state once with
GET /v1/bookings?updated_since=... at cutover and apply events from there. The same holds for any
window in which delivery to you is paused: those events are not sent when it resumes, and they leave
no hole in sequence (§3b) — a paused window shows up as a jump in revision, which you recover
exactly like any other revision gap (§4).
2. Verify the signature before you trust anything
Every delivery carries:
X-Caravel-Signature: t=<unix-seconds>,v1=<hex>[,v1=<hex>...]
hex= lowercaseHMAC-SHA256(your_webhook_secret, signed_payload)signed_payload = "{t}.{method}.{path}.{body}", literally concatenated with.:t— the same integer from the header, as a decimal stringmethod—POST, uppercasepath— your own registered callback URL's path + query string, byte-for-byte, never re-encoded or normalised. This is the request-target exactly as it arrives on the request line, so take it from the request rather than rebuilding it from your router's configbody— the exact bytes of the request body, never re-serialized (don't parse-then- re-stringify JSON before hashing — whitespace/key-order differences would break the match)
Multiple v1= params can appear during key rotation — Caravel signs with every currently
active secret. Accept if any verifies; never assume exactly one is present.
An unrecognised scheme tag (e.g. a future v2=) must be ignored, not rejected — a
well-formed param under a tag you don't implement yet must not fail the whole header. A malformed
t= is a hard reject. A v1= value that is not 64 lowercase hex characters can never verify —
treat it as just another non-matching candidate, so the header stands or falls on whether any
v1= verifies (the rotation rule above). That is exactly what the reference implementations
below do.
Replay window: reject if t is more than 300 seconds from your current time (either
direction). Use the t embedded in the signed payload — never a separately transmitted
timestamp, which an attacker could manipulate independently of the signature.
3. Dedupe and ordering
- Dedupe on
event_id, never on the signature (every delivery attempt is re-signed with a fresht, so retries of the same logical event have different signatures but the sameevent_id). - Order by
revision, not arrival time oroccurred_at. At-least-once delivery with retries means events can arrive out of order. Apply an event only if itsrevisionis greater than the highest you've already stored for thatbooking_id; discard (but still dedupe-record) an event whoserevisionyou've already seen or surpassed.
3b. sequence: how you detect that you missed a delivery
revision tells you the order of changes to one booking. sequence tells you whether you have
received everything we sent you. They answer different questions and you want both.
- 🚨
sequenceis OPTIONAL — an event may arrive without it, and that is not a gap. It is absent from therequiredset of theWebhookEventschema in thechannel-api-v1.openapi.jsonshipped with this pack — check it there rather than taking this paragraph's word for it — so a generated receiver types it as optional, and a strict consecutive-sequence check that treats a missingsequenceas a hole will false-alarm on every such delivery. Check consecutiveness across the events that carry one: skip an event that carries nosequence, do not advance your counter past it, and do not raise on it. Everything below describes how the field behaves when it is present. ⚠️ Since 1.41.0 it is no longer the ONLY optional member:supplier_idis optional too, absent on a delivery whose supplier label has not been synced yet. Neither is ever sent asnull— an absent value is an absent MEMBER. Every other member of the envelope is always present. sequenceis strictly consecutive per SUBSCRIPTION — one stream per operator whose events you receive, which is the same thing as one stream per SUPPLIER (1.36.0 names it that way because that is the unit you now hold an id for): the endpoint and signing secret belong to that relationship, and so does the counter — count per stream, never across two. It increments by exactly 1 for every event we number onto that stream, starting at 1 — and numbering and stamping are one act: an event that arrives withoutsequencenever consumed a number, so the numbered events around it remain consecutive. Skipping it, as the rule above says, can never hide a real hole.- 🚨 GROUP BY SUPPLIER BEFORE YOU CHECK CONSECUTIVENESS. If you receive events from more than
one supplier, each supplier is its own numbered stream, each starting at 1 — so the numbers you
receive will legitimately repeat and interleave across suppliers. Checking the arriving events as
ONE undifferentiated stream would show you duplicates and jumps that are not there. Group on
operator_id— it is on every envelope and it names the stream;supplier_id(1.41.0), when present, is that same stream's supplier and may be used instead, but it is an OPTIONAL member (§3b) and a receiver that buckets on it alone will mis-file a delivery that arrives without it and raise a false gap. So:operator_idalways,supplier_idas the label you store beside it. A delivery that arrives withoutsupplier_idis filed by itsoperator_idlike any other — never rejected, and never counted as a gap: its number was consumed on that stream exactly as if the label had been present, so the numbers around it stay consecutive. This is a one-line change if you store either, and it is correct whether you gave us one callback URL for every supplier or a separate one for each: with separate URLs each endpoint receives exactly one supplier, so the grouping is simply a no-op. ⚠️ THIS INSTRUCTION IS THE REVERSE OF WHAT THIS DOCUMENT SAID BEFORE 2026-09-01, and it is called out rather than quietly edited because a receiver built against the old wording will raise false alarms. The old text read "It is NOT scoped per operator … Do not group byoperator_idbefore checking consecutiveness." That followed from an assumption that no longer holds — that a partner publishes ONE callback and ONE signing secret for every operator it serves, and therefore observes one stream. Delivery configuration is now per relationship, so you may hold a different URL and a different secret per operator. No integration is affected in practice: no receiver had been sent asequencebefore this change. If you have already written gap-detection code against the old wording, this is the one line to change before delivery is armed for you. - A retry carries the same
sequence. Delivery is at-least-once, so the sameevent_idcan arrive more than once; each copy has the number it was originally allocated. Only the signature'stdiffers between attempts. - Numbers can arrive out of order. Retries and per-booking ordering mean 41 may land after 42. Track the set of numbers you've seen (or a high-water mark plus the holes below it), not just the last one.
A number that never arrives means a delivery you were meant to receive did not reach you — we gave up after exhausting retries, or it was lost in flight. That is your cue to reconcile (§4). Two caveats before you alarm on one:
- A gap is provisional. Our retry budget runs to roughly 3.7 days by default — yours is the one agreed at onboarding (§1b) — and a given-up delivery can be requeued after that, which fills the hole late. Give a hole time before escalating, and time it against your own budget.
- A gap is NOT how a deliberately-skipped event looks. Events we did not send — those that fall
in a window before your stream started or while delivery to you was paused (§1b) — never consume a
number, so they leave the sequence intact and show up as a jump in
revisioninstead. A clean sequence with a revision jump means "we deliberately did not send an intermediate state"; a hole in the sequence means "something went missing". Handle both, but do not confuse them.
The recovery action for both is described next — but they do not recover the same way, and the difference follows from what each one is. A hole is a delivery we tried to make, so it can be re-driven on request (§4, §7). A revision jump is an event we deliberately never sent: it was never allocated and never consumed a number, so there is nothing to re-drive and the poll is the only answer for it.
4. Revision gaps: the poll-as-recovery contract
If you detect a gap — you're holding revision 3 for a booking and a revision: 5 event arrives
— do not guess what changed in revision 4. GET /v1/bookings/{ref} returns the booking's
current full snapshot, which is always safe to apply directly regardless of which revision
produced it (the snapshot and the latest event are the same JSON value once revisions match).
Use this exact pattern in four situations:
- A hole in
sequence(§3b) — a delivery that never reached you. You know an event is missing but not which booking it concerned, so reconcile with theupdated_sincelist in (2). - A gap in
revisionon any single booking (as above). - Startup / after downtime — before you trust your local state, pull
GET /v1/bookings?updated_since=<your last known-good instant>, followingmeta.next_pageto exhaustion, and apply every summary's current state. This is the same reconciliation list theINTEGRATION-GUIDE.mddescribes for scheduled polling, reused here as gap recovery. - A delivery you can't verify or can't process — never silently drop it; poll the booking to find out what actually happened rather than leaving your side stale.
This is why the polling reconciliation in INTEGRATION-GUIDE.md is not an
optional "nice-to-have" alongside webhooks — it is the designed recovery path for every gap class
above, not a redundant backup.
Where a replay upgrades this. For the two classes that are about a delivery rather than your
whole state — a hole in sequence (0) and a revision gap on one booking (1) — you can ask us to
re-drive the deliveries themselves with POST /v1/webhooks/redeliver (§7). They arrive on your
registered endpoint, signed, indistinguishable from any other retry, so your existing receiver needs
no new code path for them. Four things about a replay you cannot derive from its response:
- It is not "one delivery per booking". A replay re-drives every selected revision that is not
superseded by a currently-delivered later one — a single row for a fully-delivered booking, and
the whole chain for one whose deliveries died. It re-drives only rows that have come to REST,
though: a delivery still queued or mid-retry is left exactly as it is and counted
already_pending, because re-driving it would mean two live attempts at one event. - It resets the log row it replays.
attemptsandlast_status_codereturn to their initial state anddelivered_atis cleared until the replayed delivery succeeds. If you want the record of what failed, read the log (§7) before you replay. - It reaches back only as far as retention —
capabilities.webhooks.redelivery.window_days. A selector reaching past that is refused, naming the window, never silently shortened. - It covers what existed when we accepted it. A window running up to
now()may match deliveries that appear after we admit the request, and this call will not carry them. Re-run it rather than treating one call as final.
None of that displaces the poll above. A replay re-drives only what we already allocated to you; the poll is what gets you current truth, at any age and whatever the cause — including events that were never allocated at all, which no replay can resurrect. Classes 2 and 3 remain the poll's alone.
5. Self-test your verifier against the static fixture
Certify your verifier logic offline against
fixtures/webhook-hmac-self-test.json, which ships with
these documents — a fixed {t, method, path, body, secret} tuple with its correct expected
signature, generated directly from Caravel's own signing code (not hand-computed) and
round-trip-verified before being published. Its body is a real envelope whose data is
abbreviated to four members: the fixture certifies your signature construction, not the booking
shape. Two checks:
- Compute the signature over the fixture's exact fields with the given
secretand confirm it matchesexpected_signature_hex/ the fullheadervalue. - Apply the SAME header against
tamper_test.tampered_body(one field insidedatais changed) and confirm your verifier rejects it.
The fixture is preserved byte-for-byte: re-minting it — a fresh t, a fresh body — would change the
published signature your verifier certifies against, so it never tracks the current wire.
Recomputing the signature over the published tuple, as check 1 above does, is a different act: it
must and does reproduce exactly that value. Its envelope values — the
api_version, the timestamps — are frozen example data from the moment it was minted, not a
statement of what today's deliveries carry (§1 and CHANNEL-API.md state
those). It certifies signature construction, nothing more.
The fixture's t is fixed, so it is older than your replay window by the time you run this. Drive
your verifier with the fixture's own t as "now" (every implementation below takes a nowSecs
parameter for exactly this) — otherwise the freshness check rejects the fixture and you learn
nothing about your HMAC.
The fixture's secret is a self-test value, not a live credential — it signs nothing but this
tuple. Passing both checks certifies your signing and verification mechanics without needing a
delivery, and it is the check we verify for webhook signature verification: submit what your
verifier computed — POST https://sandbox-api.caravelway.com/_certification/hmac-self-test with your sandbox key and
{"signature_hex": "<check 1's hex>", "tampered_rejected": true} — and we recompute the hex from our own
signer, compare, and record the result against your account. See
CERTIFICATION.md.
Verifier reference implementations
All three below are self-contained — no third-party dependencies beyond each language's standard
crypto library — and implement exactly the scheme in §2. Drop one in and call it with the fixture's
fields, passing the fixture's own t as now: it accepts the genuine header and rejects
tamper_test.tampered_body. That is your self-test.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifySignature(headerValue, secret, { method, path, body }, { nowSecs = Math.floor(Date.now() / 1000), skewSecs = 300 } = {}) {
const parts = headerValue.split(',').map(s => s.trim());
let t = null;
const sigs = [];
for (const part of parts) {
const eq = part.indexOf('=');
if (eq < 1) continue;
const tag = part.slice(0, eq);
const val = part.slice(eq + 1);
if (tag === 't') t = /^[0-9]+$/.test(val) ? Number(val) : NaN;
else if (tag === 'v1') sigs.push(val);
// any other tag (a future scheme version) is ignored, never rejects the whole header
}
if (t === null || Number.isNaN(t) || sigs.length === 0) return false;
if (Math.abs(nowSecs - t) > skewSecs) return false; // replay window
const signedPayload = `${t}.${method.toUpperCase()}.${path}.${body}`;
const expected = Buffer.from(createHmac('sha256', secret).update(signedPayload, 'utf8').digest('hex'), 'utf8');
return sigs.some(sig => {
const got = Buffer.from(sig, 'utf8');
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Python
import hashlib
import hmac
import re
import time
def verify_signature(header_value, secret, method, path, body, now_secs=None, skew_secs=300):
if now_secs is None:
now_secs = int(time.time())
t = None
sigs = []
for part in (p.strip() for p in header_value.split(',')):
if '=' not in part:
continue
tag, _, val = part.partition('=')
if tag == 't':
if t is not None or not re.fullmatch(r'[0-9]{1,20}', val):
return False # exactly one `t`, plain digits only, bounded: a duplicate, a sign, a separator or a huge value is a hard reject
t = int(val)
t_raw = val
elif tag == 'v1':
if re.fullmatch(r'[0-9a-f]{64}', val):
sigs.append(val) # anything else is a non-matching candidate, never an exception
# any other tag (a future scheme version) is ignored, never rejects the whole header
if t is None or not sigs:
return False
if abs(now_secs - t) > skew_secs:
return False # replay window
# `body` must be the EXACT bytes received (Flask: request.get_data(); Django: request.body;
# FastAPI: await request.body()). Never re-serialise parsed JSON, and never str() a bytes object.
if isinstance(body, str):
body = body.encode('utf-8')
signed_payload = b'.'.join([t_raw.encode('ascii'), method.upper().encode('ascii'), path.encode('utf-8'), body])
expected = hmac.new(secret.encode('utf-8'), signed_payload, hashlib.sha256).hexdigest().encode('ascii')
return any(hmac.compare_digest(sig.encode('ascii'), expected) for sig in sigs)
PHP
<?php
function verify_signature(string $headerValue, string $secret, string $method, string $path, string $body, ?int $nowSecs = null, int $skewSecs = 300): bool {
$nowSecs ??= time();
$t = null;
$sigs = [];
foreach (array_map('trim', explode(',', $headerValue)) as $part) {
$eq = strpos($part, '=');
if ($eq === false || $eq < 1) continue;
$tag = substr($part, 0, $eq);
$val = substr($part, $eq + 1);
if ($tag === 't') {
if (!ctype_digit($val)) return false;
$t = (int)$val;
} elseif ($tag === 'v1') {
$sigs[] = $val;
}
// any other tag (a future scheme version) is ignored, never rejects the whole header
}
if ($t === null || count($sigs) === 0) return false;
if (abs($nowSecs - $t) > $skewSecs) return false; // replay window
$signedPayload = "{$t}." . strtoupper($method) . ".{$path}.{$body}";
$expected = hash_hmac('sha256', $signedPayload, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}
Common mistake across all three languages: using a non-constant-time string comparison
(==/===/strcmp) instead of timingSafeEqual/hmac.compare_digest/hash_equals — a
timing side-channel that leaks the correct signature one byte at a time. Always use your
language's constant-time compare for secret material.
6. Registration, secrets and rotation
Your callback URL and signing secret are configured with Caravel during onboarding: send us the
HTTPS endpoint you want deliveries posted to, and we either generate the secret and hand it to you
once or install one you supply. Both belong to a supplier relationship — one URL and one secret per
supplier your key reaches — and a registration you make yourself (§7) applies to every supplier your
key reaches unless you narrow it with X-Supplier-Id. In the sandbox one key reaches both of its
suppliers, so a registration you make there is set up for both at once; where a key reaches one
supplier, the two are the same act, and nothing you build changes when a second is granted. Since 1.16.0 you can also change both yourself, without asking us —
see §7. Onboarding is still where the FIRST secret comes from, because it has to reach you out of
band before any delivery is armed. Your secret is held encrypted at rest, is used only to sign your
deliveries, and is never returned in an API response.
Rotation costs no downtime and no coordination. When a secret is rotated the outgoing one stays
valid for an overlap window, and we sign every delivery in that window with both — one v1=
parameter per active secret. Because your verifier accepts a delivery if any v1= matches (§2),
you can cut over to the new secret at any point in the window without dropping an event. The window
has no timer: the previous secret keeps signing until your next rotation displaces it (§7). At
most two secrets are ever live at once.
Each attempt is addressed to the URL registered at the moment it is sent, so a change of endpoint takes effect on the next attempt — including retries of events already queued. Keep the old endpoint answering until the change is in place. That is true however the change was made, including through §7's self-service route.
Beyond the fixture in §5, the end-to-end check is the real thing — once delivery is armed for your
account (§1: Caravel tells you directly, and capabilities.webhooks.enabled reads true): create a
booking on your account and its booking.confirmed delivery arrives at your registered endpoint,
signed exactly as described above. Until then the fixture is the whole test, and a quiet endpoint is
configuration, not a defect.
7. Managing your endpoint yourself
Since 1.16.0. Feature-detect with webhook_self_service on GET /v1/capabilities.
Your webhook is yours to inspect, to change, and — within limits — to re-drive. One thing is not.
| Endpoint | Scope | What it does |
|---|---|---|
GET /v1/webhooks/endpoint |
read:webhooks |
Your callback URL, whether delivery is armed, whether a secret exists, whether a rotation overlap is open — and supplier_id, the supplier whose configuration it is (the same value GET /v1/suppliers returns; on the PUT and rotate answers too). |
GET /v1/webhooks/deliveries |
read:webhooks |
Your delivery log — what we allocated onto your stream, and what happened to each delivery. |
PUT /v1/webhooks/endpoint |
write:webhooks |
Re-point the callback. |
POST /v1/webhooks/secret/rotate |
write:webhooks |
Issue a new signing secret, with an overlap. |
POST /v1/webhooks/redeliver |
write:webhooks |
Ask us to re-drive deliveries we have already allocated to you. |
Each of these reads or writes your own webhook state and nothing else: there is no partner
selector on any of them, because the account is taken from your key rather than from the request —
so there is no field in which to ask for another account's. The one selector they take is the
optional X-Supplier-Id header, and its meaning is fixed now so that nothing changes for you when a
key is granted a second supplier: without it, a write applies to every supplier your key reaches,
and a read answers for the configuration all of them share; with it, both address that one
supplier. Such a read is never refused (since 1.48.0). Each member is the value your suppliers share:
a flag is true only when it is true for every one of them, and a value they do not all hold is null.
suppliers lists each supplier's own configuration, and it is the record to trust. A write lists each
supplier's outcome in results: a supplier that was not written keeps what it had, and repeating a PUT
converges them. Two such writes never interleave: each is applied to every supplier in one step, and a second waits
for the first. A rotation issues one new secret for every supplier, or for none if one cannot be reached, and
each keeps signing with its own previous secret during the overlap, so your receiver installs one secret. A test asks for one event per
supplier, and a redelivery replays every supplier's deliveries in one request (since 1.49.0; Asking for a
redelivery, below). The sandbox's one key reaches both of its suppliers, so all of this is observable
there.
Be precise about what that does and does not mean, because the two are easy to conflate: the
delivery log DOES take booking_id and event_id filters, and nothing stops you writing an
identifier that belongs to somebody else. Your filter is applied INSIDE a query already
bounded to your own operator and partner, so it can only ever narrow your own stream — never reach
outside it. An id that is not on your stream reads as absent (not_found on a replay), exactly as an
id that never existed would. You cannot address another account's rows; a filter only selects
within your own.
Arming delivery is not yours. enabled decides which events you receive from which instant, so it
stays with the operator. Ask us.
A self-serve sandbox account: your booking events land in the inspector
Since 2026-10-18, on the sandbox only. No /v1 member, value or version changed.
A self-serve sandbox account is never armed, and nothing is sent to a URL it registers. Every booking event the sandbox emits for you is captured in the inspector on your account page instead, as exactly the request a receiver would have seen:
- the same envelope (§1), with its
sequence; - the same
X-Caravel-Signature, signed with your live secret exactly as a delivery is, and with both secrets during a rotation overlap (§2); - the same
X-Caravel-Event-Id,X-Caravel-Event-TypeandX-Caravel-Delivery-Attempt; - the request-target of the URL you registered, or
/if you registered none.
Verify each capture with your receiver code and your secret, exactly as you would a delivery. Where this differs from an armed endpoint:
- When your stream starts. An armed stream starts at the instant delivery is armed, and earlier events
are never sent. A self-serve stream starts when it first has a signing secret; the first test event
creates one (Asking for a test event, below). The booking events since the last sandbox reset that
came before the secret are then captured too, numbered in event order from
sequence1. As on the wire, captures of different bookings can land out of that order (§3b, Numbers can arrive out of order); a booking's own revisions never do. A key that reaches several suppliers has one stream per supplier, and each starts when that supplier's stream has a secret. - What
deliveredmeans. In your delivery log (Reading your delivery log, below) a captured event readsstatus: "delivered",last_status_code: null(nothing was sent, so no endpoint answered) andlast_failure_class: null. It is never a made-up200. - What you cannot exercise here: your endpoint's status codes, timeouts, retries and backoff,
Retry-After, exhaustion and dead-letter, and the circuit breaker (§1b). A capture succeeds on its first attempt. If a capture itself fails, the delivery is retried on the ordinary schedule withlast_failure_class: null; that is a fault on our side, not yours. The breaker counts those failures as it counts any, so many of them at once can hold your stream back for its cool-down. - The inspector is a window, not the log. We keep your last 20 captures per supplier, test events
included, and the page shows at most 20 at a time, newest first, across your suppliers. A
sequencemissing from the window was usually pruned out of it. Your delivery log is the authority for gap detection (§3b). - A sandbox reset empties the inspector, test events included.
- Redelivery works (Asking for a redelivery, below): a replayed event is captured again, with the
same
event_idandsequenceandX-Caravel-Delivery-Attempt: 1.
Re-pointing the callback
PUT /v1/webhooks/endpoint HTTP/1.1
X-API-Key: <a key carrying write:webhooks>
Content-Type: application/json
{"url": "https://hooks.your-domain.example/inbound"}
The URL must be deliverable or it is refused: https only, no credentials in the URL, and it must
not resolve to a private, loopback, link-local, CGNAT or otherwise reserved address. That is the same
check our delivery worker runs before every send, applied at write time so a URL we could never
deliver to is never stored. A failure is 422 and nothing changes.
⚠️ Treat this as a security change, not a settings edit. The stream you are re-pointing carries personal data — your customers' names, contact details and itineraries. Whoever owns the URL receives it; the signature proves who sent an event, never who may read it. The change is recorded in the operator's audit trail together with the URL it replaced, so an unexpected one can be seen and reversed. Do not put
write:webhookson the key that carries your booking traffic — whoever holds that key can redirect your events.
Rotating the signing secret
POST /v1/webhooks/secret/rotate HTTP/1.1
X-API-Key: <a key carrying write:webhooks>
Your existing secret moves into an overlap slot and a new one is installed. From that moment every
delivery is signed with BOTH — one v1= parameter per active secret on X-Caravel-Signature — and
since your receiver already accepts a signature if any parameter verifies, it needs no code change
to tolerate this. The cut-over is:
- Rotate. Store the returned
secret. It is shown once: we hold only its ciphertext and no endpoint decrypts it back to you. If you lose it, rotate again. - Add it to your verifier alongside the one you already have. Both are verifying now.
- Drop the old one when you are satisfied. There is no timer — the previous secret keeps signing until your next rotation displaces it, so cut over on your own clock.
At most two secrets are ever live, so the header cannot grow without bound, and
secret_overlap_open on the read endpoint tells you whether a window is open without guessing.
Your first secret is not set here. It has to reach you out of band before any delivery is armed,
so the operator sets it; this route answers 409 until they have.
Reading your delivery log
GET /v1/webhooks/deliveries?status=exhausted&limit=50 HTTP/1.1
X-API-Key: <a key carrying read:webhooks>
sequence (§3b) tells you that you missed something. This tells you what happened to it. One page
of your own deliveries, newest occurrence first, following meta.next_page to exhaustion like every
other list (§1.9 of the contract).
Filter with any of status, occurred_since, occurred_until, booking_id, event_id,
sequence_from, sequence_to, plus limit and page_token. Unknown parameters are refused, never
ignored — a misspelt occurred_sinse is a 422 rather than a silently unfiltered page.
Each row carries the envelope's own event_id, sequence, type, booking_id, external_ref and
revision, so a log row joins to what you stored — plus:
| Field | Meaning |
|---|---|
status |
delivered, retrying (we are still trying) or exhausted (we have stopped). On a self-serve sandbox account, delivered means captured in your inspector (above). |
attempts |
Attempts made since the last reset — see the warning below. |
last_status_code |
The HTTP status your endpoint last returned, or null if we never got one. |
last_failure_class |
endpoint_error (your endpoint answered non-2xx), endpoint_unreachable (we could not complete a request), not_deliverable (your URL resolved somewhere we refuse to send), or null. |
last_attempt_at · next_attempt_at · delivered_at · queued_at |
The delivery's timeline. |
⚠️ This is current state, not an append-only history — so read it before you replay. A row is updated in place, and a replay resets
attemptsandlast_status_codeand clearsdelivered_at. Every per-attempt figure means "since the last reset", never "ever".🚨 And "cleared" can be permanent.
delivered_atis restored only when the replayed delivery SUCCEEDS. Replay a row that was alreadydelivered, and if the replay then exhausts its attempts, the original success instant is gone from this log for good — the row endsexhaustedwithdelivered_at: null, and this row no longer records the earlier success instant. If that instant is evidence you rely on, store it your side before you replay; this log will not keep it for you.
The raw error text is deliberately not published: it names our own internals. last_failure_class is
the part you can act on — endpoint_error and endpoint_unreachable are yours to fix,
not_deliverable means the URL on file cannot be delivered to and needs re-pointing.
Asking for a test event
POST /v1/webhooks/test (scope write:webhooks) sends you one webhook.test delivery, signed exactly
as §2 describes, over a synthetic booking you will never have issued (external_ref starts
WEBHOOK-TEST-, data.test is true). Use it to prove your receiver before the first real booking:
the signature check, the dedupe on event_id, the 2xx you answer.
Where it goes depends on what your account is:
- Armed for delivery (the operator has enabled your endpoint, §6): it is POSTed once to your
registered URL, through the same checks a real delivery passes, and the response tells you what your
endpoint answered (
delivered_to,status,ok). Nothing is retried for a test event, and it does not appear in your delivery log (§7 "Reading your delivery log") — that log is booking events. - A self-serve sandbox account: you cannot arm delivery yourself, and a test event never leaves the
host — not even while the operator has switched on real delivery for you (next section).
Instead the exact request a receiver would have seen — every header including
X-Caravel-Signature, and the body — is captured into the inspector on your account page, beside your booking events (above) (delivered_to: "inspector"; the same headers and body come back in the response). Verify the signature there with your receiver code and your secret; the first test mints your secret if you have none yet (rotate it to learn its value, "Rotating the signing secret" above). - Not armed yet, and not self-serve:
409 webhooks_disabled— ask the operator to arm delivery.
Real delivery to your endpoint from a self-serve sandbox account
A self-serve sandbox account's booking events are captured in its inspector, never sent. To see real requests reach your own receiver before production — TLS to your host, your load balancer, your verifier on the bytes we send — ask the operator to switch on real delivery for one sandbox supplier. You cannot switch it on yourself, by any endpoint or from the account page. The operator does it only after your endpoint has answered an ownership challenge, which proves the URL you registered is yours.
The challenge. One POST to your registered URL, through the same checks as every delivery (https
only, a public address, no redirect followed), signed exactly as §2 describes:
POST /your/registered/path HTTP/1.1
Content-Type: application/json
X-Caravel-Event-Type: webhook.endpoint_challenge
X-Caravel-Event-Id: <challenge_id>
X-Caravel-Signature: t=…,v1=…
{"type":"webhook.endpoint_challenge","challenge_id":"<uuid>","challenge":"<random token>",
"issued_at":"<ISO-8601 UTC>","operator_id":"<uuid>","supplier_id":"<supplier id>"}
Your answer. Verify the signature first, as for any delivery. Then answer 200 with a JSON object
of at most 4 KiB, within 10 seconds:
{"challenge_response": "<lowercase hex HMAC-SHA256(secret, \"webhook.endpoint-challenge.v1:\" + challenge)>"}
secret is your current webhook secret for that supplier — or, during a rotation overlap, the
previous one. challenge is the string from the body, exactly as sent. Echoing the request back does
not pass, and neither does answering 200 with no challenge_response: a receiver that skips unknown
types fails the challenge, so add this one type to the types you handle. It is not a booking event: it
is never retried, never in your delivery log, and never in webhooks.event_types.
Once it is on, that supplier's booking events are POSTed to the URL that answered, retried and
logged exactly like any armed delivery (§1b), with the same sequence stream your inspector was
showing — no gap. It runs for a limited time and a limited number of requests (retries count), and the
account page shows which: what is live, until when, and how many requests are used. It stops, and your
events go back to the inspector with the next sequence numbers, when it expires, when its requests are
spent, when the operator ends it, or when you re-point your URL (PUT /v1/webhooks/endpoint) —
pointing back at the verified URL resumes it while it is otherwise live. GET /v1/webhooks/endpoint
keeps reporting enabled: false: that flag is production arming, which this is not.
Asking for a redelivery
POST /v1/webhooks/redeliver HTTP/1.1
X-API-Key: <a key carrying write:webhooks>
Idempotency-Key: <your key>
Content-Type: application/json
{"sequence_from": 4120, "sequence_to": 4180}
Choose exactly one selector — sending two is a 422:
| Selector | Body |
|---|---|
| By sequence range | {"sequence_from": <n>, "sequence_to": <n>} — inclusive, both bounds required together. |
| By event id | {"event_ids": ["<uuid>", …]} — de-duplicated before the cap, so a repeated id costs you one. |
| By time window | {"occurred_since": "<iso>"}, optionally with "occurred_until". |
Idempotency-Key is required here (§1.8 of the contract): a network-retried replay must not spend
your replay budget twice. The reply is 202 — it promises queueing, never delivery — and counts
the outcome per selected delivery:
| Count | Meaning |
|---|---|
requeued |
Queued again. |
skipped_superseded |
A later revision of that booking is already delivered. We never knowingly send backwards. |
already_pending |
Already queued or in flight; nothing to do. |
not_found |
Selected ids with no delivery on your stream. |
A replay is not "one delivery per booking". It re-drives every selected revision that is not
superseded by a currently-delivered later one and has come to rest — one row for a
fully-delivered booking, the whole chain for one whose deliveries died — so requeued is a count of
DELIVERIES, not of bookings. A selected delivery that is still queued or mid-retry is NOT re-driven;
it is counted already_pending and left alone. So requeued is not the size of your selection:
read all four counts rather than inferring what was queued from what you asked for. And
it covers what existed when we accepted it: a window running up to now() may match deliveries
that appear after we admit the request, which this call will not carry. Re-run it rather than
treating one call as final.
Read the live bounds from capabilities.webhooks.redelivery rather than from this page. At the time
of writing they are at most 500 events per request, a 60-second cooldown between replays, and
a retention window of roughly 84 days — and that last one is not a constant even at fixed
configuration. The enforced window is the SMALLER of what is configured and what retention has
actually left us, so after a retention change it can be shorter than the configured depth; that
smaller figure is what window_days advertises and what the refusal below applies — and a selector reaching past that window is refused 422
naming the window, never silently shortened. Both throttles answer 429 with Retry-After —
one replay per account runs at a time, so a second concurrent one is refused, and so is a replay
inside the cooldown. Neither is stored against your Idempotency-Key: they are transient by
construction, so the SAME key is still usable when you retry after Retry-After.
A key that reaches several suppliers (since 1.49.0), sending no X-Supplier-Id, replays every supplier's
deliveries in one request, under one Idempotency-Key and one answer. results lists each supplier's outcome; the
counts above are their sums, except not_found, which counts an id once when no supplier holds it. A window or an
id list covers every supplier. A sequence range counts one supplier's stream, so it needs X-Supplier-Id, or
it is 409 supplier_required. The limits hold for the request as a whole: the event cap applies across all
suppliers, and if any supplier is inside its cooldown, busy, or over its in-flight bound, nothing is queued anywhere
and the 429 names the longest wait. A supplier whose delivery is off is listed webhooks_disabled while the others
replay; when every one is off, the answer is 409 webhooks_disabled. A key used for such a replay is not reusable
for one supplier with X-Supplier-Id, nor the other way round: 422 idempotency_key_reused.
A self-serve sandbox account replays into its inspector: each requeued event is captured again on the
next pass. Until a stream has a signing secret there is nothing to replay, and the answer is
409 webhooks_disabled, saying so.
What it cannot do. It re-drives deliveries we have already allocated to you — it cannot create an event that was never allocated, and it will not hand you a revision that has since been superseded. For those, and for anything older than the retention window, the answer is the poll in §4.
Error catalogue
Status: Informational, and generated: the table below is rendered into this pack from the same
source as the live catalogue page at https://api.caravelway.com/docs/errors, so it is the vocabulary as
of this pack's build (surface version 1.66.0). The live page is always current; every
error you receive carries a doc_url that resolves to it. This file exists so you can read the
catalogue before you hold a key, offline, in the first hour.
Every error is one envelope (CHANNEL-API.md §1.5):
{ "error": { "code": "…", "message": "…", "request_id": "req_…", "doc_url": "…/docs/errors#…", "details": [ … ] } }
Branch on code, never on the HTTP status alone — 409 alone covers more than a dozen distinct
conditions, each with its own remedy. message is for a human and may change wording at any time.
details[], where present, names the offending field and a finer issue; that finer vocabulary is
indexed below and, unlike code, is not covered by the extensibility guarantee — treat
an issue you do not recognise as "this field was rejected, reason unknown to me". The code list
itself grows within /v1: an unrecognised code is a soft failure, never a crash.
The catalogue
| HTTP | code |
Meaning / typical cause | Retry? | What to do |
|---|---|---|---|---|
| 400 | malformed_request |
The request body is not valid JSON, or the content-type is wrong. | No — fix the request. | Send a JSON body with Content-Type: application/json; check the payload parses before you send it, and look at details[] for the byte that broke it. |
| 401 | unauthenticated |
Missing or invalid X-API-Key. Keys are environment-scoped: a key presented to the environment it was not issued for (sandbox vs production) is refused with this same response — if a known-good key is refused, verify the base URL targets the environment the key was issued for. It is never a load signal: a valid key is not answered 401 because the service is busy — that is 503 service_unavailable. |
No — check the key (two keys per partner are active during rotation). | Present the key in X-API-Key, on the host it was issued for (chk_test_ keys work on the sandbox host only, chk_live_ keys on production only); during a rotation either of the two live keys is accepted. |
| 403 | forbidden |
The key is valid but lacks the required scope (e.g. write:bookings). One operation has a second reason: PUT /v1/partner-codes is also refused, with details[].issue partner_codes_not_enabled, when the supplier has not switched on your recording your own codes — the scope is held, the supplier has not said yes. |
No — request the scope, or ask the supplier to enable it. | Read the scope the operation publishes (the reference names it per operation) and ask the operator to add it to your key; a new key is issued with the scope, the old one keeps working until you retire it. If details[].issue is partner_codes_not_enabled the key is fine: ask the supplier to switch on your recording your own codes (contract §3.15). |
| 404 | not_found |
No booking or resource exists with that reference. | No. | Check the reference you sent — a booking ref is the one this platform returned when the booking was created; a reference-data id comes from the catalogue endpoints. Nothing exists under that value for your key. |
| 409 | booking_conflict |
This external_ref was already used to create a booking with a different intent hash (§1.8). | No — use a new external_ref, or fetch the existing booking. | You already created a booking under this external_ref with different content. Fetch the existing booking with GET /v1/bookings/{ref} and use it, or send a new external_ref if this is genuinely a different rental. |
| 409 | invalid_transition |
The booking state does not allow this operation (e.g. modifying a cancelled booking). | No — re-read the booking. | Re-read the booking with GET /v1/bookings/{ref} and act on its current status; a cancelled or completed booking cannot be modified, and a modification cannot be applied twice. |
| 409 | price_changed |
The computed price no longer equals expected_price; the response carries the new full breakdown. | No — re-consent the customer and resubmit with the new price. | Show the customer the new full breakdown the response carries, obtain their consent to the new total, and resubmit the same request with expected_price set to that total. |
| 409 | revision_conflict |
expected_revision is stale — the booking changed since it was last read; the response carries the current booking. | No — re-read, re-decide. | Re-read the booking with GET /v1/bookings/{ref}, decide again against its current state, and resubmit with the revision you just read as expected_revision. |
| 409 | idempotency_in_progress |
A request with the same Idempotency-Key is still executing — or the supplier is moving your relationship with it into your account (the move that lets one key reach all your suppliers), which holds every write to that relationship, under any key, until the move completes. | Yes — after Retry-After, and again with backoff while it persists. | Wait the Retry-After seconds, then retry with the SAME Idempotency-Key and body, backing off between retries as the retry policy says. A retry can meet the original still executing, so keep backing off until the code changes: a duplicate then receives the original request's result once the original finishes, never a second execution, and a held write runs once the move completes. The message names the first cause in both cases: branch on the code. |
| 409 | version_mismatch |
X-API-Version disagrees with the version in the path. | No — align header and path. | Remove the X-API-Version header, or set it to 1; the version is the /v1 path segment and the header may only agree with it. |
| 410 | rate_expired |
RESERVED — not returned by any /v1 endpoint today. It is published so the code enum is stable, and it would mean: the priced result this request was built on is too old to honour. There is no rate_id in this API to expire — a quote expiring is quote_expired (409). Do NOT confuse it with rate_limited (429), which is one character away and IS retried. |
No — re-search. (Never returned today.) | This code is reserved and never returned today; if you ever receive it, search again with GET /v1/availability and quote afresh — a priced result has a lifetime and this one ended. |
| 409 | quote_expired |
The quote_id is past its expires_at. | No — re-quote. | Request a fresh quote with POST /v1/quote; read quote_ttl_seconds on GET /v1/capabilities and book inside that window instead of assuming a lifetime. |
| 422 | validation_error |
Field-level validation failed; details[] names each field and issue. | No — fix the fields. | Read details[]: each entry names the field and the issue; correct every field named, then resubmit — sending the same body again returns the same answer. |
| 422 | idempotency_key_reused |
The same Idempotency-Key was sent with a different body — or, for an account whose credentials reach more than one supplier, it was already used with a different supplier. | No — use a fresh key for a different request. | An Idempotency-Key belongs to exactly one logical request to one supplier. Mint a new UUID for a different body or a different supplier; reuse a key only to replay the identical request, to the same supplier, after a timeout. |
| 422 | driver_age_out_of_range |
The driver's age is outside the vehicle's min/max. | No. | Compare the driver's age with the min_driver_age / max_driver_age the vehicle publishes in GET /v1/reference/vehicles and offer a group the driver qualifies for; the operator's age rules are not negotiable per booking. |
| 422 | no_availability |
The selected group is no longer available for those dates. | No — re-search. | The group has no units left for those dates. Search again with GET /v1/availability for the same dates and offer the customer another group, or other dates. |
| 429 | rate_limited |
Too many requests for this partner key. | Yes — honour Retry-After with exponential backoff. | Slow down: honour Retry-After, read X-RateLimit-Remaining and X-RateLimit-Reset on every response, and spread bulk reads (catalogue refreshes) across the window instead of bursting them. |
| 500 | internal_error |
An unexpected fault on our side, and we are alerted to it. It is not load: a service too busy to answer, or a database failing over, answers 503 service_unavailable instead. |
Yes — backoff with jitter. | Retry with exponential backoff and full jitter — wait a random time up to 0.5 s × 2ⁿ before retry n, capped at 8 s — keeping the same Idempotency-Key on a write so the retry replays rather than re-executes; if it persists, quote the request_id to support. |
| 503 | service_unavailable |
Maintenance or overload: the service could not serve this request right now — it is at capacity, its database is failing over, or a statement could not be answered in time. It is never a verdict on the request or on your key; an overload answers this, never 401 and never 500. |
Yes — honour Retry-After. | Wait at least the Retry-After seconds when the header is present (a few whole seconds), otherwise back off exponentially with full jitter (up to 0.5 s × 2ⁿ, capped at 8 s); retry with the same Idempotency-Key on a write, so an attempt that did land replays instead of running twice. If the window keeps repeating, check the status the operator publishes. |
| 422 | not_sellable |
The requested group/product cannot be sold for this request (e.g. incoherent or withheld configuration). | No — choose another option. | Choose another option from the availability response: the group or product you named cannot be sold for this request as configured by the operator, and no retry changes that. |
| 409 | availability_changed |
Availability moved between quote/expectation and booking; nothing was booked. | No — re-search or re-quote. | Nothing was booked. Search again with GET /v1/availability, quote the selection you find, and book the new quote_id; tell the customer the earlier option is gone. |
| 409 | quote_consumed |
This quote_id was already consumed by a booking. | No — re-quote for a new attempt. | A quote is booked at most once. Request a fresh quote with POST /v1/quote for the new attempt and book that quote_id; the earlier booking is still yours under its ref. |
| 500 | reference_set_too_large |
A reference dataset exceeded the servable bound; the operator has been alerted. | Yes — backoff; contact support if persistent. | Retry later with backoff — the operator has been alerted and is trimming the dataset; use changed_since on the catalogue endpoints so a refresh reads only what moved. |
| 409 | credential_inactive |
The key you presented is no longer active, so it cannot rotate or retire anything. | No — ask the operator for a new credential. | The key you presented is retired or revoked. Use the successor key you were issued, or ask the operator for a new credential; an inactive key cannot rotate or retire anything. |
| 409 | rotation_in_progress |
This account already has an open rotation overlap; two live keys are the most it may hold. | No — retire the outgoing key with POST /v1/credentials/{key_id}/revoke, then rotate again. | Finish the rotation you started: switch your traffic to the new key, then retire the outgoing one with POST /v1/credentials/{key_id}/revoke; only then can you rotate again. |
| 409 | credential_state_changed |
The key changed state while this request was running; nothing was rotated. | No — this is not one of the four retryable answers (§1.8). Re-read GET /v1/credentials, then decide again. | Read GET /v1/credentials to see the current state of both keys, then repeat the operation once against what you read; nothing was rotated by the request that got this answer. |
| 409 | self_revocation_refused |
A key cannot revoke itself — doing so would leave the account with no live credential. | No — present the successor key and revoke this one with it. | Rotate first so a successor exists, present the successor key, and revoke this one with it; a key never revokes itself, so the account always keeps one live credential. |
| 409 | rotation_required |
The key you presented was not issued by a rotation, so it has no predecessor to retire. | No — rotate first (POST /v1/credentials/rotate), then present the key that rotation issues. | Call POST /v1/credentials/rotate with the key you hold, switch to the key it returns, then retire the old one with that new key; a key without a predecessor has nothing to retire. |
| 409 | not_your_predecessor |
A key may only retire the key it replaced. The key named is not this credential's predecessor. | No — present the successor of the key you want to retire, or ask the operator to revoke it. | Present the key that replaced the one you want to retire — only a successor retires its own predecessor; read GET /v1/credentials to see which key succeeded which. |
| 409 | no_webhook_secret |
This account has no webhook secret to rotate; the operator sets the first one. | No — rotation is available once the operator has set the first secret. | Ask the operator to set your first webhook signing secret from the console; once it exists you rotate it yourself with POST /v1/webhooks/secret/rotate. |
| 409 | webhooks_disabled |
Webhook delivery is not enabled for this account, so there is nothing to replay. | No — ask the operator to enable delivery. | Ask the operator to enable webhook delivery for your account (they arm it per relationship); until then use POST /v1/webhooks/test on the sandbox to prove your receiver, and poll GET /v1/bookings for changes. |
| 422 | unknown_supplier |
The X-Supplier-Id header names a supplier this API key cannot sell. Sent today for any value that is not your own supplier_id. | No — send your own supplier id, or omit the header. | Read GET /v1/suppliers, take the supplier_id of the supplier you meant, and send that value — or omit the header entirely, which is always valid because the supplier is implied by the ids in your request. |
| 403 | supplier_not_enabled |
RESERVED — not returned by any /v1 endpoint today, because a credential reaches exactly one supplier and it is always its own. It would mean: the supplier exists and you can see it, but your agreement does not permit you to sell it. | No — the agreement has to change. (Never returned today.) | This code is reserved and never returned today; if you ever receive it, stop selling that supplier and ask the operator to enable it for your account, then re-read GET /v1/suppliers to confirm. |
| 409 | supplier_paused |
RESERVED — not returned by any /v1 endpoint today, because every supplier this platform serves is selling. It would mean: the supplier you named is not selling right now, so nothing may be quoted or booked against it. | No — search again instead. (Never returned today.) | This code is reserved and never returned today; if you ever receive it, search again with GET /v1/availability — the intent is that a paused supplier's offers will disappear from search, so the search result will be what tells you what is still sellable. |
| 409 | ambiguous_ref |
The external_ref in the path names a booking at more than one of the suppliers your API key reaches, because the value was reused across suppliers (§1.8). Returned only to a key that reaches more than one supplier; a key that reaches one never receives it. | No — address the booking by booking_id, or name the supplier with X-Supplier-Id. | Use ?ref_type=booking_id with the booking_id you stored, or send X-Supplier-Id naming the supplier you mean. Then stop reusing an external_ref across suppliers: mint one value per rental, unique across your account. |
| 409 | supplier_required |
The request needs exactly one supplier and nothing in it decides which: your API key reaches more than one supplier, no X-Supplier-Id was sent, and no id in the request belongs to exactly one of your suppliers (or a settlement id exists at more than one). Returned only to a key that reaches more than one supplier. | No — name the supplier. | Send X-Supplier-Id with the supplier_id of the supplier you mean — it is carried on every booking, quote and reference object that supplier returns — or address the request by an id that belongs to that supplier, such as a booking_id or a quote's vehicle_id and station location_id. |
| 422 | supplier_mismatch |
The ids in one request belong to more than one of the suppliers your API key reaches, for example a pickup station of one supplier and a vehicle of another. A request is executed by exactly one supplier. Returned only to a key that reaches more than one supplier. | No — correct the ids. | Take every id in the request from the same supplier's reference data (read GET /v1/reference/* with X-Supplier-Id naming that supplier), and send the same X-Supplier-Id on this request so the supplier is explicit. |
| 422 | distribution_channel_not_approved |
The downstream distribution channel named on the search, the quote or the booking is not approved by the operator for your account at this moment: it is not registered, it is proposed, suspended or deactivated, or its approval is not in effect. Nothing was priced, booked or changed. | No — name an approved channel, or have this one approved first. | Read GET /v1/distribution-channels and send the code of a channel whose status is approved and whose effective window covers now; to sell through another channel, ask the operator to register it and give its written approval first. |
Retry policy
Retry only 429, 5xx, 409 idempotency_in_progress and network errors — and always with the
same Idempotency-Key on a retried write, so the retry replays rather than re-executes
(INTEGRATION-GUIDE.md §5.4). Everything else is a request to change, not
to repeat.
- When the response carries
Retry-After, wait at least that many seconds before the retry. - Otherwise, back off exponentially with full jitter: before retry n (counting from 0), wait a random time between 0 and 0.5 s × 2ⁿ, capped at 8 s — so retries 0, 1, 2, 3, 4 wait up to 0.5, 1, 2, 4 and 8 s, and every retry after that up to 8 s. The randomness is the point: it keeps clients that failed together from retrying together.
- A busy service answers
503, withRetry-After— never401and never500(contract §1.8 says what counts as overload). So a401is always about the key, and waiting does not change it.
The details[].issue index
Every issue value any /v1 endpoint can put in details[], in one place. It is generated from
the source the endpoints raise them from, and a test holds the two together, so a value that
appears on the wire appears here. The rule above still holds: this vocabulary is finer than
error.code, it grows as surfaces gain rules, and a value you do not recognise means "this
field was rejected, reason unknown to me" — never a reason to stop. field and issue are on every
entry; the third column lists the other members an entry of that kind carries today (message is
the human sentence; the rest are values you can act on). Entries raised on a 409 say so. A §
reference is a section of CHANNEL-API.md.
issue |
Meaning | Other members the entry can carry |
|---|---|---|
schema |
The member fails the request schema — wrong type or format, a required member missing, or a member this request does not take. message says which. |
message |
required |
A required header or member is absent; message names it and says why it is required — for example Idempotency-Key on a write that replays (§1.8). |
message |
unknown_parameter |
A query parameter this endpoint does not take (strict-in, §1.2). Check the spelling against the OpenAPI document. | message |
malformed_parameter |
A query parameter given more than once, or not a single scalar value. | message |
malformed |
The value has the wrong shape for its parameter — e.g. cross_border must be the literal true or false. |
message |
malformed_datetime |
Not a full ISO-8601 datetime with an explicit offset (§1.7). | message |
unparseable_instant |
A datetime string that cannot be parsed at all. | value |
invalid_calendar_date |
A date that does not exist on the calendar (e.g. the 31st of a 30-day month). | value |
exactly_one_datetime_form |
A pickup or dropoff end must be given in exactly one of its two accepted forms, not both and not neither. | message |
conflicting_parameters |
Two parameters name one thing two ways: a desk by pickup_location_id or dropoff_location_id beside a place by airport or city, or an end's airport code beside its city or its country. Send one; message says which pair. |
message |
dropoff_not_after_pickup |
The dropoff must be after the pickup (compared as local dates at the stations). | message |
must_be_after |
This instant must be after the one named in ref. |
ref |
malformed_id |
An identifier in the request is not well-formed (a UUID was expected). | message |
malformed_page_token |
page_token is not this endpoint's own opaque cursor — pass back meta.next_page verbatim (§1.9). |
message |
out_of_range |
A numeric value outside the accepted range — a limit, a sequence bound, a window. message names the bounds. |
message |
invalid_value |
The value is not one of the accepted set; message lists them. |
message |
unknown_value |
A parameter with a fixed vocabulary received a value outside it (e.g. ref_type). |
message |
unknown_status |
A booking status outside the published status vocabulary. | message |
unsupported_currency |
This deployment does not price in the requested currency; supported names the one it does (§3.0). |
value, supported |
unknown_location |
A location_id that is not in the operator's current selling estate (§3.2) — re-sync locations. |
message |
unknown_vehicle |
vehicle_id is not in your catalogue (§3.3). |
value |
unknown_protection |
protection_id is not in your catalogue (§3.5). |
value |
unknown_extra |
An extra_id is not in your catalogue (§3.4). |
value |
unknown_plan |
Your account holds no price book for the named plan (§4.2). |
message |
protection_vehicle_mismatch |
The protection is not offered on this vehicle group — pick one from the group's protection_options. |
vehicle_id, protection_id |
duplicate_extra |
The same extra_id appears more than once in the selection — send it once with a quantity. |
value |
quantity_out_of_range |
An extra's quantity is outside the min..max the catalogue publishes for it. |
min, max, value |
mutually_exclusive_extras |
Two selected extras belong to the same exclusive group; conflicts_with names the other. |
group, conflicts_with |
exactly_one_main_driver |
drivers[] must contain exactly one role: main; value is how many it had. |
value |
driver_age_out_of_range |
The main driver's age is outside the vehicle group's min_driver_age..max_driver_age (§6.3) — the 422 code is driver_age_out_of_range. |
age, min_driver_age, max_driver_age, vehicle_id |
rental_duration_out_of_range |
The rental length (value, in rental days) is outside the operator's published bounds min..max (§2.6). |
min, max, value |
lead_time_too_short |
The pickup is closer than the operator's min_lead_hours to as_of (§2.6). |
min_lead_hours, as_of |
out_of_hours_not_offered |
The end falls outside the desk's opening hours at location_id (local_weekday, local_time) and out-of-hours service is not offered there (§8.5). |
location_id, local_weekday, local_time |
delivery_not_offered |
Delivery or collection is not offered at location_id (§2.1). |
location_id |
movement_not_permitted |
The operator does not perform this (pickup, dropoff) movement; policy says whether the pair is forbidden or simply not in the permitted list (§2.6). |
pickup_location_id, dropoff_location_id, policy |
no_rate |
The vehicle is not priceable for those dates — the 422 code is no_availability; date names the first uncovered day when one is known. |
value, date |
selection_too_large |
The selector matches more items than one request may carry (webhook redelivery, §7) — narrow it and replay in parts. | message |
selector_too_broad |
A search by place names more desk pairs, across the suppliers your key reaches, than one search prices; it is refused, never served in part. Narrow it: an airport code rather than a city, or one desk by pickup_location_id. message gives both counts. |
message |
outside_retention_window |
The requested window lies outside the retention this account holds (webhook delivery log, §7); message names the bound. |
message |
not_deliverable |
The callback URL is refused by the egress rules (scheme, credentials in the URL, a private or unresolvable host); message gives the reason (§7). |
message |
unavailable |
On 503 service_unavailable from a webhook write or test your key sent to several suppliers (§7): the supplier in ref could not be reached, so nothing was done for it. Retry. |
ref, message |
not_found |
On 503 service_unavailable from a webhook write your key sent to several suppliers (§7): the supplier in ref has no webhook registration yet, so it had nothing to change (on its own it answers 404 not_found). |
ref, message |
no_webhook_secret |
On 503 service_unavailable from a secret rotation your key sent to several suppliers (§7): the supplier in ref has no secret to rotate; the operator sets the first one. |
ref, message |
not_rotated |
On 503 service_unavailable from a secret rotation your key sent to several suppliers (§7): the rotation was undone for every supplier, so the one in ref keeps the secret it had. Retry. |
ref, message |
outcome_unknown |
On 503 service_unavailable from a webhook write your key sent to several suppliers (§7): whether it took effect for the supplier in ref could not be confirmed. Read GET /v1/webhooks/endpoint before you rotate again; a PUT you can simply repeat. |
ref, message |
intent_mismatch |
On 409 booking_conflict: this external_ref already exists with a different booking intent — modify that booking rather than creating a new one (§5.1). |
— |
voucher_conflict |
On 409 booking_conflict: this external_ref is already recorded as your prepaid voucher on another booking — one voucher, one booking (§5.1). |
— |
quote_driver_mismatch |
drivers[].age / role on the create do not match the drivers priced on the quote (names may differ). |
message |
not_permitted |
The named value or member is outside what your agreement permits: a payment_model it does not permit (§5.1), or a distribution_channel — on a search, a quote or a booking — where your agreement takes none. |
— |
quote_channel_mismatch |
On a create that consumes a quote_id: the distribution_channel you named does not match the price the quote carries. Either the quote was priced for another channel (quote_distribution_channel), or it was priced for none (null) and the channel you named has prices of its own. Send the quote's channel, or request a new quote naming yours. |
quote_distribution_channel |
booking_channel_mismatch |
On a cancel that names a distribution_channel: it is not the channel recorded on the booking (booking_distribution_channel, null when none is recorded). Nothing was cancelled; omit the member or send the booking's own. |
booking_distribution_channel |
unregistered |
On 422 distribution_channel_not_approved: no channel with this code is in the operator's register for your account (GET /v1/distribution-channels). |
— |
not_approved |
On 422 distribution_channel_not_approved: the channel is registered but not approved; status says whether it is proposed, suspended or deactivated. |
status |
not_in_effect |
On 422 distribution_channel_not_approved: the channel is approved, but its approval does not cover this moment; effective_from and effective_until bound it. |
effective_from, effective_until |
price_changed |
On 409 price_changed: the price recomputed at create (computed) differs from the one you sent (expected); breakdown is the current one (§5.1). |
expected, computed, breakdown |
revision_conflict |
On 409 revision_conflict: your expected_revision (expected) is not the booking's current_revision; the current booking is attached so you can re-read without another call (§5.3). |
expected, current_revision, booking |
invalid_transition |
On 409 invalid_transition: the booking's current_status does not allow this change (§5.3, §5.4). |
current_status |
empty_patch |
A modify must change at least one field (§5.3). | message |
partner_codes_not_enabled |
On 403 forbidden from PUT /v1/partner-codes: the supplier has not switched on your recording your own codes, so nothing in the body was read (§3.15). It is not a missing scope. GET is not affected. |
— |
too_many_entries |
PUT /v1/partner-codes carries more than 2 000 entries (JSON) or rows (CSV); send them in more than one request (§3.15). |
message |
unterminated_quote |
A quoted CSV field opens on the line field names (lines[7]) and the file ends before it closes (§3.15). |
message |
empty |
The CSV body of PUT /v1/partner-codes has no header row, or a header and no rows (§3.15). |
message |
missing_column |
The CSV header has no kind, id or code column; message names it. Other columns are ignored (§3.15). |
message |
duplicate_column |
The CSV header names kind, id or code more than once (§3.15). |
message |
invalid_kind |
A CSV kind that is none of vehicle, protection, plan, location or accessory (§3.15). |
message |
unknown_object |
kind and id name nothing among the objects you can read. One answer for every reason — it does not exist, it is another supplier's, it is withdrawn, it is not yours to read — so a refusal teaches you nothing about an object you cannot see (§3.15). |
message |
invalid_code |
The code is not 1 to 128 characters with no control character and no leading or trailing whitespace (§3.14), or it contains a character that could not be decoded as UTF-8 (a file not saved as UTF-8) (§3.15). | message |
duplicate_object |
The same object appears twice in one PUT /v1/partner-codes; an object has one code (§3.15). |
message |
duplicate_code |
After the request, two objects of one kind you can read would hold the same code; within one kind a code names one object, and message names them. Also returned, with the message "changed while this request ran", when your map (or a code your request was going to release) was changed by someone else between your read and the save: nothing was written; read with GET and send again (§3.15). |
message |
invalid |
A dry_run on PUT /v1/partner-codes that is not true or false; it is refused, never ignored (§3.15). |
message |
more_problems |
The last entry of a refused PUT /v1/partner-codes that found more than 100 problems: message says how many were left out. Fix the ones listed and send again (§3.15). |
message |
API reference
Status: Normative for shapes, and generated: every operation, object and enumeration below
is rendered into this pack from the contract shipped beside it, channel-api-v1.openapi.json, at
surface version 1.66.0 — the same document a client is generated from. Nothing here
is written by hand; a sentence that belongs in the reference lives in the contract's own
descriptions. The live copy is at https://api.caravelway.com/docs/reference; which routes a host
mounts is that host's own GET /v1/capabilities.
How to read it. This is the lookup. The concepts — how availability and quoting behave, what a
quote promises, the booking lifecycle, how the price breakdown composes — are in
CHANNEL-API.md; the first working call is in
QUICKSTART.md; errors are catalogued in ERRORS.md.
Every response wraps its payload in one envelope, stated once here and not repeated below:
{ "request_id": "req_…", "data": { … }, "meta": { … } }
The shape each response lists is what data carries. An error is the Error object, one
envelope for every failure (ERRORS.md). Where an operation publishes an example, it is the one
example the contract carries; a response without one is described by its shape alone.
Discovery — what this host serves
GET /v1/capabilities
Version, feature and limit discovery — answers without a key; send yours, which a hosted certification run needs. Read it at startup and feature-detect; never hardcode limits or versions.
- Operation:
getCapabilities· Scope: none — answers without a key; send yours, because a hosted certification run changes only responses to requests that carry it - Response headers:
Content-Language,X-Request-Id
Parameters
X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.
Responses
- 200 — OK ·
data: Capabilities
Example response (200):
{
"request_id": "req_D7s7e2PPxzyKu-vG",
"data": {
"api_version": "1.66.0",
"environment": "sandbox",
"versions": [
{
"version": "1",
"status": "current",
"sunset_at": null
}
],
"features": [
{
"code": "reference",
"enabled": true
},
{
"code": "availability",
"enabled": true
},
{
"code": "quotes",
"enabled": true
}
],
"webhooks": {
"enabled": true,
"signature": {
"tag": "v1",
"header": "X-Caravel-Signature",
"algorithm": "hmac-sha256",
"signed_payload": "{t}.{method}.{path}.{body}"
},
"event_types": [
"booking.confirmed",
"booking.modified",
"booking.cancelled",
"booking.no_show",
"booking.completed"
],
"redelivery": {
"enabled": true,
"max_events_per_request": 500,
"window_days": 84,
"retention_months": 3,
"cooldown_seconds": 60
}
},
"limits": {
"list_limit_default": 50,
"list_limit_max": 200,
"reference_set_cap": 50000,
"availability_rows_max": 500
},
"currency": "EUR",
"tax_inclusive": true,
"quote_ttl_seconds": 1800,
"idempotency_retention_hours": 24,
"forward_compatibility": {
"unknown_fields": "ignore",
"unknown_enum_members": "tolerate",
"extensible_enums": [
"reference.constants.fee_types",
"reference.constants.extra_categories",
"reference.constants.location_types"
]
},
"deprecation_notice_days": 180,
"deprecations": [
{
"code": "booking.vehicle.allocated_category",
"deprecated_at": "2026-08-22T00:00:00Z",
"sunset_at": "2027-03-01T00:00:00Z",
"note": "Read booking.vehicle.allocated_vehicle_id instead — the same group, named by the opaque vehicle_id you already hold. allocated_category keeps being served with its value unchanged until /v2."
}
]
},
"meta": {}
}
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getCapabilities — GET /v1/capabilities
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/capabilities" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getCapabilities — GET /v1/capabilities
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/capabilities`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getCapabilities — GET /v1/capabilities
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/capabilities", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /health
Liveness of the host you are pointed at, and which contract version it serves — unauthenticated, and always available. It returns three members: ok, api_version, exposed.
Deployment liveness, safe to poll and alarm on. ok: false (HTTP 503) means this host cannot reach its database or is leaving service; it says nothing about your account, your key or any route. api_version is the contract version this host runs, as on GET /v1/capabilities. exposed is false when this host has its /v1 surface switched off: its authenticated routes then answer 404 whatever the key (§1.1). Not the status page; the two can disagree (INTEGRATION-GUIDE.md §13). Internal diagnostics go to the operator only. Unlike every other path, the response is not enveloped: no data to unwrap. Members may be added; ignore what you do not recognise.
- Operation:
getHealth· Scope: none — no key needed - Response headers:
Content-Language,X-Request-Id
Parameters
X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.
Responses
- 200 — This deployment can reach its own database. · body: HealthStatus
Example response (200):
{
"ok": true,
"api_version": "1.66.0",
"exposed": true
}
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getHealth — GET /health
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/health" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getHealth — GET /health
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
if (!base) throw new Error('set CHANNEL_BASE_URL');
const res = await fetch(`${base}/health`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getHealth — GET /health
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
req = urllib.request.Request(f"{base}/health", method='GET', headers={'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Account — who you are
GET /v1/whoami
Your own account configuration — opt-ins, scopes and webhook state — as this deployment has it.
Your account as this key sees it — the first call when a response surprises you (fewer vehicles, an unexpected 403, a missing webhook). opt_ins are the per-account switches that change response shape. scopes is what this key may do: the list the server checks. per_broker_policies names the policies where your negotiated terms apply (source: "broker_terms" on GET /v1/reference/policies). webhooks.enabled is the per-account delivery gate; the webhooks feature code on GET /v1/capabilities is the deployment's, and delivery needs both. attribution_token is opaque and permanent, scoped to your relationship with this operator: pin it to prove a credential reaches the account you expect. Rate limits are reported in response headers, not here.
- Operation:
getWhoami· Scope: none — send your key; no scope is needed - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK — your account as this deployment has it configured. Cache-Control: no-store, and the response varies by API key: never cache it in a shared cache, and never key a private cache on the path alone. ·
data: Accountmetaon this response carries:suppliers_unavailable· array of string · optional Present only when this answer could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwith no entry inbooks[]: unread, not lost. The summary abovebooks[]treats that supplier as unknown. Retry to read it.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"attribution_token": "acct_0000000000000000000000000000000",
"scopes": [
"read:reference"
],
"opt_ins": {
"accepts_supplier_price": false,
"availability_duration_bounds": false,
"availability_end_terms": false,
"availability_movement_restrictions": false,
"availability_plans": true
},
"per_broker_policies": [],
"webhooks": {
"enabled": false,
"first_enabled_at": null
},
"currency": "EUR",
"api_version": {
"pinned": "1.40.0",
"latest": "1.40.0",
"upgrade_available": false
},
"distribution_channels": {
"policy": "off",
"enforcement": "refuse"
},
"books": [
{
"supplier_id": "acct_0000000000000000000000000000000",
"opt_ins": {
"accepts_supplier_price": false,
"availability_duration_bounds": false,
"availability_end_terms": false,
"availability_movement_restrictions": false,
"availability_plans": true
},
"per_broker_policies": [],
"webhooks": {
"enabled": false,
"first_enabled_at": null
},
"currency": "EUR",
"api_version": {
"pinned": "1.40.0",
"latest": "1.40.0",
"upgrade_available": false
},
"distribution_channels": {
"policy": "off",
"enforcement": "refuse"
}
}
]
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getWhoami — GET /v1/whoami
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/whoami" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getWhoami — GET /v1/whoami
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/whoami`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getWhoami — GET /v1/whoami
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/whoami", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/suppliers
The suppliers you can sell — read this first, and store the supplier_id it gives you on everything you keep.
The first call of an integration (§3.10): the suppliers this key can sell, and the source of the supplier_id carried on every record you keep from this API. One is returned today — read it as a list anyway, key your own tables on supplier_id, and hardcode neither value nor count. There is no selector of any kind: a key reads its own suppliers. The response varies by API key and is never served from a shared cache.
- Operation:
listSuppliers· Scope: none — send your key; no scope is needed - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.
Responses
- 200 — OK — the suppliers this API key can sell, one entry each. Cache-Control: no-store, and the response varies by API key: never cache it in a shared cache, and never key a private cache on the path alone. ·
data: array of Suppliermetaon this response carries:next_page· string or null · optional Always null: the collection is not paged.as_of· string · optional The instant this list was read.suppliers_unavailable· array of string · optional Present only when this answer could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idleft out ofdatabecause it was not read: it is not withdrawn, and you can still sell it. Never retire it on this answer; retry to read it.as_of_by_supplier· object · optional The instant each supplier that answered was read, keyed bysupplier_id(1.45.0), for diagnosis.
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listSuppliers — GET /v1/suppliers
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/suppliers" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listSuppliers — GET /v1/suppliers
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/suppliers`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listSuppliers — GET /v1/suppliers
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/suppliers", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/distribution-channels
Your downstream distribution channels in the operator’s register — which you may sell through, and from when.
The downstream channels the operator has registered for your account, each with its status and effective window: read it before you name distribution_channel.code on a booking. Only an approved channel inside its window may sell; your agreement’s policy on GET /v1/whoami says whether a booking must name one. Paged and in-loop: follow meta.next_page, then store meta.as_of as your next updated_since. The response varies by API key and is never served from a shared cache.
- Operation:
listDistributionChannels· Scope: none — send your key; no scope is needed - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Page size (default 50, max 200).page_token(query) · string · optional Opaque, endpoint-specific cursor from meta.next_page — pass back verbatim, never construct (§1.9). A token from a different endpoint is rejected 422. When present, updated_since is ignored.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK — a page of your register entries. Cache-Control: no-store, and the response varies by API key. ·
data: array of DistributionChannelmetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.46.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.46.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_0000000000000000000000000000000",
"distribution_channel_id": "11111111-2222-4333-8444-555555555555",
"code": "partner-channel-de",
"name": "Partner Channel DE",
"legal_name": null,
"website": null,
"markets": [
"AT",
"DE"
],
"status": "approved",
"effective_from": "2026-10-01T00:00:00.000000Z",
"effective_until": null,
"approved_at": "2026-09-30T08:00:00.000000Z",
"updated_at": "2026-09-30T08:00:00.000000+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-30T08:05:00.000000+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listDistributionChannels — GET /v1/distribution-channels
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/distribution-channels" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listDistributionChannels — GET /v1/distribution-channels
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/distribution-channels`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listDistributionChannels — GET /v1/distribution-channels
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/distribution-channels", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Reference data — the catalogue
GET /v1/reference/constants
The enum and code lists this API uses, machine-readable (§8).
Fetch them rather than hardcoding — change is additive within /v1, and forward_compatibility.extensible_enums (§3.0) names the sets a strict decoder should hold open.
- Operation:
getReferenceConstants· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: Constants - 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"booking_statuses": [
"pending"
],
"fee_types": [
"young_driver"
],
"extra_categories": [
"child_seat"
],
"location_types": [
"airport"
],
"payable_timings": [
"prepaid"
],
"protection_tiers": [
"basic"
],
"protection_coverage_codes": [
"administration_fee"
],
"restriction_codes": [
"commercial_passenger_transport"
],
"international_permit_scopes": [
"all_foreign_licences"
],
"fuel_policies": [
"full_to_full"
],
"transmissions": [
"manual"
],
"fuels": [
"petrol"
],
"body_styles": [
"hatchback"
],
"electric_range_bases": [
"wltp"
],
"mileage_types": [
"unlimited"
],
"currencies": [
"EUR"
],
"locales": [
"en"
],
"acriss": {
"category": {
"M": "Mini",
"N": "Mini Elite",
"E": "Economy",
"H": "Economy Elite",
"C": "Compact",
"D": "Compact Elite",
"I": "Intermediate",
"J": "Intermediate Elite",
"S": "Standard",
"R": "Standard Elite",
"F": "Fullsize",
"G": "Fullsize Elite",
"P": "Premium",
"U": "Premium Elite",
"L": "Luxury",
"W": "Luxury Elite",
"O": "Oversize",
"X": "Special"
},
"type": {
"B": "2/3 Door",
"C": "2/4 Door",
"D": "4/5 Door",
"W": "Wagon/Estate",
"V": "Passenger Van",
"L": "Sedan/Limousine",
"S": "Sport",
"T": "Convertible",
"F": "SUV",
"J": "Open Air All Terrain",
"X": "Special",
"P": "Pickup (single/extended cab) 2 door",
"Q": "Pickup (double cab) 4 door",
"Z": "Special Offer Car",
"E": "Coupe",
"M": "Monospace",
"R": "Recreational Vehicle",
"H": "Motor Home",
"Y": "2 Wheel Vehicle",
"N": "Roadster",
"G": "Crossover",
"K": "Commercial Van/Truck"
},
"transmission_drive": {
"M": {
"transmission": "Manual",
"drive": "Unspecified drive"
},
"N": {
"transmission": "Manual",
"drive": "4WD"
},
"C": {
"transmission": "Manual",
"drive": "AWD"
},
"A": {
"transmission": "Automatic",
"drive": "Unspecified drive"
},
"B": {
"transmission": "Automatic",
"drive": "4WD"
},
"D": {
"transmission": "Automatic",
"drive": "AWD"
}
},
"fuel_air": {
"R": "Petrol with Air Conditioning",
"N": "Petrol no Air Conditioning",
"D": "Diesel with Air Conditioning",
"Q": "Diesel no Air Conditioning",
"H": "Hybrid with Air Conditioning",
"I": "Hybrid Plug in with Air Conditioning",
"E": "Electric with Air Conditioning",
"C": "Electric (distance ≥ 250mi/400km) with Air Conditioning",
"L": "LPG/Compressed Gas with Air Conditioning",
"S": "LPG/Compressed Gas no Air Conditioning",
"A": "Hydrogen with Air Conditioning",
"B": "Hydrogen no Air Conditioning",
"M": "Multi Fuel with Air Conditioning",
"F": "Multi Fuel no Air Conditioning",
"V": "Petrol with Air Conditioning",
"Z": "Petrol no Air Conditioning",
"U": "Ethanol with Air Conditioning",
"X": "Ethanol no Air Conditioning"
}
}
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getReferenceConstants — GET /v1/reference/constants
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/constants" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getReferenceConstants — GET /v1/reference/constants
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/constants`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getReferenceConstants — GET /v1/reference/constants
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/constants", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/locations
Pickup/dropoff stations with stable location_id (the IDs partners map to).
This is the operator's current selling estate: a desk the operator closes (archives/deactivates) leaves this list, and its location_id is then refused at availability, quote and booking exactly like an id that never existed. A re-opened desk returns under the same location_id. Re-sync this list rather than assuming membership is permanent (§3.2).
- Operation:
listReferenceLocations· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Accepted and validated as on the paginating lists (an integer outside 1–200 is422), but it does not narrow this collection: a full refresh is served in one page, and meta.next_page is always null. Omit it; it exists so one client can call every list the same way (§1.9, §3).page_token(query) · string · optional Accepted for uniformity and validated (a malformed or foreign token is 422), but this collection never issues one: meta.next_page is always null, so there is nothing to pass back. Omit it.changed_since(query) · string · optional Incremental sync for the five full-refresh catalogues:/v1/reference/locations,/vehicles,/extras,/protectionsand/fees. A full ISO-8601 datetime with an explicit offset; a date or an offsetless value is refused422. Only records whoserow_updated_atis at or after it are returned. Pass back the response'smeta.changed_as_of, never your own clock. An absent record is unchanged, not withdrawn: upsert what arrives, delete nothing, and see removals on a full refresh you run on your own schedule. Anywhere else it is refused422unknown_parameter; policies and bookings narrow withupdated_sinceinstead.iata(query) · string · optional Only the desks at this airport (1.47.0) — the desks a search withpickup_iataprices. A narrowed page: a desk absent from it is not withdrawn.city(query) · string · optional Only the desks in this city, matched regardless of case and spacing (1.47.0). A narrowed page: a desk absent from it is not withdrawn.country_iso2(query) · string · optional Only the desks in this country, as an ISO 3166-1 alpha-2 code (1.47.0). A narrowed page: a desk absent from it is not withdrawn.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Locationmetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.changed_as_of· string · optional The watermark to replay as your nextchanged_since— present on every list response, and the only value to store for that purpose. Unlikeas_of, it is a short lap behind the instant this page was read: a write that began before the page was built and committed after it carries a stamp earlier thanas_of, so replayingas_ofwould skip it for good. Store it only after followingnext_pageto the end, as foras_of; on these five catalogues there is never a second page.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.45.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.changed_as_of_by_supplier· object · optional Thechanged_as_ofof each supplier that answered, keyed bysupplier_id(1.45.0). Same rule asas_of_by_supplier: replay the top-levelchanged_as_of, never one of these.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
- 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"location_id": "1",
"code": null,
"type": "airport",
"name": "Sandbox Airport",
"address": {
"city": "Sandboxville",
"region": "Sandbox",
"postal_code": "00000"
},
"geo": null,
"timezone": "Europe/Athens",
"hours": null,
"out_of_hours": {
"available": true,
"fee": {
"amount": 3000,
"currency": "EUR"
}
},
"updated_at": "2026-09-07T06:55:19.437747+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"location_id": "2",
"code": null,
"type": "city",
"name": "Sandbox Downtown",
"address": {
"city": "Sandboxville",
"region": "Sandbox",
"postal_code": "00000"
},
"geo": null,
"timezone": "Europe/Athens",
"hours": null,
"out_of_hours": {
"available": true,
"fee": {
"amount": 3000,
"currency": "EUR"
}
},
"updated_at": "2026-09-07T06:55:19.437747+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:19.437747+00:00",
"changed_as_of": "2026-09-07T06:54:19.437747+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceLocations — GET /v1/reference/locations
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/locations" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceLocations — GET /v1/reference/locations
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/locations`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceLocations — GET /v1/reference/locations
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/locations", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/vehicles
Fleet catalogue as vehicle groups (static; prices come from availability/quote).
- Operation:
listReferenceVehicles· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Accepted and validated as on the paginating lists (an integer outside 1–200 is422), but it does not narrow this collection: a full refresh is served in one page, and meta.next_page is always null. Omit it; it exists so one client can call every list the same way (§1.9, §3).page_token(query) · string · optional Accepted for uniformity and validated (a malformed or foreign token is 422), but this collection never issues one: meta.next_page is always null, so there is nothing to pass back. Omit it.changed_since(query) · string · optional Incremental sync for the five full-refresh catalogues:/v1/reference/locations,/vehicles,/extras,/protectionsand/fees. A full ISO-8601 datetime with an explicit offset; a date or an offsetless value is refused422. Only records whoserow_updated_atis at or after it are returned. Pass back the response'smeta.changed_as_of, never your own clock. An absent record is unchanged, not withdrawn: upsert what arrives, delete nothing, and see removals on a full refresh you run on your own schedule. Anywhere else it is refused422unknown_parameter; policies and bookings narrow withupdated_sinceinstead.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Vehiclemetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.changed_as_of· string · optional The watermark to replay as your nextchanged_since— present on every list response, and the only value to store for that purpose. Unlikeas_of, it is a short lap behind the instant this page was read: a write that began before the page was built and committed after it carries a stamp earlier thanas_of, so replayingas_ofwould skip it for good. Store it only after followingnext_pageto the end, as foras_of; on these five catalogues there is never a second page.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.45.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.changed_as_of_by_supplier· object · optional Thechanged_as_ofof each supplier that answered, keyed bysupplier_id(1.45.0). Same rule asas_of_by_supplier: replay the top-levelchanged_as_of, never one of these.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
- 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"vehicle_id": "veh_f8a3f2dd19f8",
"acriss": "CCMR",
"acriss_decoded": {
"category": "Compact",
"type": "2/4 Door",
"transmission": "Manual",
"drive": "Unspecified drive",
"fuel_air": "Petrol with Air Conditioning"
},
"name": "Sandbox Compact (or similar)",
"display_name": "Sandbox Compact (or similar)",
"min_driver_age": 21,
"max_driver_age": 99,
"seats": 5,
"doors": 4,
"large_bags": 2,
"small_bags": 1,
"transmission": "manual",
"fuel": "petrol",
"air_conditioning": true,
"example_models": [
"Sandbox Sedan"
],
"images": [
{
"url": "https://images.example.com/fleet/comp-hero.svg",
"role": "hero"
}
],
"updated_at": "2026-09-07T06:55:20.490803+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"vehicle_id": "veh_2a5bcba25e8b",
"acriss": "ECMR",
"acriss_decoded": {
"category": "Economy",
"type": "2/4 Door",
"transmission": "Manual",
"drive": "Unspecified drive",
"fuel_air": "Petrol with Air Conditioning"
},
"name": "Sandbox Economy (or similar)",
"display_name": "Sandbox Economy (or similar)",
"min_driver_age": 21,
"max_driver_age": 99,
"seats": 5,
"doors": 4,
"large_bags": 1,
"small_bags": 1,
"transmission": "manual",
"fuel": "petrol",
"air_conditioning": true,
"example_models": [
"Sandbox Hatchback"
],
"images": [
{
"url": "https://images.example.com/fleet/econ-hero.svg",
"role": "hero"
}
],
"updated_at": "2026-09-07T06:55:20.490803+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:20.490803+00:00",
"changed_as_of": "2026-09-07T06:54:20.490803+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceVehicles — GET /v1/reference/vehicles
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/vehicles" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceVehicles — GET /v1/reference/vehicles
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/vehicles`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceVehicles — GET /v1/reference/vehicles
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/vehicles", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/extras
Extras catalogue with pricing type, unit price, caps, and quantity rules.
- Operation:
listReferenceExtras· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Accepted and validated as on the paginating lists (an integer outside 1–200 is422), but it does not narrow this collection: a full refresh is served in one page, and meta.next_page is always null. Omit it; it exists so one client can call every list the same way (§1.9, §3).page_token(query) · string · optional Accepted for uniformity and validated (a malformed or foreign token is 422), but this collection never issues one: meta.next_page is always null, so there is nothing to pass back. Omit it.changed_since(query) · string · optional Incremental sync for the five full-refresh catalogues:/v1/reference/locations,/vehicles,/extras,/protectionsand/fees. A full ISO-8601 datetime with an explicit offset; a date or an offsetless value is refused422. Only records whoserow_updated_atis at or after it are returned. Pass back the response'smeta.changed_as_of, never your own clock. An absent record is unchanged, not withdrawn: upsert what arrives, delete nothing, and see removals on a full refresh you run on your own schedule. Anywhere else it is refused422unknown_parameter; policies and bookings narrow withupdated_sinceinstead.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Extrametaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.changed_as_of· string · optional The watermark to replay as your nextchanged_since— present on every list response, and the only value to store for that purpose. Unlikeas_of, it is a short lap behind the instant this page was read: a write that began before the page was built and committed after it carries a stamp earlier thanas_of, so replayingas_ofwould skip it for good. Store it only after followingnext_pageto the end, as foras_of; on these five catalogues there is never a second page.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.45.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.changed_as_of_by_supplier· object · optional Thechanged_as_ofof each supplier that answered, keyed bysupplier_id(1.45.0). Same rule asas_of_by_supplier: replay the top-levelchanged_as_of, never one of these.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
- 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"extra_id": "extra_1",
"code": "extra_1",
"supplier_code": "CHILD",
"ota_code": "7",
"name": "Child seat",
"description": null,
"category": "other",
"pricing_type": "per_day",
"unit_price": {
"amount": 500,
"currency": "EUR"
},
"price_cap": null,
"quantity": {
"min": 1,
"max": 1,
"default": 1
},
"requires_quantity": false,
"mutually_exclusive_group": null,
"payable": "at_pickup",
"tax_rate": 0.24,
"active": true,
"updated_at": "2026-09-07T06:55:20.994826+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"extra_id": "extra_2",
"code": "extra_2",
"supplier_code": "ADDDRV",
"ota_code": null,
"name": "Additional driver",
"description": null,
"category": "other",
"pricing_type": "per_day",
"unit_price": {
"amount": 0,
"currency": "EUR"
},
"price_cap": null,
"quantity": {
"min": 1,
"max": 1,
"default": 1
},
"requires_quantity": false,
"mutually_exclusive_group": null,
"payable": "at_pickup",
"tax_rate": 0.24,
"active": true,
"updated_at": "2026-09-07T06:55:20.994826+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:20.994826+00:00",
"changed_as_of": "2026-09-07T06:54:20.994826+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceExtras — GET /v1/reference/extras
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/extras" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceExtras — GET /v1/reference/extras
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/extras`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceExtras — GET /v1/reference/extras
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/extras", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/protections
Protection tiers with excess and deposit per tier.
- Operation:
listReferenceProtections· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Accepted and validated as on the paginating lists (an integer outside 1–200 is422), but it does not narrow this collection: a full refresh is served in one page, and meta.next_page is always null. Omit it; it exists so one client can call every list the same way (§1.9, §3).page_token(query) · string · optional Accepted for uniformity and validated (a malformed or foreign token is 422), but this collection never issues one: meta.next_page is always null, so there is nothing to pass back. Omit it.changed_since(query) · string · optional Incremental sync for the five full-refresh catalogues:/v1/reference/locations,/vehicles,/extras,/protectionsand/fees. A full ISO-8601 datetime with an explicit offset; a date or an offsetless value is refused422. Only records whoserow_updated_atis at or after it are returned. Pass back the response'smeta.changed_as_of, never your own clock. An absent record is unchanged, not withdrawn: upsert what arrives, delete nothing, and see removals on a full refresh you run on your own schedule. Anywhere else it is refused422unknown_parameter; policies and bookings narrow withupdated_sinceinstead.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Protectionmetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.changed_as_of· string · optional The watermark to replay as your nextchanged_since— present on every list response, and the only value to store for that purpose. Unlikeas_of, it is a short lap behind the instant this page was read: a write that began before the page was built and committed after it carries a stamp earlier thanas_of, so replayingas_ofwould skip it for good. Store it only after followingnext_pageto the end, as foras_of; on these five catalogues there is never a second page.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.45.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.changed_as_of_by_supplier· object · optional Thechanged_as_ofof each supplier that answered, keyed bysupplier_id(1.45.0). Same rule asas_of_by_supplier: replay the top-levelchanged_as_of, never one of these.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
- 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"protection_id": "prot_0e122578cb56",
"vehicle_id": "veh_f8a3f2dd19f8",
"code": "INS_BASIC",
"name": "Basic insurance",
"tier": "basic",
"description": null,
"excess": {
"amount": 140000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"price": {
"amount": 0,
"currency": "EUR"
},
"pricing_type": "included",
"payable": "at_pickup",
"active": true,
"updated_at": "2026-09-07T06:55:21.386147+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"protection_id": "prot_5be288b7e040",
"vehicle_id": "veh_f8a3f2dd19f8",
"code": "INS_MEDIUM",
"name": "Medium insurance",
"tier": "medium",
"description": null,
"excess": {
"amount": 40000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"price": {
"amount": 1200,
"currency": "EUR"
},
"pricing_type": "per_day",
"payable": "at_pickup",
"active": true,
"updated_at": "2026-09-07T06:55:21.386147+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:21.386147+00:00",
"changed_as_of": "2026-09-07T06:54:21.386147+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceProtections — GET /v1/reference/protections
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/protections" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceProtections — GET /v1/reference/protections
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/protections`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceProtections — GET /v1/reference/protections
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/protections", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/fees
Conditional surcharge rules; applied amounts always come from pricing responses.
- Operation:
listReferenceFees· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Accepted and validated as on the paginating lists (an integer outside 1–200 is422), but it does not narrow this collection: a full refresh is served in one page, and meta.next_page is always null. Omit it; it exists so one client can call every list the same way (§1.9, §3).page_token(query) · string · optional Accepted for uniformity and validated (a malformed or foreign token is 422), but this collection never issues one: meta.next_page is always null, so there is nothing to pass back. Omit it.changed_since(query) · string · optional Incremental sync for the five full-refresh catalogues:/v1/reference/locations,/vehicles,/extras,/protectionsand/fees. A full ISO-8601 datetime with an explicit offset; a date or an offsetless value is refused422. Only records whoserow_updated_atis at or after it are returned. Pass back the response'smeta.changed_as_of, never your own clock. An absent record is unchanged, not withdrawn: upsert what arrives, delete nothing, and see removals on a full refresh you run on your own schedule. Anywhere else it is refused422unknown_parameter; policies and bookings narrow withupdated_sinceinstead.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Feemetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.changed_as_of· string · optional The watermark to replay as your nextchanged_since— present on every list response, and the only value to store for that purpose. Unlikeas_of, it is a short lap behind the instant this page was read: a write that began before the page was built and committed after it carries a stamp earlier thanas_of, so replayingas_ofwould skip it for good. Store it only after followingnext_pageto the end, as foras_of; on these five catalogues there is never a second page.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.45.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.changed_as_of_by_supplier· object · optional Thechanged_as_ofof each supplier that answered, keyed bysupplier_id(1.45.0). Same rule asas_of_by_supplier: replay the top-levelchanged_as_of, never one of these.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
- 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"fee_id": "fee_1a163c041d4e",
"code": "YOUNG_DRIVER",
"type": "young_driver",
"name": "Young driver surcharge",
"applies_when": {
"driver_age_between": [
16
]
},
"pricing_type": "per_day",
"amount": {
"amount": 1200,
"currency": "EUR"
},
"cap": null,
"payable": "at_pickup",
"taxable": true,
"active": true,
"updated_at": "2026-09-07T06:55:21.722267+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"fee_id": "fee_4646165d184f",
"code": "ONE_WAY",
"type": "one_way",
"name": "One-way fee",
"applies_when": {
"pickup_location_id": "1",
"dropoff_location_id": "2"
},
"pricing_type": "per_rental",
"amount": {
"amount": 2500,
"currency": "EUR"
},
"cap": null,
"payable": "at_pickup",
"taxable": true,
"active": true,
"updated_at": "2026-09-07T06:55:21.722267+00:00",
"row_updated_at": "2026-09-07T06:54:23.577657+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:21.722267+00:00",
"changed_as_of": "2026-09-07T06:54:21.722267+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceFees — GET /v1/reference/fees
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/fees" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceFees — GET /v1/reference/fees
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/fees`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceFees — GET /v1/reference/fees
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/fees", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/rates
Rate cards by supplier, group, period, plan and station set — a snapshot you replace (§3.12).
The base rates a quote prices from, read from the same ruleset, so a row agrees with a quote for the same plan, station and channel for every length the supplier's rental-length limits admit (rental_days). Replace your rows per supplier_id (and per plan and distribution_channel when you narrow the read) from each snapshot. It is not an offer: the quote and the booking decide.
- Operation:
listReferenceRates· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.plan(query) · string —basic·medium·premium·no_deposit_with_excess· optional Only the rates sold with this protection plan. Omit it for every plan your account holds.distribution_channel(query) · string · optional Your code for the downstream channel to read rates for, judged as on a search. A supplier that refuses it is left out and named inmeta.suppliers_unavailable(keep the rows you hold for it); the read is refused only when every supplier that answered refused, naming the supplier.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Ratemetaon this response carries:next_page· string or null · optional Always null: this read is one snapshot and is never paged.as_of· string · optional The instant this snapshot was read. It is not a watermark: nothing narrows on it.collection· object · optional How to consume this collection: replace your rows for eachsupplier_idfrom each snapshot, because a row has no id and a station or period that is no longer served leaves by disappearing.semantics· unspecified —snapshot· optional Alwayssnapshot.replace_by· unspecified —supplier_id· optional The key you replace by.scope· object · optional The slice this snapshot is OF: replace, per supplier, only the rows of the same slice. A read narrowed byplanordistribution_channelis not the whole card, and replacing a supplier's table from it would delete the rest.plan· string or null · optional Theplanyou asked for, or null for every plan.distribution_channel· string or null · optional Thedistribution_channelyou asked for, or null for none.
pricing_by_supplier· object · optional What you need to reproduce a total from a row, keyed bysupplier_idbecause billing rules differ by supplier.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional The instant each supplier's snapshot was read, keyed bysupplier_id, for diagnosis. This read has no watermark: nothing resumes from it.
- 304 — Not modified. · body: unspecified
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceRates — GET /v1/reference/rates
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/rates" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceRates — GET /v1/reference/rates
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/rates`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceRates — GET /v1/reference/rates
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/rates", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/stop-sales
The days a vehicle group is closed, at every station — a snapshot you replace (§3.13).
The days a vehicle group you can sell is closed, because the operator stopped it (manual) or because no unit is left (sold_out): a rental whose pickup date is on or before end and whose dropoff date is after start is refused. It is a list of closures and not of availability: a day that is not listed can still be refused. Replace your rows per supplier_id from each snapshot. It takes no parameter.
- Operation:
listReferenceStopSales· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of StopSalemetaon this response carries:next_page· string or null · optional Always null: this read is one snapshot and is never paged.as_of· string · optional The instant this snapshot was read. It is not a watermark: nothing narrows on it.collection· object · optional How to consume this collection: replace your rows for eachsupplier_idfrom each snapshot, because a row has no id and a stop that has been lifted leaves by disappearing.semantics· unspecified —snapshot· optional Alwayssnapshot.replace_by· unspecified —supplier_id· optional The key you replace by.
rules_by_supplier· object · optional How to read a row, keyed bysupplier_id. The keys are the suppliers this snapshot read: a supplier here with no rows has nothing closed.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional The instant each supplier's snapshot was read, keyed bysupplier_id, for diagnosis. This read has no watermark: nothing resumes from it.
- 304 — Not modified. · body: unspecified
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceStopSales — GET /v1/reference/stop-sales
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/stop-sales" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceStopSales — GET /v1/reference/stop-sales
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/stop-sales`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceStopSales — GET /v1/reference/stop-sales
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/stop-sales", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/policies
Default mileage/fuel/cancellation policies and rental-length limits.
- Operation:
listReferencePolicies· Scope:read:reference - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Page size (default 50, max 200).page_token(query) · string · optional Opaque, endpoint-specific cursor from meta.next_page — pass back verbatim, never construct (§1.9). A token from a different endpoint is rejected 422. When present, updated_since is ignored.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of Policymetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.46.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.46.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"policy_id": "b4ac5f2a-026f-458b-97a5-f25fc6697560",
"mileage": {
"type": "unlimited",
"source": "operator_default"
},
"fuel": {
"type": "full_to_full",
"source": "operator_default"
},
"cancellation": {
"type": "free_until",
"free_until_hours_before_pickup": 0,
"fee_basis": "grand_total",
"source": "operator_default"
},
"min_rental_days": 1,
"max_rental_days": 365,
"deposit_payment_methods": [],
"tax_inclusive": true,
"updated_at": "2026-07-20T11:41:06.470743+00:00"
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:54:22.140375+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferencePolicies — GET /v1/reference/policies
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/policies" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferencePolicies — GET /v1/reference/policies
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/policies`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferencePolicies — GET /v1/reference/policies
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/policies", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/terms
The supplier's terms and conditions, as documents you can retrieve, store and cite — the CURRENT one per language.
The supplier's current terms documents. You are the trader your customer contracts with, so you need them before the order, and proof later of which version you showed: document_id names a document whose bytes never change, content_hash proves them, and version is the supplier's number as printed on the contract. Bodies are not here — fetch each once from GET /v1/reference/terms/{document_id} and store it. A full refresh: re-sync and compare document_id, because a change always mints a new id. Only current documents are listed (withdrawn_at is always null here); a superseded one stays retrievable by id.
- Operation:
listReferenceTermsDocuments· Scope:read:reference - Response headers:
Cache-Control,Content-Language,ETag,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Accepted and validated as on the paginating lists (an integer outside 1–200 is422), but it does not narrow this collection: a full refresh is served in one page, and meta.next_page is always null. Omit it; it exists so one client can call every list the same way (§1.9, §3).page_token(query) · string · optional Accepted for uniformity and validated (a malformed or foreign token is 422), but this collection never issues one: meta.next_page is always null, so there is nothing to pass back. Omit it.If-None-Match(header) · string · optional TheETagof the copy you hold (§1.10a). If the page is unchanged you get304and no body. Only the full-refresh reads honour it.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of TermsDocumentmetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.45.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.45.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
- 304 — Not modified. · body: unspecified
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:22.476935+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listReferenceTermsDocuments — GET /v1/reference/terms
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/terms" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listReferenceTermsDocuments — GET /v1/reference/terms
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/terms`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listReferenceTermsDocuments — GET /v1/reference/terms
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/terms", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/reference/terms/{document_id}
One terms document, WITH its body — current, superseded or withdrawn.
This is the retrieval that makes the id you stored worth storing: it keeps answering after the document stops being the current one, so a rental you sold in March can still be evidenced against the exact text the customer was shown. withdrawn_at tells you which state it is in — null means it is still the current document for its language, a timestamp means it must not be presented as the terms of a new sale. The bytes never change for an id, so fetch once and cache; content_hash is the SHA-256 to check them against.
- Operation:
getReferenceTermsDocument· Scope:read:reference - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.document_id(path) · string · required Thedocument_idfrom GET /v1/reference/terms, or one you stored earlier against a booking you sold. Opaque — do not construct one.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: TermsDocumentDetail
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getReferenceTermsDocument — GET /v1/reference/terms/{document_id}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/reference/terms/50000000-0000-4000-8000-0000000000d1" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getReferenceTermsDocument — GET /v1/reference/terms/{document_id}
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/reference/terms/50000000-0000-4000-8000-0000000000d1`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getReferenceTermsDocument — GET /v1/reference/terms/{document_id}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/reference/terms/50000000-0000-4000-8000-0000000000d1", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Availability
GET /v1/availability
Search what is available for a route and date window, with an indicative "from" price per vehicle group.
The first call of search → quote → book. Returns the vehicle groups sellable for a route and date window, one row per group per price plan, each indicative (requires_quote: true); POST /v1/quote makes a selection firm. Counts are fleet-wide for the window: the locations you pass are validated and priced, and do not partition inventory. Each row carries what a results card renders — the cover behind the price, the §6 breakdown, the protection ladder with window totals, the vehicle description — and meta.withheld explains a short page (§4.1).
- Operation:
getAvailability· Scope:read:availability - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.pickup_location_id(query) · string · optional The desk to collect from. Required unless the search names a place instead, withpickup_iataorpickup_cityandpickup_country_iso2(1.47.0); the two forms cannot be combined.dropoff_location_id(query) · string · optional The desk to return to. Required withpickup_location_id, and not sent on a search that names a place.pickup(query) · string · optional Offset-aware ISO instant; or pass pickup_date + pickup_time.dropoff(query) · string · optional Offset-aware ISO instant; or pass dropoff_date + dropoff_time.pickup_date(query) · string · optionalpickup_time(query) · string · optionaldropoff_date(query) · string · optionaldropoff_time(query) · string · optionalcurrency(query) · string · optionaldriver_age(query) · integer · optional The main driver's age in whole years. Send it and the price is computed for that driver: an age surcharge is insideprice, as afeeline inline_items, and a group whose age band excludes the driver is withheld — so an age can return fewer groups. Omit it and each group is priced for a representative age of 30, clamped into its age band; that age sits outside every current age-fee band, so the price is a true "from" floor.meta.driver_ageechoes the value you sent.cross_border(query) · string —true·false· optional Your customer's declared intent to take the vehicle out of the country of hire, so the search prices it. Optional; absent is not a declaration. The literaltrueorfalseonly — any other spelling is422validation_error— and an empty?cross_border=reads as absent, neverfalse. It changespriceonly where the operator configures across_borderfee, and is not a one-way. Carry it toPOST /v1/quoteandPOST /v1/bookingsas a boolean; contract §2.2 has the rest.pickup_iata(query) · string · optional Search a place instead of a desk: every desk at this airport, of every supplier your key reaches (1.47.0). Each row then names the desks it prices. Exclusive withpickup_location_idand withpickup_city.pickup_city(query) · string · optional Search every desk in this city, of every supplier your key reaches (1.47.0). Needspickup_country_iso2; matched regardless of case and spacing.pickup_country_iso2(query) · string · optional The country ofpickup_city, as an ISO 3166-1 alpha-2 code.dropoff_iata(query) · string · optional Return at this airport rather than to the pickup desk (1.47.0): each pickup desk is paired with every desk of the same supplier here. Without a dropoff place, each desk returns to itself.dropoff_city(query) · string · optional Return in this city rather than to the pickup desk (1.47.0). Needsdropoff_country_iso2.dropoff_country_iso2(query) · string · optional The country ofdropoff_city, as an ISO 3166-1 alpha-2 code.distribution_channel(query) · string · optional Your code for the downstream channel this search prices for, the value a booking sends asdistribution_channel.code(since 1.54.0). It is judged as a booking naming it is: by your agreement’s policy on GET /v1/whoami, and refused422 distribution_channel_not_approvedwhen the operator has not approved it for today (or served and flagged, where your agreement says so). Rows are priced for that channel, andmeta.distribution_channelechoes it. Searching by place, each supplier judges it (meta.suppliers).X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK — available categories with an indicative price, the cover behind it, its line items, and (in meta) the window-priced extras and the partner-effective policies. Cache-Control: no-store. ·
data: array of AvailabilityItemmetaon this response carries:as_of· string · required Server snapshot time of the counts and prices, ISO-8601 with an explicit offset. Availability is not in the incremental-sync loop, so this is a freshness stamp for what you are showing a customer rather than a cursor to pass back —available_unitscan have moved since.unsellable_window· object · optional Present (1.5.0, with the duration opt-in — off by default) when the searched window falls outside the published min_rental_days/max_rental_days;datais then empty, because every vehicle would be refused at quote. Carries the detail object POST /v1/quote raises for that window (field, issue, min, max, value), so one renderer serves both. Absent when the window is sellable, and when nothing was available at all — absence never asserts the window is fine.field· string · optional Which input to change — the query parameter naming the offending end of the window, in the vocabulary of the request you sent.issue· string · optional Why the window was refused, as a machine-readable string — the same value POST /v1/quote raises for the same window, so one error vocabulary serves both surfaces. Treat one you do not recognise as 'refused, reason unknown to me' rather than failing the parse: this vocabulary is per-surface and is not covered by the extensible-enum guarantee.min· integer · optional The operator's minimum rental length in billed days, as published on GET /v1/reference/policies. Show it as the shortest window you can sell.max· integer · optional The operator's maximum rental length in billed days, as published on GET /v1/reference/policies.value· integer · optional The billed day count WE computed for the window you searched (§6.2) — an integer, and deliberately not a date subtraction of your own, which can differ. Compare it againstmin/maxto explain the refusal.
unsellable_ends· array of object · optional Present only with the end-terms opt-in (off by default;opt_ins.availability_end_termsonGET /v1/whoami), when an end of the searched rental is one this operator does not handle;datais then empty. One entry per offending end, pickup first: the detail objectPOST /v1/quoteraises —issue(out_of_hours_not_offeredordelivery_not_offered),location_id, and for an out-of-hours end the instant you sent (value) with the desk's local reading (local_weekday,local_time), safe to show a customer.fieldnames the parameter to change. Absent proves nothing: without the opt-in it never appears, and an end outside the priced catalogue is skipped (§4.1).field· string · optional Which input to change — a query parameter of the search you sent (the end, or that end's location). This is the one member that differs from the quote 422 by design: quote names its body member, this names the parameter you can change, so compare the two objects ignoring this field.issue· string · optional Which of the two location-level refusals applies: the out-of-hours one where the end falls outside that desk's own operating window and its operator does not perform out-of-hours handovers there, or the delivery one where the operator prices a delivery at that location and does not deliver there. The same string POST /v1/quote raises. Tolerate one you do not recognise.location_id· string · optional The station this end names — thelocation_idyou sent, so you can look up its publishedhours,timezone,out_of_hoursanddeliveryon GET /v1/reference/locations and pre-filter the next search.value· string · optional Out-of-hours ends only: the handover instant you sent, echoed back as an ISO-8601 string. A string here and an integer onunsellable_window.value— the two members are deliberately separate objects for exactly this reason, because retyping a published property is the breaking change §1.2 forbids.local_weekday· string · optional Out-of-hours ends only: the weekday the refusal was judged at, in the desk's own published timezone. Safe to show a customer: it is the reading the refusal was actually made against, so it cannot disagree with it — unlike a derivation of your own, whose catalogue copy may be stale.local_time· string · optional Out-of-hours ends only: the local HH:MM the refusal was judged at, in the desk's own published timezone. Prefer this pair to deriving the time yourself, for the reason onlocal_weekday.
unservable_currency· object · optional Present when you sent acurrencythis deployment does not price in (?currency=GBPon a EUR deployment);datais then empty andPOST /v1/quoteanswers422unsupported_currency, status still200. Carries the engine's detail object (field,issue,value) andsupported, your supplier's currency — alsocurrencyonGET /v1/whoami, per key. All four members are always present. Absent when you sent no currency, or the matching one.field· string · optional Which input to change — the query parameter that carried the currency.issue· string · optional Why the currency was refused, as a machine-readable string — the same value POST /v1/quote raises for the same selection, so one error vocabulary serves both surfaces. Tolerate one you do not recognise.value· string · optional The currency you sent, echoed back as an ISO 4217 alpha-3 code — so a log line shows what was asked for as well as what is served.supported· string · optional Your supplier's currency, an ISO 4217 alpha-3 code — alsocurrencyon GET /v1/whoami, per key: pin it and this never appears.
unsellable_movement· object · optional Present, with the movement opt-in (off by default), when this operator does not serve the movement between the two locations searched;datais then empty. It is the detail objectPOST /v1/quoteraises (field,issue,pickup_location_id,dropoff_location_id,policy). The pair is directed: the ordered combination is refused, not either desk — the reverse may be served and a same-location rental never is.policydecides the remedy. Absence proves nothing: it is also absent without the opt-in, for a location outside the priced catalogue, or when no policy is published. Pre-filter againstmovement_restrictionsonGET /v1/reference/policies;meta.withheldcounts it asmovement_not_permitted.field· string · optional Which input to change — a query parameter of the search you sent. It names the dropoff because that is usually the cheaper half to move (a customer collecting at an airport rarely wants to collect elsewhere), not because the pickup is fine: either end can be changed, and both are given below so you can choose. This is the one member that differs from the quote 422 by design: quote names its body member, this names the parameter you can change, so compare the two objects ignoring this field.issue· string · optional Why the movement was refused, as a machine-readable string — the same value POST /v1/quote raises for the same pair, so one error vocabulary serves both surfaces. Treat one you do not recognise as 'refused, reason unknown to me' rather than failing the parse: this vocabulary is per-surface and is not covered by the extensible-enum guarantee.pickup_location_id· string · optional The collection location of the refused movement, echoed back — the id you sent. Listed first, matching the pickup-before-dropoff convention every other detail member on this API follows.dropoff_location_id· string · optional The return location of the refused movement, echoed back — the id you sent.policy· string · optional Which rule refused you; the two values call for opposite next steps.forbidden_pairs: the operator serves movements generally and does not serve this one — try another return desk; most will work.permitted_pairs_only: it serves only the movements it lists — choose frommovement_restrictions.permitted_pairsonGET /v1/reference/policies, or search a same-desk rental, which is always permitted. For an unknown value, message as forforbidden_pairsbut route from the published policy.
withheld· array of object · optional Why vehicle groups are not indata: one entry per reason, with the number of groups withheld for it. Always present;data: []withwithheld: []means this supplier has nothing sellable for your search, rather than something suppressed. When a catalogue loaded,data.lengthplus the counts equals the partner-scoped sellable ruleset — neither your synced catalogue nor the fleet, and not something you can verify from outside. There is no per-vehicle detail, because some reasons cover groups the catalogue never publishes to you. Reasons are an open set: treat an unknown one as "withheld, cause unknown".reason· string · required Why these vehicle groups were withheld — an open set: treat a reason you do not recognise as "withheld, cause unknown to me". What each value means and what to do about it is tabled in the Channel API specification (§4.1):no_partner_catalogue,no_units_for_window,not_published_to_channel,no_sellable_cover,driver_age_out_of_range,no_rate_for_window,unservable_currency,unpriceable_selection,acriss_shared_code_incoherent,rental_duration_out_of_range,out_of_hours_not_offered,delivery_not_offeredandmovement_not_permitted. Each group is counted under exactly one reason, so the totals close. When a search-level refusal fires, its detail member —meta.unsellable_window,meta.unsellable_endsormeta.unsellable_movement— is the verdict, and this list is the accounting.vehicle_groups· integer · optional · ≥ 1 How many vehicle groups were withheld for this reason. Omitted only onno_partner_catalogue, where no catalogue was loaded and no count exists — never emitted as 0 or null.plan· string —basic·medium·premium·no_deposit_with_excess· optional The price-book plan whose sellable groups this count is against (1.30.0; present on per-plan entries unlessavailability_plansis switched off for your account). Absent on the page-level entries (a rental-duration, end-term or movement refusal withholds every plan at once) and on every entry of an account switched off. With it the accounting closes per plan: rows(plan) + Σ vehicle_groups(plan) + page-level = that plan's sellable groups.
driver_age· integer · optional Echo of thedriver_ageyou sent (1.3.0) — every row in this response was priced for exactly that age, surcharges included. Absent when you sent none, and that absence is meaningful rather than incidental: with no age each group is priced at a default clamped into its own permitted band, so there is no single age the response was priced at and none is invented.cross_border· boolean · optional Echo of a declared cross-border intent: present andtrueexactly when you sentcross_border=true, so every row here is priced with the operator's cross-border rule applied. Absent when you sentcross_border=falseor nothing at all — it is never emitted asfalse, so a search that declared nothing answers byte-identically to before. Read its presence, not its value.distribution_channel· object · optional The downstream channel this page was priced for, echoed when you named one (since 1.54.0) — the same object a booking through it carries. Absent when you named none, so such a search answers byte-identically to before.distribution_channel_id· string or null · required The operator's register entry the code matched, as listed by GET /v1/distribution-channels; null when the code matched no entry.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, exactly as you sent it.approval· string —approved·unregistered·proposed·suspended·deactivated·not_yet_effective·expired· (extensible: new members may appear) · required What the register said about the channel when it was recorded. Alwaysapprovedunless your agreement accepts and flags bookings through channels the operator has not approved; then it names why the channel was not approved at that moment.
plans· array of object · optional The price-book plans this page carries (since 1.30.0; present unlessavailability_plansis switched off for your account), in the published tier order, withservedmarking the one the supplier serves your account by default — i.e. the plan every row is priced from on an account switched off. Absent on such an account.plan· string —basic·medium·premium·no_deposit_with_excess· required The plan — the protection tier the operator includes in the rate on it. Send it asplanon POST /v1/quote to price that plan.served· boolean · required Whether this is the plan your account is served by default when a request names none.
rental_days· integer · optional · ≥ 1 The billed §6.2 day count for the searched window — the same figure a quote for it returns. Present when at least one group is returned.extras· array of Extra + object · optional The §2.3 extras catalogue (group-independent — applies to every row), each entry additionally priced for this window. Present when at least one group is returned.supplier_id· string · optional · min length 1 The supplier this extra belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.extra_id· string · required · min length 1 Opaque, stable identifier of this extra — the value you send inextras[].extra_idon a quote, a create and a modify. Treat it as a string: never parse it and never construct one. An extra the operator withdraws simply stops appearing in this catalogue, and from then on its id is refused at quote and booking (§3).code· string · required · min length 1 The operator's short machine code for this extra, and the value that appears ascodeon the matchingextraline of a price breakdown — so it is what joins a charged line back to this record. It is stable per record but not guaranteed unique across the catalogue, soextra_idremains the identity.supplier_code· string or null · optional · min length 1; max length 32; pattern^[A-Z0-9][A-Z0-9._-]{0,31}$The supplier's own short code for this accessory, such asCHILD, for keying your table on something that is not our identifier. Upper-case letters, digits,.,_and-, up to 32 characters. Set once and never changed, and never reused for another accessory, even after this one is withdrawn. Unique within one supplier.nulluntil stated. Notcode, which still joins a charged line to this record.ota_code· string or null · optional · min length 1; max length 16; pattern^[A-Za-z0-9.]{1,16}$The OTA equipment code the supplier states for this accessory, for example7or9.EQP.nulluntil stated. It is the supplier's statement and is not checked against any code list, so treat it as a hint for your own mapping, not as a guarantee. The supplier can correct it, and two accessories may share one.partner_code· string or null · optional · min length 1; max length 128 Your own code for this accessory, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this accessory. It is notcode, which joins a charged line to this record, and it never changes whatextra_ididentifies.name· string · required · min length 1 Customer-facing name of the extra, written to be shown as-is. Display only — never match or branch on it.description· string or null · required Free text describing the extra, as the operator wrote it — for display besidename.nullwhere the operator has stated none; that is 'not stated', not an empty description, so render nothing rather than an empty line.category· string —child_seat·additional_driver·navigation·connectivity·winter·protection_upsell·other· (extensible: new members may appear) · required What kind of extra this is, so you can group a selection page without parsingname(§8.8):child_seat(an infant, child or booster seat),additional_driver(a named extra driver — an age surcharge is a fee, not an extra, §2.5),navigation,connectivity(data or a hotspot),winter(snow chains, a ski rack),protection_upsell(an insurance-adjacent add-on, the likeliest to carry amutually_exclusive_group), andother, which you will receive, so handle it.pricing_type· string —per_day·per_rental· (extensible: new members may appear) · required Howunit_priceis charged, and enforced — a quote prices on it:per_dayis once per rental day,per_rentalonce for the whole rental. It can be an assumption rather than a stated fact: a row on which the operator never stated a basis is published, and charged, asper_day. New extras cannot be created without a basis, and an operator can require one on every active row, after which no assumed value remains.unit_price· Money · required What one unit of this extra costs, in integer minor units (§1.6), charged on the basispricing_typenames. It is the catalogue price and not the charge: the amount actually billed appears as the matchingextraline on a quote or booking breakdown, andquantity × unit_priceneed not equal that line'stotal— a cap or the day count can move it, so read the line rather than deriving it (§6.1).price_cap· NullableMoney · required The ceiling on what a single unit of this extra can be charged for one rental — a per-day extra that stops accruing after so many days, for instance. Always present, never omitted: a{amount, currency}object where a cap is set and the literalnullwhere there is none. Branch on the value, never on the key. It caps the per-unit charge under both pricing types.quantity· object · required The selectable quantity range for this extra, for building a stepper or a checkbox. Always present, and all three members with it. On a countable extra it is a real range; on one whererequires_quantityis false,maxis 1 — that pairing is guaranteed by the emitter, which derivesrequires_quantityfrom this object, and not by this schema, whose keyword subset cannot tie two members together.min· integer · required · ≥ 0 The fewest units that may be selected.0— the normal case — means the extra is optional; a positive value means the operator requires at least that many whenever the extra is taken.max· integer · required · ≥ 1 The most units that may be selected. A request above it is refused 422 validation_error naming the field. Note the request schemas capextras[].quantityat 9 independently of this value, so the effective ceiling is the lower of the two.default· integer · required · ≥ 0 The quantity to pre-select in a picker.0means 'not selected by default', which is the normal case; it is a UI hint and nothing is added to a price unless you actually send the extra.
requires_quantity· boolean · required Whether this extra is countable.true— the customer chooses how many (seats, additional drivers), withinquantity.min…quantity.max.false— it is a yes/no product, taken once or not at all; send quantity 1 to take it and omit the extra entirely to decline it. Render a stepper fortrueand a checkbox forfalse.mutually_exclusive_group· string or null · required An opaque group label: extras sharing one non-null value cannot be combined on the same booking, so a picker must let a customer choose at most one of them. Always present, never omitted — the literalnullwhere the extra combines freely, so branch on the value rather than on the key. The value is a label, not an identifier you can look up: treat it as opaque and compare it only against other extras' values.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this extra is due (§8.3).prepaid— it falls in the prepaid part of the total.at_pickup— it is payable on arrival. It is what routes this extra's charge into thepay_now/pay_at_pickupsplit of a breakdown'stotals(§6). Note that no money moves through this API in either case: this states when the amount is due, not that anything is collected here.tax_rate· number · required · ≥ 0; ≤ 1 The tax rate that applies to this extra, as a decimal fraction between 0 and 1 (0.24 means 24%) — deliberately not a percentage and not minor units. It is the one non-integer numeric on this catalogue and it is display information: the tax actually charged is the engine's owntax_breakdownon a quote or booking, extracted per line, so never derive tax from this rate — §6.2 says why an extraction done once over a total disagrees with the invoice by cents.active· boolean · required Alwaystrue: a withdrawn extra is not served at all, so never wait forfalse. Withdrawal reaches you as an absence from this full-refresh collection, and an absence is not a deletion — upsert by id and keep the record (§1.10). The authoritative signal is the failing call:422validation_errornaming the extra, or409availability_changedon a quoted create (§3).updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not A per-row edit stamp: the endpoint returns the full current catalogue on every call rather than narrowing, so every row's value moves together on every read. Store it and order by it; do not build change detection on it — the siblingrow_updated_atis the real per-row edit stamp, and?changed_since=narrows on it (§1.10).row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). It moves on this extra's own changes, including a price or availability override that applies to you.total_for_window· Money · required What this extra costs for the searched window at the minimum selectable quantity (1 for almost every extra) — the engine’s own figure, identical to the extra line a quote for this window carries. per_day extras: unit_price × billed days; per_rental: the flat unit_price. price_cap, when non-null, caps the per-unit charge on both pricing types. Read this figure rather than deriving it — quantity × unit_price need not equal it (§6.1).
policies· object · optional The partner-effective policy subset for the results page’s badges (§2.6), plus, since 1.6.0,deposit_payment_methods. The three §2.6 objects each carrysource(operator_default | broker_terms), resolved with your in-force overrides exactly as GET /v1/reference/policies reports them. Present when the operator has stated policies. A booking echoes the same three keys but not the same members: since 1.10.0 the cancellation detail below is published here and on GET /v1/reference/policies, and deliberately not on a booking’s copy (§2.6).mileage· object · required {type: unlimited|limited[, included_km_per_day], source}fuel· object · required {type, source} — §8 fuel policy enum.cancellation· object · required The cancellation rule in force for you — the operator's default or your negotiated override;sourcesays which. Show it before the sale: the fee atPOST /v1/bookings/{ref}/cancelis computed from this rule as it stood when the booking was made (§5.4).typeisfree_until(withfree_until_hours_before_pickupand an optionallate_fee_percent),tiered(withtiers, longest notice first, ending in a 0-hour band) ornon_refundable.fee_basis, on every type, names what each percentage is of (grand_total,pay_noworbase_rate); it is the operator's setting, not negotiable, so read it. The same members as the Policy schema; §2.6 is normative.no_show· object · optional The no-show fee in force for you, asGET /v1/reference/policiesreports it. Absent means a no-show costs nothing. Each booking freezes it at creation incancellation_policy.no_show_fee_percent.deposit_payment_methods· array of string · required What the desk accepts for the security deposit — the valueGET /v1/reference/policiesserves for this operator, carried here so a results page can warn "credit card required" before the customer picks a car. Always present whenpoliciesis. Operator-level and never negotiated, so it has nosource. An open vocabulary: degrade gracefully on a method you do not recognise.tax_inclusive· boolean · optional Whether every amount in this response already includes tax — the valueGET /v1/reference/policiesandGET /v1/capabilitiespublish. Always present whenpoliciesis.true: display amounts as given and read the tax from the row'stax_breakdownortotals; never add VAT on top or derive it, because §6.2 extracts tax per line. It describes the pricing engine, the same for every operator on this deployment, so it has nosource.
suppliers· array of object · optional Present only on a search that names a place (1.47.0): one entry per supplier searched, ordered bysupplier_idas the rows are. A supplier with no desk at the place is listed with no searches.supplier_id· string · required The supplier this entry describes, as on GET /v1/suppliers.distribution_channel_refusal· object · optional Present only when this supplier refused thedistribution_channelyou named (1.54.0): its error, as a desk-addressed search of it would answer —validation_error(issuenot_permittedorrequired) ordistribution_channel_not_approved. It contributed no rows. When every supplier with a desk here refuses it, the whole search is refused with the first refusal.code· string · required The error code, as on a refused request.message· string · required Why, in words.details· array of object · optional The field-level detail the refusal carries, as on a refused request.
searches· array of object · required Each pair of that supplier's desks the search priced, in the order its rows appear, with that pair's own meta.pickup_location_id· string · required The desk this leg collects from.dropoff_location_id· string · required The desk this leg returns to.as_of· string · required Asmeta.as_ofon a search by location_id, for this leg.unsellable_window· object · optional Asmeta.unsellable_windowon a search by location_id, for this leg.unsellable_ends· array of object · optional Asmeta.unsellable_endson a search by location_id, for this leg.unservable_currency· object · optional Asmeta.unservable_currencyon a search by location_id, for this leg.unsellable_movement· object · optional Asmeta.unsellable_movementon a search by location_id, for this leg.withheld· array of object · optional Asmeta.withheldon a search by location_id, for this leg.driver_age· integer · optional Asmeta.driver_ageon a search by location_id, for this leg.cross_border· boolean · optional Asmeta.cross_borderon a search by location_id, for this leg.distribution_channel· object · optional Asmeta.distribution_channelon a search by location_id, for this leg.plans· array of object · optional Asmeta.planson a search by location_id, for this leg.rental_days· integer · optional · ≥ 1 Asmeta.rental_dayson a search by location_id, for this leg.extras· array of Extra + object · optional Asmeta.extrason a search by location_id, for this leg.policies· object · optional Asmeta.policieson a search by location_id, for this leg.
suppliers_unavailable· array of string · optional Present only when a supplier could not be searched (1.47.0). Each entry is asupplier_idwith no rows and no entry insuppliersbecause it was not read; search again to include it.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"vehicle_id": "veh_2a5bcba25e8b",
"display_name": "Sandbox Economy (or similar)",
"acriss": "ECMR",
"available_units": 5,
"price": {
"amount": 9600,
"currency": "EUR"
},
"price_type": "indicative",
"requires_quote": true,
"protection": {
"supplier_id": "acct_00000000000000000000000000000000",
"protection_id": "prot_a8958631922d",
"vehicle_id": "veh_2a5bcba25e8b",
"code": "INS_BASIC",
"name": "Basic insurance",
"tier": "basic",
"description": null,
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"price": {
"amount": 0,
"currency": "EUR"
},
"pricing_type": "included",
"payable": "at_pickup",
"active": true,
"updated_at": "2026-09-07T06:55:41.710918+00:00"
},
"protection_options": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"protection_id": "prot_a8958631922d",
"vehicle_id": "veh_2a5bcba25e8b",
"code": "INS_BASIC",
"name": "Basic insurance",
"tier": "basic",
"description": null,
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"price": {
"amount": 0,
"currency": "EUR"
},
"pricing_type": "included",
"payable": "at_pickup",
"active": true,
"updated_at": "2026-09-07T06:55:41.710918+00:00",
"totals_for_window": {
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
}
},
"tax_breakdown_for_window": [
{
"code": "VAT",
"rate": 0.24,
"taxable_base": {
"amount": 7742,
"currency": "EUR"
},
"amount": {
"amount": 1858,
"currency": "EUR"
}
}
]
}
],
"line_items": [
{
"kind": "base_rate",
"code": "TIME_MILEAGE",
"label": "Rental (4 days)",
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
},
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true
}
],
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"taxable_base": {
"amount": 7742,
"currency": "EUR"
},
"amount": {
"amount": 1858,
"currency": "EUR"
}
}
],
"totals": {
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
}
},
"min_driver_age": 21,
"max_driver_age": 99,
"seats": 5,
"doors": 4,
"large_bags": 1,
"small_bags": 1,
"transmission": "manual",
"fuel": "petrol",
"air_conditioning": true,
"example_models": [
"Sandbox Hatchback"
],
"images": [
{
"url": "https://images.example.com/fleet/econ-hero.svg",
"role": "hero"
}
]
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"vehicle_id": "veh_31b9eef9d3be",
"display_name": "Sandbox SUV (or similar)",
"acriss": "IFAR",
"available_units": 2,
"price": {
"amount": 16400,
"currency": "EUR"
},
"price_type": "indicative",
"requires_quote": true,
"protection": {
"supplier_id": "acct_00000000000000000000000000000000",
"protection_id": "prot_9b99e3293d7d",
"vehicle_id": "veh_31b9eef9d3be",
"code": "INS_BASIC",
"name": "Basic insurance",
"tier": "basic",
"description": null,
"excess": {
"amount": 250000,
"currency": "EUR"
},
"deposit": {
"amount": 50000,
"currency": "EUR"
},
"price": {
"amount": 0,
"currency": "EUR"
},
"pricing_type": "included",
"payable": "at_pickup",
"active": true,
"updated_at": "2026-09-07T06:55:41.710918+00:00"
},
"protection_options": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"protection_id": "prot_9b99e3293d7d",
"vehicle_id": "veh_31b9eef9d3be",
"code": "INS_BASIC",
"name": "Basic insurance",
"tier": "basic",
"description": null,
"excess": {
"amount": 250000,
"currency": "EUR"
},
"deposit": {
"amount": 50000,
"currency": "EUR"
},
"price": {
"amount": 0,
"currency": "EUR"
},
"pricing_type": "included",
"payable": "at_pickup",
"active": true,
"updated_at": "2026-09-07T06:55:41.710918+00:00",
"totals_for_window": {
"subtotal_ex_tax": {
"amount": 13226,
"currency": "EUR"
},
"tax_total": {
"amount": 3174,
"currency": "EUR"
},
"grand_total": {
"amount": 16400,
"currency": "EUR"
},
"pay_now": {
"amount": 16400,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
}
},
"tax_breakdown_for_window": [
{
"code": "VAT",
"rate": 0.24,
"taxable_base": {
"amount": 13226,
"currency": "EUR"
},
"amount": {
"amount": 3174,
"currency": "EUR"
}
}
]
}
],
"line_items": [
{
"kind": "base_rate",
"code": "TIME_MILEAGE",
"label": "Rental (4 days)",
"quantity": 4,
"unit_price": {
"amount": 4100,
"currency": "EUR"
},
"total": {
"amount": 16400,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true
}
],
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"taxable_base": {
"amount": 13226,
"currency": "EUR"
},
"amount": {
"amount": 3174,
"currency": "EUR"
}
}
],
"totals": {
"subtotal_ex_tax": {
"amount": 13226,
"currency": "EUR"
},
"tax_total": {
"amount": 3174,
"currency": "EUR"
},
"grand_total": {
"amount": 16400,
"currency": "EUR"
},
"pay_now": {
"amount": 16400,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
}
},
"min_driver_age": 23,
"max_driver_age": 99,
"seats": 5,
"doors": 5,
"large_bags": 3,
"small_bags": 2,
"transmission": "automatic",
"fuel": "petrol",
"air_conditioning": true,
"example_models": [
"Sandbox 4x4"
],
"images": [
{
"url": "https://images.example.com/fleet/suv-hero.svg",
"role": "hero"
}
]
}
],
"meta": {
"as_of": "2026-09-07T06:55:41.710918+00:00",
"rental_days": 4,
"extras": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"extra_id": "extra_1",
"code": "extra_1",
"supplier_code": "CHILD",
"ota_code": "7",
"name": "Child seat",
"description": null,
"category": "other",
"pricing_type": "per_day",
"unit_price": {
"amount": 500,
"currency": "EUR"
},
"price_cap": null,
"quantity": {
"min": 1,
"max": 1,
"default": 1
},
"requires_quantity": false,
"mutually_exclusive_group": null,
"payable": "at_pickup",
"tax_rate": 0.24,
"active": true,
"updated_at": "2026-09-07T06:55:41.710918+00:00",
"total_for_window": {
"amount": 2000,
"currency": "EUR"
}
},
{
"supplier_id": "acct_00000000000000000000000000000000",
"extra_id": "extra_2",
"code": "extra_2",
"supplier_code": "ADDDRV",
"ota_code": null,
"name": "Additional driver",
"description": null,
"category": "other",
"pricing_type": "per_day",
"unit_price": {
"amount": 0,
"currency": "EUR"
},
"price_cap": null,
"quantity": {
"min": 1,
"max": 1,
"default": 1
},
"requires_quantity": false,
"mutually_exclusive_group": null,
"payable": "at_pickup",
"tax_rate": 0.24,
"active": true,
"updated_at": "2026-09-07T06:55:41.710918+00:00",
"total_for_window": {
"amount": 0,
"currency": "EUR"
}
}
],
"withheld": [],
"policies": {
"mileage": {
"type": "unlimited",
"source": "operator_default"
},
"fuel": {
"type": "full_to_full",
"source": "operator_default"
},
"cancellation": {
"type": "free_until",
"free_until_hours_before_pickup": 0,
"fee_basis": "grand_total",
"source": "operator_default"
},
"deposit_payment_methods": [],
"tax_inclusive": true
}
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getAvailability — GET /v1/availability
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/availability?pickup_location_id=1&dropoff_location_id=2&pickup_date=2027-05-10&pickup_time=10%3A00&dropoff_date=2027-05-13&dropoff_time=10%3A00¤cy=EUR&driver_age=30" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getAvailability — GET /v1/availability
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/availability?${new URLSearchParams({"pickup_location_id":"1","dropoff_location_id":"2","pickup_date":"2027-05-10","pickup_time":"10:00","dropoff_date":"2027-05-13","dropoff_time":"10:00","currency":"EUR","driver_age":"30"})}`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getAvailability — GET /v1/availability
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, urllib.parse
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/availability?" + urllib.parse.urlencode({'pickup_location_id': '1', 'dropoff_location_id': '2', 'pickup_date': '2027-05-10', 'pickup_time': '10:00', 'dropoff_date': '2027-05-13', 'dropoff_time': '10:00', 'currency': 'EUR', 'driver_age': '30'}), method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Quotes
POST /v1/quote
Turn a selection into a firm, full-total price and mint a quote_id you can book against. Requires Idempotency-Key.
The second call in a search → quote → book flow, and the one that binds the price. The response carries the full priced breakdown plus quote_id, expires_at and status. A quote guarantees the price for its lifetime, not the inventory: book it before expires_at, and be ready for the create call to report that availability moved underneath you. GET /v1/capabilities reports the quote lifetime for this deployment as quote_ttl_seconds — read it rather than assuming a window.
- Operation:
createQuote· Scope:write:quotes - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
Idempotency-Key(header) · string (uuid) · required Client-generated UUID, unique per logical operation (§1.8). Identical replays return the original response; a different body under the same key is 422 idempotency_key_reused.X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — QuoteRequest
plan· string —basic·medium·premium·no_deposit_with_excess· optional The price-book plan to price from — the protection tier the operator includes in the rate on that plan (since 1.30.0). Optional: omit it and you are priced from the plan the operator serves your account by default, exactly as before 1.30.0. Send theplancarried by the availability row you are pricing (rows carry it unlessavailability_plansis switched off for your account — GET /v1/whoami). A plan this account holds no book for at the pickup location is 422unknown_plan. The vocabulary is the protection tier set and is published x-extensible-enum, liketier.currency· string · optional · pattern^[A-Z]{3}$The ISO 4217 alpha-3 currency to price in, uppercase. Optional: omit it and the offer's supplier prices in its own currency. One that supplier does not price in is refused 422 — readcurrencyper supplier on GET /v1/suppliers and pin that.pickup· StopInput · required One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. This one is the collection.dropoff· StopInput · required One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. This one is the return.vehicle_id· string · required · min length 1 The vehicle group to price, from GET /v1/reference/vehicles — or from an availability row, whosevehicle_idis the same value. Required. You are pricing a class, not a unit.protection_id· string · required · min length 1 The protection product to price, from GET /v1/reference/protections — or from an availability row'sprotection_options, every entry of which is selectable here as-is. Required: there is no default tier. It must belong to the selectedvehicle_id, or the request is rejected 422.extras· array of object · optional · max items 20 The extras to add. Optional — omit it entirely to take none; an empty array means the same thing. Each entry is a separate product, so send one entry perextra_idrather than repeating one. At most twenty entries.extra_id· string · required · min length 1 The opaque identifier of the extra, from GET /v1/reference/extras. An id that is no longer offered is rejected 422 naming the field — that is the authoritative withdrawal signal, not the catalogue'sactiveflag.quantity· integer · required · ≥ 1; ≤ 9 How many units of this extra, 1–9. To decline an extra, omit it from the array — do not send a quantity of 0. The catalogue's ownquantity.maxcan be lower than 9, and the effective ceiling is the lower of the two; a request above it is rejected 422. A non-countable extra (requires_quantity: false) takes 1.
cross_border· boolean · optional The customer's declared intent to take the vehicle out of the country of hire; sendtrueonly when they have said so — absent is not a declaration. Where the operator configures a cross-border rule it changes the price, and it is part of a booking's identity forexternal_ref(§1.8): a retry with a different value answers409booking_conflict. It is not a one-way, which aone_wayrule prices, but a border crossed between collecting and returning at one desk, which the locations cannot show. A missing cross-border fee is not permission to cross: the API does not publish whether the operator allows it (§2.6).drivers· array of object · required · min items 1; max items 9 Everyone who will drive, at least one and at most nine. Send every driver you intend to list or the quote will be short: age fees are resolved per qualifying driver across this array, and the quote's driver set is binding — the booking that consumes it must restate the same{age, role}multiset, so a driver added afterwards means a new quote. Names are not sent here; they are supplied on the create.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across this whole array, main and additional alike, so two drivers in one band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· unspecified —main·additional· requiredmain— the driver the rental is contracted to; send exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra while an age surcharge is charged as a fee: different mechanisms, and both can apply to the same person.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
distribution_channel· object · optional · no other members The downstream channel this quote prices for (since 1.54.0), judged as a booking naming it is: by your agreement’s policy on GET /v1/whoami, anddistribution_channel_not_approvedfor a channel the operator has not approved (or accepted and flagged where your agreement says so). Priced from that channel’s prices where the operator keeps them, else from your account’s own, and echoed asdistribution_channel. A booking made from the quote must be priced the same way (issuequote_channel_mismatch).code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
Example request:
{
"pickup": {
"location_id": "loc_HER_APT",
"at": "2026-11-03T10:00:00+02:00"
},
"dropoff": {
"location_id": "loc_HER_APT",
"at": "2026-11-07T10:00:00+02:00"
},
"vehicle_id": "veh_econ_a",
"protection_id": "prot_basic",
"extras": [
{
"extra_id": "xtr_child_seat",
"quantity": 1
}
],
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB"
}
]
}
Responses
- 200 — Idempotent replay of a prior quote (the effective status may be expired). ·
data: QuoteResponseData - 201 — Quote minted. ·
data: QuoteResponseData
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"supplier_id": "acct_00000000000000000000000000000000",
"totals": {
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
},
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
}
},
"deposit": {
"type": "pre_authorization",
"amount": {
"amount": 30000,
"currency": "EUR"
},
"refundable": true,
"collected_at": "pickup"
},
"currency": "EUR",
"line_items": [
{
"code": "TIME_MILEAGE",
"kind": "base_rate",
"label": "Rental (4 days)",
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true,
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
}
}
],
"rental_days": 4,
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"amount": {
"amount": 1858,
"currency": "EUR"
},
"taxable_base": {
"amount": 7742,
"currency": "EUR"
}
}
],
"quote_id": "60ee3ad0-158f-4434-af18-2a6d155777f6",
"expires_at": "2026-09-07T07:32:39.081185+00:00",
"status": "active"
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 409 idempotency_in_progress · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# createQuote — POST /v1/quote
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/quote" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"pickup": {
"location_id": "1",
"at": "2026-11-03T10:00:00+02:00"
},
"dropoff": {
"location_id": "1",
"at": "2026-11-07T10:00:00+02:00"
},
"vehicle_id": "veh_2a5bcba25e8b",
"protection_id": "prot_42ccd73c02a7",
"extras": [
{
"extra_id": "extra_1",
"quantity": 1
}
],
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB"
}
]
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// createQuote — POST /v1/quote
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
import { randomUUID } from 'node:crypto';
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/quote`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
body: JSON.stringify({
"pickup": {
"location_id": "1",
"at": "2026-11-03T10:00:00+02:00"
},
"dropoff": {
"location_id": "1",
"at": "2026-11-07T10:00:00+02:00"
},
"vehicle_id": "veh_2a5bcba25e8b",
"protection_id": "prot_42ccd73c02a7",
"extras": [
{
"extra_id": "extra_1",
"quantity": 1
}
],
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB"
}
]
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# createQuote — POST /v1/quote
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, uuid
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"pickup": {
"location_id": "1",
"at": "2026-11-03T10:00:00+02:00"
},
"dropoff": {
"location_id": "1",
"at": "2026-11-07T10:00:00+02:00"
},
"vehicle_id": "veh_2a5bcba25e8b",
"protection_id": "prot_42ccd73c02a7",
"extras": [
{
"extra_id": "extra_1",
"quantity": 1
}
],
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB"
}
]
}).encode()
req = urllib.request.Request(f"{base}/v1/quote", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Bookings
GET /v1/bookings
Reconciliation list — summaries only; fetch detail via GET /v1/bookings/{ref}.
- Operation:
listBookings· Scope:write:bookings - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.limit(query) · integer · optional Page size (default 50, max 200).page_token(query) · string · optional Opaque, endpoint-specific cursor from meta.next_page — pass back verbatim, never construct (§1.9). A token from a different endpoint is rejected 422. When present, updated_since is ignored.status(query) · unspecified —pending·confirmed·cancelled·no_show·completed· optional One state, matched against the status you are served. Validated closed: a new state is accepted from the release that emits it, if you are pinned at 1.64.0 or later (§8.1).X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of BookingListItemmetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.46.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.46.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"supplier_id": "acct_00000000000000000000000000000000",
"booking_id": "b9e2ade0-b29d-4895-96d8-c3027a634a48",
"external_ref": "docs-capture-065625",
"status": "confirmed",
"revision": 1,
"updated_at": "2026-09-07T06:56:25+00:00",
"grand_total": {
"amount": 9600,
"currency": "EUR"
}
}
],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:55:42.545405+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listBookings — GET /v1/bookings
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/bookings" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listBookings — GET /v1/bookings
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/bookings`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listBookings — GET /v1/bookings
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/bookings", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/bookings
Create a booking, from a quote you minted or from a full selection inline. Requires Idempotency-Key.
Two body shapes, and quote_id decides which. With quote_id (CreateQuotedBookingRequest), the unexpired quote books at exactly its price: send only external_ref, drivers, customer and the optional pass-throughs — expected_price or any other priced field is refused 422. Without it (CreateBookingRequest), the whole selection travels in the body and expected_price, the grand total you showed the customer, is required unless your account accepts the supplier price without a consent gate; a different computed price fails 409 price_changed with the new breakdown in details. mode (direct or quoted) is optional and never selects the path, but a mismatch then fails at mode. There is no OpenAPI discriminator, because mode is optional.
- Operation:
createBooking· Scope:write:bookings - Response headers:
Content-Language,Deprecation,Link,Retry-After,Sunset,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
Idempotency-Key(header) · string (uuid) · required Client-generated UUID, unique per logical operation (§1.8). Identical replays return the original response; a different body under the same key is 422 idempotency_key_reused.X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — CreateBookingRequestBody
No members.
Example request:
{
"quote_id": "5f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"external_ref": "PARTNER-12345",
"drivers": [
{
"first_name": "Jane",
"last_name": "Roe",
"age": 30,
"role": "main",
"country": "GB"
}
],
"customer": {
"first_name": "Jane",
"last_name": "Roe",
"email": "[email protected]",
"phone": "+441234567890",
"country": "GB"
},
"flight_number": "A3 991"
}
Responses
- 200 — Idempotent replay, or an existing booking with the same external_ref and identical intent (§1.8). ·
data: Bookingmetaon this response carries:replayed· boolean · optional Present andtrueonly when nothing was written for this request and the booking indataalready existed. Absent on a first execution, so test for its presence rather than forreplayed === false, which this API does not send.replay_of· string —idempotency_key·external_ref· (extensible: new members may appear) · optional Which mechanism answered, present wheneverreplayedis.idempotency_key— the same key and body, so the stored outcome was replayed; no action needed.external_ref— a booking already existed under this reference with the same intent (§1.8), and because customer, driver,notes,flight_numberandmetasit outside that intent, anything newer you sent in them was not applied: compare, thenPATCHthe difference. An exchange that is both reportsidempotency_key. Treat an unknown value as: this booking already existed.
- 201 — Created ·
data: Bookingmetaon this response carries:replayed· boolean · optional Present andtrueonly when nothing was written for this request and the booking indataalready existed. Absent on a first execution, so test for its presence rather than forreplayed === false, which this API does not send.replay_of· string —idempotency_key·external_ref· (extensible: new members may appear) · optional Which mechanism answered, present wheneverreplayedis.idempotency_key— the same key and body, so the stored outcome was replayed; no action needed.external_ref— a booking already existed under this reference with the same intent (§1.8), and because customer, driver,notes,flight_numberandmetasit outside that intent, anything newer you sent in them was not applied: compare, thenPATCHthe difference. An exchange that is both reportsidempotency_key. Treat an unknown value as: this booking already existed.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"supplier_id": "acct_00000000000000000000000000000000",
"price": {
"totals": {
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
},
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
}
},
"deposit": {
"type": "pre_authorization",
"amount": {
"amount": 30000,
"currency": "EUR"
},
"refundable": true,
"collected_at": "pickup"
},
"currency": "EUR",
"line_items": [
{
"code": "TIME_MILEAGE",
"kind": "base_rate",
"label": "Rental (4 days)",
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true,
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
}
}
],
"rental_days": 4,
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"amount": {
"amount": 1858,
"currency": "EUR"
},
"taxable_base": {
"amount": 7742,
"currency": "EUR"
}
}
]
},
"extras": [],
"pickup": {
"at": "2026-11-06T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"status": "confirmed",
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
}
],
"dropoff": {
"at": "2026-11-10T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"vehicle": {
"acriss": "ECMR",
"vehicle_id": "veh_2a5bcba25e8b",
"display_name": "Sandbox Economy (or similar)",
"allocated_category": "ECON",
"allocated_vehicle_id": "veh_2a5bcba25e8b"
},
"customer": {
"email": "[email protected]",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
},
"policies": {
"mileage": {
"type": "unlimited"
},
"fuel": {
"type": "full_to_full"
},
"cancellation": {
"type": "free_until"
}
},
"quote_id": "60ee3ad0-158f-4434-af18-2a6d155777f6",
"revision": 1,
"booking_id": "38f81fa7-fa00-46d3-b4cf-594963bfb023",
"created_at": "2026-09-07T07:02:39.984415+00:00",
"protection": {
"tier": "basic",
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"protection_id": "prot_a8958631922d"
},
"updated_at": "2026-09-07T07:02:39.984415+00:00",
"rental_days": 4,
"external_ref": "docs-replay-070239",
"cancellation_policy": {
"type": "free_until",
"source": "operator_default",
"fee_basis": "grand_total",
"free_until": "2026-11-06T10:00:00Z",
"late_fee_percent": 0
}
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 404 not_found · 409 booking_conflict · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# createBooking — POST /v1/bookings
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <quote_id> = the quote_id POST /v1/quote returned; <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/bookings" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"quote_id": "<quote_id>",
"external_ref": "<external_ref>",
"drivers": [
{
"first_name": "Jane",
"last_name": "Roe",
"age": 30,
"role": "main",
"country": "GB"
}
],
"customer": {
"first_name": "Jane",
"last_name": "Roe",
"email": "[email protected]",
"phone": "+441234567890",
"country": "GB"
},
"flight_number": "A3 991"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// createBooking — POST /v1/bookings
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <quote_id> = the quote_id POST /v1/quote returned; <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
import { randomUUID } from 'node:crypto';
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/bookings`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
body: JSON.stringify({
"quote_id": "<quote_id>",
"external_ref": "<external_ref>",
"drivers": [
{
"first_name": "Jane",
"last_name": "Roe",
"age": 30,
"role": "main",
"country": "GB"
}
],
"customer": {
"first_name": "Jane",
"last_name": "Roe",
"email": "[email protected]",
"phone": "+441234567890",
"country": "GB"
},
"flight_number": "A3 991"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# createBooking — POST /v1/bookings
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <quote_id> = the quote_id POST /v1/quote returned; <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, uuid
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"quote_id": "<quote_id>",
"external_ref": "<external_ref>",
"drivers": [
{
"first_name": "Jane",
"last_name": "Roe",
"age": 30,
"role": "main",
"country": "GB"
}
],
"customer": {
"first_name": "Jane",
"last_name": "Roe",
"email": "[email protected]",
"phone": "+441234567890",
"country": "GB"
},
"flight_number": "A3 991"
}).encode()
req = urllib.request.Request(f"{base}/v1/bookings", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/bookings/{ref}
Retrieve a booking by external_ref (default) or booking_id (?ref_type=booking_id).
- Operation:
getBooking· Scope:write:bookings - Response headers:
Content-Language,Deprecation,Link,Retry-After,Sunset,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
ref(path) · string · requiredref_type(query) · unspecified —external_ref·booking_id· optionalX-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: Booking
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"supplier_id": "acct_00000000000000000000000000000000",
"notes": "documentation example capture — cancelled immediately",
"price": {
"totals": {
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
},
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
}
},
"deposit": {
"type": "pre_authorization",
"amount": {
"amount": 30000,
"currency": "EUR"
},
"refundable": true,
"collected_at": "pickup"
},
"currency": "EUR",
"line_items": [
{
"code": "TIME_MILEAGE",
"kind": "base_rate",
"label": "Rental (4 days)",
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true,
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
}
}
],
"rental_days": 4,
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"amount": {
"amount": 1858,
"currency": "EUR"
},
"taxable_base": {
"amount": 7742,
"currency": "EUR"
}
}
]
},
"extras": [],
"pickup": {
"at": "2026-10-07T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"status": "confirmed",
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
}
],
"dropoff": {
"at": "2026-10-11T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"vehicle": {
"acriss": "ECMR",
"vehicle_id": "veh_2a5bcba25e8b",
"display_name": "Sandbox Economy (or similar)",
"allocated_category": "ECON",
"allocated_vehicle_id": "veh_2a5bcba25e8b"
},
"customer": {
"email": "[email protected]",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
},
"policies": {
"mileage": {
"type": "unlimited"
},
"fuel": {
"type": "full_to_full"
},
"cancellation": {
"type": "free_until"
}
},
"quote_id": "a1107968-520f-457d-871c-28bad2742539",
"revision": 1,
"booking_id": "b9e2ade0-b29d-4895-96d8-c3027a634a48",
"created_at": "2026-09-07T06:56:25.925808+00:00",
"protection": {
"tier": "basic",
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"protection_id": "prot_a8958631922d"
},
"updated_at": "2026-09-07T06:56:25+00:00",
"rental_days": 4,
"external_ref": "docs-capture-065625",
"cancellation_policy": {
"type": "free_until",
"source": "operator_default",
"fee_basis": "grand_total",
"free_until": "2026-10-07T10:00:00Z",
"late_fee_percent": 0
}
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getBooking — GET /v1/bookings/{ref}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/bookings/<external_ref>" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getBooking — GET /v1/bookings/{ref}
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/bookings/<external_ref>`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getBooking — GET /v1/bookings/{ref}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/bookings/<external_ref>", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
PATCH /v1/bookings/{ref}
Modify a live (non-terminal) booking — partial merge of only the fields that change. Primary form. Requires Idempotency-Key + expected_revision.
A patch that changes nothing is a no-op: 200, the same revision, no booking.modified event, and expected_revision is not checked. A real change enforces expected_revision (409 revision_conflict on a stale value), re-prices when a pricing-relevant field changed, and rechecks availability when vehicle_id or the operator-local pickup or dropoff date changed. A pricing-relevant change must carry expected_price, the consent gate of a direct create (§5.1) with the same exception; a total that no longer matches fails 409 price_changed with the new breakdown to re-consent against. A metadata-only patch needs no expected_price (§5.3).
- Operation:
modifyBooking· Scope:write:bookings - Response headers:
Content-Language,Deprecation,Link,Retry-After,Sunset,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
ref(path) · string · requiredref_type(query) · unspecified —external_ref·booking_id· optionalIdempotency-Key(header) · string (uuid) · required Client-generated UUID, unique per logical operation (§1.8). Identical replays return the original response; a different body under the same key is 422 idempotency_key_reused.X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — ModifyBookingRequest
expected_revision· integer · required · ≥ 1 Therevisionyou last read for this booking. Required on every patch. It is enforced only when the PATCH actually changes something: a patch that resolves to no field change is a no-op — 200, the revision is reused rather than bumped, no event is emitted, and this value is not checked. A patch with a real change enforces it, and a stale value fails 409 revision_conflict carrying the current booking: re-read, re-decide, resubmit.pickup· StopInput · optional One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. Send it only to move the collection. Moving an end re-checks availability and re-prices; an end you do not touch keeps the answer it was sold under.dropoff· StopInput · optional One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. Send it only to move the return, on the same terms aspickup.vehicle_id· string · optional · min length 1 The vehicle group to move the booking to. Send it only to change the group — it is one of the two fields that re-check availability, so a group with no free unit for the window fails 409 availability_changed and nothing is written: the booking stands exactly as it was.protection_id· string · optional · min length 1 The protection product to move the booking to. Send it only to change the cover. Since 1.32.0 a price plan sells exactly one package and a modify re-prices on the booking's own plan, so within a booking there is no other package to change to: any other package's id is 422unknown_protection. It is pricing-relevant, so the patch re-prices and needsexpected_price; it must belong to the booking's vehicle group.extras· array of object · optional The extras to add. Optional — omit it entirely to take none; an empty array means the same thing. Each entry is a separate product, so send one entry perextra_idrather than repeating one. It replaces the whole set rather than adding to it: send the full list you want the booking to end with, and send an empty array to remove them all.extra_id· string · required · min length 1 The opaque identifier of the extra, from GET /v1/reference/extras. An id that is no longer offered is rejected 422 naming the field — that is the authoritative withdrawal signal, not the catalogue'sactiveflag.quantity· integer · required · ≥ 1; ≤ 9 How many units of this extra, 1–9. To decline an extra, omit it from the array — do not send a quantity of 0. The catalogue's ownquantity.maxcan be lower than 9, and the effective ceiling is the lower of the two; a request above it is rejected 422. A non-countable extra (requires_quantity: false) takes 1.
drivers· array of object · optional · min items 1; max items 9 The full driver list the booking should end with, at least one and at most nine — it replaces the existing set rather than adding to it. Pricing-relevant, because age fees are resolved per qualifying driver across it.first_name· string · optional · min length 1 The driver's given name. Optional on the wire; send it for every driver, because the desk checks the licence against the name.last_name· string · optional · min length 1 The driver's family name. Optional on the wire, on the same footing asfirst_name.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across this whole array, main and additional alike, so two drivers in one band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· unspecified —main·additional· requiredmain— the driver the rental is contracted to; send exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra while an age surcharge is charged as a fee: different mechanisms, and both can apply to the same person.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
customer· object · optional · no other members Who the booking is for — the contracting renter, who need not be one of thedrivers. It never affects the price. Send it to correct the renter's details; it replaces the stored object rather than merging into it, and it is not pricing-relevant.first_name· string · required · min length 1 The renter's given name. Required.last_name· string · required · min length 1 The renter's family name. Required.email· string · required · pattern^[^@\s]+@[^@\s]+\.[^@\s]+$The renter's email address. Required — it is how the operator reaches the customer about this rental. Pattern-checked for shape only; a syntactically valid address that does not exist is accepted here and fails later.phone· string · optional · pattern^\+?[0-9 ().-]{5,32}$The renter's telephone number, digits with an optional leading+and common separators. Optional, and worth sending: it is what the desk uses when a handover goes wrong.country· string · optional · pattern^[A-Z]{2}$The renter's country of residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional. It is a customer attribute; the driver country is the one on eachdrivers[]entry.locale· string · optional · pattern^[a-z]{2}(-[A-Z]{2})?$The renter's preferred language as a BCP-47-style tag (en, oren-GB). Optional. Readconstants.localeson GET /v1/reference/constants for what can actually be honoured rather than assuming a tag is supported.
expected_price· object · optional · no other members The price-consent gate: the supplier grand total you are consenting to, never a figure you computed or added fees to. If the computed total differs, the request fails409price_changedwith the full new breakdown, so you can re-consent and resubmit; nothing is ever repriced silently. Required only for a pricing-relevant change (vehicle, protection, extras, drivers or dates): omitting it there is422unless your account accepts the supplier price without a consent gate. A metadata-only patch ignores it.grand_total· MoneyInput · required The supplier total you are consenting to, in integer minor units with its currency (§1.6) — the same figure a quote reports astotals.grand_total, tax included. It is compared exactly; it is not a tolerance.
flight_number· string or null · optional The customer's inbound flight. Send it to set or correct one;nullclears it. Not pricing-relevant, so a patch carrying only this is a metadata-only change.notes· string or null · optional Free-text detail;nullclears it. Replaces the stored value, never appends, and does not re-price. Read by desk staff, never machine-actioned; for a delivery-priced location it carries the hotel.meta· object · optional Your own open object (§1.8b), stored verbatim and returned on the booking. It is outside the intent hash (§1.8), so changing it never causes a409. Two namespaces are reserved:meta.billing, the invoicing identity a company rental needs (tax identifier, company name, address, invoice email), andmeta.partner, your own correlation data (booking id, agent, campaign), which is never parsed; both are optional. It stays structurally open, so a payload accepted today is never refused later. On a patch it replaces the stored object rather than merging: send the whole object.distribution_channel· object or null · optional · no other members Corrects the downstream channel this booking was sold through. An object names a channel, judged against the operator's register now;nullremoves the recorded channel unless your agreement requires one. Omit it to keep the recorded channel: it is not judged again, even if that channel has since been suspended. Re-sending the recorded code changes nothing.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
Example request:
{
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}
Responses
- 200 — Modified (or a no-op patch — see description). ·
data: Booking
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"supplier_id": "acct_00000000000000000000000000000000",
"price": {
"totals": {
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
},
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
}
},
"deposit": {
"type": "pre_authorization",
"amount": {
"amount": 30000,
"currency": "EUR"
},
"refundable": true,
"collected_at": "pickup"
},
"currency": "EUR",
"line_items": [
{
"code": "TIME_MILEAGE",
"kind": "base_rate",
"label": "Rental (4 days)",
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true,
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
}
}
],
"rental_days": 4,
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"amount": {
"amount": 1858,
"currency": "EUR"
},
"taxable_base": {
"amount": 7742,
"currency": "EUR"
}
}
]
},
"extras": [],
"pickup": {
"at": "2026-10-27T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"status": "confirmed",
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
}
],
"dropoff": {
"at": "2026-10-31T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"vehicle": {
"acriss": "ECMR",
"vehicle_id": "veh_2a5bcba25e8b",
"display_name": "Sandbox Economy (or similar)",
"allocated_category": "ECON",
"allocated_vehicle_id": "veh_2a5bcba25e8b"
},
"customer": {
"email": "[email protected]",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
},
"policies": {
"mileage": {
"type": "unlimited"
},
"fuel": {
"type": "full_to_full"
},
"cancellation": {
"type": "free_until"
}
},
"quote_id": "15e8c4c7-6420-4c90-af43-6f74aeebaaff",
"revision": 2,
"booking_id": "d022fac0-3abf-4a52-ab44-74dfac2e20d7",
"created_at": "2026-09-07T07:00:30.746894+00:00",
"protection": {
"tier": "basic",
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"protection_id": "prot_a8958631922d"
},
"updated_at": "2026-09-07T07:00:31.420017+00:00",
"rental_days": 4,
"external_ref": "docs-modify2-070030",
"cancellation_policy": {
"type": "free_until",
"source": "operator_default",
"fee_basis": "grand_total",
"free_until": "2026-10-27T10:00:00Z",
"late_fee_percent": 0
},
"flight_number": "A3 991"
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 404 not_found · 409 invalid_transition · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# modifyBooking — PATCH /v1/bookings/{ref}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X PATCH "$CHANNEL_BASE_URL/v1/bookings/<external_ref>" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// modifyBooking — PATCH /v1/bookings/{ref}
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
import { randomUUID } from 'node:crypto';
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/bookings/<external_ref>`, {
method: 'PATCH',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
body: JSON.stringify({
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# modifyBooking — PATCH /v1/bookings/{ref}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, uuid
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}).encode()
req = urllib.request.Request(f"{base}/v1/bookings/<external_ref>", method='PATCH', headers={'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/bookings/{ref}/cancel
Cancel (idempotent — a second cancel returns 200). expected_revision guards the stale-write race. Requires Idempotency-Key.
- Operation:
cancelBooking· Scope:write:bookings - Response headers:
Content-Language,Deprecation,Link,Retry-After,Sunset,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
ref(path) · string · requiredref_type(query) · unspecified —external_ref·booking_id· optionalIdempotency-Key(header) · string (uuid) · required Client-generated UUID, unique per logical operation (§1.8). Identical replays return the original response; a different body under the same key is 422 idempotency_key_reused.X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — CancelBookingRequest
expected_revision· integer · required · ≥ 1 Therevisionyou last read for this booking. Required — cancelling a booking the desk has since changed is the same stale-write race a modify runs, so a stale value fails 409 revision_conflict carrying the current booking: re-read it, re-decide, and resubmit if you still mean to cancel. A desk-sideno_showORcompleteddoes not bumprevision, so a booking can have moved to a terminal state without this value going stale — such a cancel fails 409 invalid_transition instead.reason· string or null · optional Why you are cancelling, as free text — echoed back on the booking'scancellation.reasonand visible to the operator. Optional;nullis accepted and means the same as omitting it. It is not machine-readable and it does not affect what cancelling costs: the fee comes from the rule frozen onto the booking, evaluated against the notice actually given (§5.4).distribution_channel· object · optional · no other members The downstream channel the booking was sold through, if you send your channel code on every request (since 1.54.0). Optional under every agreement — a cancel is never refused for lacking it. When sent it must be the channel recorded on the booking (itsdistribution_channel.code), or the cancel is refused 422validation_errorwith issuebooking_channel_mismatchand nothing changes. It never changes the booking's channel; a modify does that.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
Example request:
{
"expected_revision": 1,
"reason": "customer_request"
}
Responses
- 200 — Cancelled (or already cancelled). ·
data: Booking
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"supplier_id": "acct_00000000000000000000000000000000",
"notes": "documentation example capture — cancelled immediately",
"price": {
"totals": {
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
},
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
}
},
"deposit": {
"type": "pre_authorization",
"amount": {
"amount": 30000,
"currency": "EUR"
},
"refundable": true,
"collected_at": "pickup"
},
"currency": "EUR",
"line_items": [
{
"code": "TIME_MILEAGE",
"kind": "base_rate",
"label": "Rental (4 days)",
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true,
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
}
}
],
"rental_days": 4,
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"amount": {
"amount": 1858,
"currency": "EUR"
},
"taxable_base": {
"amount": 7742,
"currency": "EUR"
}
}
]
},
"extras": [],
"pickup": {
"at": "2026-10-07T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"status": "cancelled",
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
}
],
"dropoff": {
"at": "2026-10-11T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"vehicle": {
"acriss": "ECMR",
"vehicle_id": "veh_2a5bcba25e8b",
"display_name": "Sandbox Economy (or similar)",
"allocated_category": "ECON",
"allocated_vehicle_id": "veh_2a5bcba25e8b"
},
"customer": {
"email": "[email protected]",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
},
"policies": {
"mileage": {
"type": "unlimited"
},
"fuel": {
"type": "full_to_full"
},
"cancellation": {
"type": "free_until"
}
},
"quote_id": "a1107968-520f-457d-871c-28bad2742539",
"revision": 2,
"booking_id": "b9e2ade0-b29d-4895-96d8-c3027a634a48",
"created_at": "2026-09-07T06:56:25.925808+00:00",
"protection": {
"tier": "basic",
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"protection_id": "prot_a8958631922d"
},
"updated_at": "2026-09-07T06:56:42.852348+00:00",
"rental_days": 4,
"external_ref": "docs-capture-065625",
"cancellation_policy": {
"type": "free_until",
"source": "operator_default",
"fee_basis": "grand_total",
"free_until": "2026-10-07T10:00:00Z",
"late_fee_percent": 0
},
"cancellation": {
"fee": {
"amount": 0,
"currency": "EUR"
},
"refund": {
"amount": 0,
"currency": "EUR"
},
"reason": "customer_request",
"cancelled_at": "2026-09-07T06:56:42.852348+00:00"
}
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 404 not_found · 409 invalid_transition · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# cancelBooking — POST /v1/bookings/{ref}/cancel
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/bookings/<external_ref>/cancel" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"expected_revision": 1,
"reason": "customer_request"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// cancelBooking — POST /v1/bookings/{ref}/cancel
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
import { randomUUID } from 'node:crypto';
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/bookings/<external_ref>/cancel`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
body: JSON.stringify({
"expected_revision": 1,
"reason": "customer_request"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# cancelBooking — POST /v1/bookings/{ref}/cancel
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, uuid
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"expected_revision": 1,
"reason": "customer_request"
}).encode()
req = urllib.request.Request(f"{base}/v1/bookings/<external_ref>/cancel", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/bookings/{ref}/modify
Modify a live (non-terminal) booking — partial merge of only the fields that change. Alternative form — identical behaviour to PATCH /v1/bookings/{ref}. Requires Idempotency-Key + expected_revision.
A patch that changes nothing is a no-op: 200, the same revision, no booking.modified event, and expected_revision is not checked. A real change enforces expected_revision (409 revision_conflict on a stale value), re-prices when a pricing-relevant field changed, and rechecks availability when vehicle_id or the operator-local pickup or dropoff date changed. A pricing-relevant change must carry expected_price, the consent gate of a direct create (§5.1) with the same exception; a total that no longer matches fails 409 price_changed with the new breakdown to re-consent against. A metadata-only patch needs no expected_price (§5.3).
- Operation:
modifyBookingAlias· Scope:write:bookings - Response headers:
Content-Language,Deprecation,Link,Retry-After,Sunset,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
ref(path) · string · requiredref_type(query) · unspecified —external_ref·booking_id· optionalIdempotency-Key(header) · string (uuid) · required Client-generated UUID, unique per logical operation (§1.8). Identical replays return the original response; a different body under the same key is 422 idempotency_key_reused.X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — ModifyBookingRequest
expected_revision· integer · required · ≥ 1 Therevisionyou last read for this booking. Required on every patch. It is enforced only when the PATCH actually changes something: a patch that resolves to no field change is a no-op — 200, the revision is reused rather than bumped, no event is emitted, and this value is not checked. A patch with a real change enforces it, and a stale value fails 409 revision_conflict carrying the current booking: re-read, re-decide, resubmit.pickup· StopInput · optional One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. Send it only to move the collection. Moving an end re-checks availability and re-prices; an end you do not touch keeps the answer it was sold under.dropoff· StopInput · optional One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. Send it only to move the return, on the same terms aspickup.vehicle_id· string · optional · min length 1 The vehicle group to move the booking to. Send it only to change the group — it is one of the two fields that re-check availability, so a group with no free unit for the window fails 409 availability_changed and nothing is written: the booking stands exactly as it was.protection_id· string · optional · min length 1 The protection product to move the booking to. Send it only to change the cover. Since 1.32.0 a price plan sells exactly one package and a modify re-prices on the booking's own plan, so within a booking there is no other package to change to: any other package's id is 422unknown_protection. It is pricing-relevant, so the patch re-prices and needsexpected_price; it must belong to the booking's vehicle group.extras· array of object · optional The extras to add. Optional — omit it entirely to take none; an empty array means the same thing. Each entry is a separate product, so send one entry perextra_idrather than repeating one. It replaces the whole set rather than adding to it: send the full list you want the booking to end with, and send an empty array to remove them all.extra_id· string · required · min length 1 The opaque identifier of the extra, from GET /v1/reference/extras. An id that is no longer offered is rejected 422 naming the field — that is the authoritative withdrawal signal, not the catalogue'sactiveflag.quantity· integer · required · ≥ 1; ≤ 9 How many units of this extra, 1–9. To decline an extra, omit it from the array — do not send a quantity of 0. The catalogue's ownquantity.maxcan be lower than 9, and the effective ceiling is the lower of the two; a request above it is rejected 422. A non-countable extra (requires_quantity: false) takes 1.
drivers· array of object · optional · min items 1; max items 9 The full driver list the booking should end with, at least one and at most nine — it replaces the existing set rather than adding to it. Pricing-relevant, because age fees are resolved per qualifying driver across it.first_name· string · optional · min length 1 The driver's given name. Optional on the wire; send it for every driver, because the desk checks the licence against the name.last_name· string · optional · min length 1 The driver's family name. Optional on the wire, on the same footing asfirst_name.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across this whole array, main and additional alike, so two drivers in one band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· unspecified —main·additional· requiredmain— the driver the rental is contracted to; send exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra while an age surcharge is charged as a fee: different mechanisms, and both can apply to the same person.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
customer· object · optional · no other members Who the booking is for — the contracting renter, who need not be one of thedrivers. It never affects the price. Send it to correct the renter's details; it replaces the stored object rather than merging into it, and it is not pricing-relevant.first_name· string · required · min length 1 The renter's given name. Required.last_name· string · required · min length 1 The renter's family name. Required.email· string · required · pattern^[^@\s]+@[^@\s]+\.[^@\s]+$The renter's email address. Required — it is how the operator reaches the customer about this rental. Pattern-checked for shape only; a syntactically valid address that does not exist is accepted here and fails later.phone· string · optional · pattern^\+?[0-9 ().-]{5,32}$The renter's telephone number, digits with an optional leading+and common separators. Optional, and worth sending: it is what the desk uses when a handover goes wrong.country· string · optional · pattern^[A-Z]{2}$The renter's country of residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional. It is a customer attribute; the driver country is the one on eachdrivers[]entry.locale· string · optional · pattern^[a-z]{2}(-[A-Z]{2})?$The renter's preferred language as a BCP-47-style tag (en, oren-GB). Optional. Readconstants.localeson GET /v1/reference/constants for what can actually be honoured rather than assuming a tag is supported.
expected_price· object · optional · no other members The price-consent gate: the supplier grand total you are consenting to, never a figure you computed or added fees to. If the computed total differs, the request fails409price_changedwith the full new breakdown, so you can re-consent and resubmit; nothing is ever repriced silently. Required only for a pricing-relevant change (vehicle, protection, extras, drivers or dates): omitting it there is422unless your account accepts the supplier price without a consent gate. A metadata-only patch ignores it.grand_total· MoneyInput · required The supplier total you are consenting to, in integer minor units with its currency (§1.6) — the same figure a quote reports astotals.grand_total, tax included. It is compared exactly; it is not a tolerance.
flight_number· string or null · optional The customer's inbound flight. Send it to set or correct one;nullclears it. Not pricing-relevant, so a patch carrying only this is a metadata-only change.notes· string or null · optional Free-text detail;nullclears it. Replaces the stored value, never appends, and does not re-price. Read by desk staff, never machine-actioned; for a delivery-priced location it carries the hotel.meta· object · optional Your own open object (§1.8b), stored verbatim and returned on the booking. It is outside the intent hash (§1.8), so changing it never causes a409. Two namespaces are reserved:meta.billing, the invoicing identity a company rental needs (tax identifier, company name, address, invoice email), andmeta.partner, your own correlation data (booking id, agent, campaign), which is never parsed; both are optional. It stays structurally open, so a payload accepted today is never refused later. On a patch it replaces the stored object rather than merging: send the whole object.distribution_channel· object or null · optional · no other members Corrects the downstream channel this booking was sold through. An object names a channel, judged against the operator's register now;nullremoves the recorded channel unless your agreement requires one. Omit it to keep the recorded channel: it is not judged again, even if that channel has since been suspended. Re-sending the recorded code changes nothing.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
Example request:
{
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}
Responses
- 200 — Modified (or a no-op patch — see description). ·
data: Booking
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"supplier_id": "acct_00000000000000000000000000000000",
"price": {
"totals": {
"pay_now": {
"amount": 9600,
"currency": "EUR"
},
"tax_total": {
"amount": 1858,
"currency": "EUR"
},
"grand_total": {
"amount": 9600,
"currency": "EUR"
},
"pay_at_pickup": {
"amount": 0,
"currency": "EUR"
},
"subtotal_ex_tax": {
"amount": 7742,
"currency": "EUR"
}
},
"deposit": {
"type": "pre_authorization",
"amount": {
"amount": 30000,
"currency": "EUR"
},
"refundable": true,
"collected_at": "pickup"
},
"currency": "EUR",
"line_items": [
{
"code": "TIME_MILEAGE",
"kind": "base_rate",
"label": "Rental (4 days)",
"total": {
"amount": 9600,
"currency": "EUR"
},
"payable": "prepaid",
"taxable": true,
"quantity": 4,
"unit_price": {
"amount": 2400,
"currency": "EUR"
}
}
],
"rental_days": 4,
"tax_breakdown": [
{
"code": "VAT",
"rate": 0.24,
"amount": {
"amount": 1858,
"currency": "EUR"
},
"taxable_base": {
"amount": 7742,
"currency": "EUR"
}
}
]
},
"extras": [],
"pickup": {
"at": "2026-10-27T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"status": "confirmed",
"drivers": [
{
"age": 30,
"role": "main",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
}
],
"dropoff": {
"at": "2026-10-31T10:00:00Z",
"name": "Sandbox Airport",
"location_id": "1"
},
"vehicle": {
"acriss": "ECMR",
"vehicle_id": "veh_2a5bcba25e8b",
"display_name": "Sandbox Economy (or similar)",
"allocated_category": "ECON",
"allocated_vehicle_id": "veh_2a5bcba25e8b"
},
"customer": {
"email": "[email protected]",
"country": "GB",
"last_name": "Roe",
"first_name": "Jane"
},
"policies": {
"mileage": {
"type": "unlimited"
},
"fuel": {
"type": "full_to_full"
},
"cancellation": {
"type": "free_until"
}
},
"quote_id": "15e8c4c7-6420-4c90-af43-6f74aeebaaff",
"revision": 2,
"booking_id": "d022fac0-3abf-4a52-ab44-74dfac2e20d7",
"created_at": "2026-09-07T07:00:30.746894+00:00",
"protection": {
"tier": "basic",
"excess": {
"amount": 150000,
"currency": "EUR"
},
"deposit": {
"amount": 30000,
"currency": "EUR"
},
"protection_id": "prot_a8958631922d"
},
"updated_at": "2026-09-07T07:00:31.420017+00:00",
"rental_days": 4,
"external_ref": "docs-modify2-070030",
"cancellation_policy": {
"type": "free_until",
"source": "operator_default",
"fee_basis": "grand_total",
"free_until": "2026-10-27T10:00:00Z",
"late_fee_percent": 0
},
"flight_number": "A3 991"
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 404 not_found · 409 invalid_transition · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# modifyBookingAlias — POST /v1/bookings/{ref}/modify
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/bookings/<external_ref>/modify" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// modifyBookingAlias — POST /v1/bookings/{ref}/modify
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
import { randomUUID } from 'node:crypto';
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/bookings/<external_ref>/modify`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
body: JSON.stringify({
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# modifyBookingAlias — POST /v1/bookings/{ref}/modify
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <external_ref> = your own booking reference — the external_ref you sent on POST /v1/bookings.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, uuid
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"expected_revision": 1,
"flight_number": "A3 993",
"notes": "Customer lands late — please hold the car until 23:00"
}).encode()
req = urllib.request.Request(f"{base}/v1/bookings/<external_ref>/modify", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Webhooks — your endpoint
GET /v1/webhooks/endpoint
Where we deliver your events, and whether a signing secret and rotation overlap exist.
Your account's webhook configuration, from this key; there is no account selector. No secret material is returned: secret_set says a secret exists, and secret_overlap_open says deliveries are signed with two secrets while a rotation is in flight. enabled says whether the operator has armed delivery — an operator action, because arming decides which events you receive from which instant.
- Operation:
getWebhookEndpoint· Scope:read:webhooks - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK -- your webhook configuration. ·
data: WebhookEndpointConfigmetaon this response carries:suppliers_unavailable· array of string · optional Present only when this answer could not read every supplier your key reaches (1.48.0). Each entry is asupplier_idwhose configuration is unread and absent fromsuppliers; every shared flag is then false and every shared value null. Ask again.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": {
"url": null,
"enabled": false,
"enabled_at": null,
"secret_set": false,
"secret_overlap_open": false
},
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getWebhookEndpoint — GET /v1/webhooks/endpoint
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/webhooks/endpoint" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getWebhookEndpoint — GET /v1/webhooks/endpoint
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/webhooks/endpoint`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getWebhookEndpoint — GET /v1/webhooks/endpoint
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/webhooks/endpoint", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
PUT /v1/webhooks/endpoint
Re-point your callback. Takes effect immediately, and is recorded with the URL it replaced.
Sets the URL your events are delivered to. The stream carries personal data — customers' names, contact details and itineraries — so treat a change as a security change: it applies at once to undelivered events, and the operator's audit trail records the new and the old URL. The URL must be deliverable: https only, no embedded credentials, and not resolving to a private, loopback, link-local, CGNAT or other reserved address; otherwise 422 and nothing changes. This route does not arm delivery and does not accept enabled; ask the operator. Whoever holds a key with write:webhooks can redirect your events.
- Operation:
setWebhookEndpoint· Scope:write:webhooks - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — WebhookEndpointRequest
url· string · required · max length 500 The HTTPS URL we will POST your events to. Must be a public endpoint: https only, no credentials embedded in the URL, and it must not resolve to a private, loopback, link-local, CGNAT or otherwise reserved address — the same check the delivery worker applies before every send, so a URL accepted here is one we can actually deliver to. A URL that fails is refused 422 and nothing is stored. Changing this takes effect immediately for events that have not yet been delivered, and it is recorded in the operator's audit trail together with the URL it replaced.
Example request:
{
"url": "https://hooks.your-domain.example/inbound"
}
Responses
- 200 — Updated -- your configuration as it now stands. ·
data: WebhookEndpointConfig
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# setWebhookEndpoint — PUT /v1/webhooks/endpoint
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X PUT "$CHANNEL_BASE_URL/v1/webhooks/endpoint" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"url": "https://hooks.your-domain.example/inbound"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// setWebhookEndpoint — PUT /v1/webhooks/endpoint
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/webhooks/endpoint`, {
method: 'PUT',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
"url": "https://hooks.your-domain.example/inbound"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# setWebhookEndpoint — PUT /v1/webhooks/endpoint
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"url": "https://hooks.your-domain.example/inbound"
}).encode()
req = urllib.request.Request(f"{base}/v1/webhooks/endpoint", method='PUT', headers={'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/webhooks/secret/rotate
Issue a new signing secret. BOTH secrets sign every delivery until your next rotation.
Installs a new signing secret and keeps the current one in an overlap slot: every delivery is then signed with both, one v1= parameter each in X-Caravel-Signature, so a receiver that accepts any verifying parameter needs no change. The new secret is returned once, in this response; if you lose it, rotate again. The previous secret keeps signing until your next rotation replaces it — there is no timer — so at most two are ever live. Your first secret comes from the operator, out of band, and until it is set this route answers 409. The optional body carries only a note for the operator's audit trail.
- Operation:
rotateWebhookSecret· Scope:write:webhooks - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — WebhookSecretRotateRequest
reason· string · optional · max length 500 Optional free text recorded in the operator's audit trail — why you rotated. Write something they can act on ("suspected leak in CI logs", "routine quarterly rotation"). It is stored in an immutable audit record the operator reads, so it must carry no secret and no personal data: never a key, a token, a password, a customer name or an email address.
Example request:
{
"reason": "scheduled quarterly rotation"
}
Responses
- 200 — Rotated. The secret in
secretis shown once and is not recoverable afterwards. ·data: RotatedWebhookSecret
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 409 no_webhook_secret · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# rotateWebhookSecret — POST /v1/webhooks/secret/rotate
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/webhooks/secret/rotate" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"reason": "scheduled quarterly rotation"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// rotateWebhookSecret — POST /v1/webhooks/secret/rotate
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/webhooks/secret/rotate`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
"reason": "scheduled quarterly rotation"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# rotateWebhookSecret — POST /v1/webhooks/secret/rotate
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"reason": "scheduled quarterly rotation"
}).encode()
req = urllib.request.Request(f"{base}/v1/webhooks/secret/rotate", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Webhooks — deliveries
GET /v1/webhooks/deliveries
Read this partner’s own webhook delivery log — what we allocated onto your stream, and what happened to each delivery.
Your deliveries and what became of each: read it when the envelope's sequence shows a gap, and to scope a redelivery. Scoped to this key's account; there is no partner selector. The log is current state, not history: a requeue or a replay resets a row, so attempts, last_status_code, last_failure_class and last_attempt_at count from the last reset, and a replay clears delivered_at until it succeeds. Read before you replay. Newest first by (occurred_at, event_id), both descending — a total order; continue with meta.next_page as page_token. There is no meta.as_of, and updated_since is not accepted. Filters apply page by page, so diagnose from an unfiltered walk. booking_id is the public id the envelope carries.
- Operation:
listWebhookDeliveries· Scope:read:webhooks - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.status(query) · unspecified —delivered·retrying·exhausted· optional Filter by delivery state. One partner word selects every internal state it covers, soretryingreturns both queued and in-flight deliveries.occurred_since(query) · string (date-time) · optional Full ISO-8601 with offset. Filters on the event’S occurrence instant (occurred_at), not on when we last attempted it.occurred_until(query) · string (date-time) · optional Full ISO-8601 with offset. Inclusive upper bound onoccurred_at.booking_id(query) · string (uuid) · optional The public booking id — the same one the delivery envelope carries, not an internal identifier.event_id(query) · string (uuid) · optional One event, by the id the envelope carries as your dedupe key.sequence_from(query) · integer · optional Lower bound on your stream number for this subscription. The ceiling is the allocator’s own, so a larger value is a 422 rather than a 500.sequence_to(query) · integer · optional Upper bound on your stream number for this subscription. Pair it withsequence_fromto scope the exact gap you detected.limit(query) · integer · optionalpage_token(query) · string · optional Opaque, endpoint-specific cursor from meta.next_page — pass back verbatim, never construct (§1.9). A token from a different endpoint is rejected 422. When present, updated_since is ignored.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK — a page of your deliveries, newest occurrence first. ·
data: array of WebhookDeliverymetaon this response carries:next_page· string or null · required Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one. A token minted by another endpoint is rejected 422.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.46.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [],
"meta": {
"next_page": null
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listWebhookDeliveries — GET /v1/webhooks/deliveries
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/webhooks/deliveries" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listWebhookDeliveries — GET /v1/webhooks/deliveries
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/webhooks/deliveries`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listWebhookDeliveries — GET /v1/webhooks/deliveries
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/webhooks/deliveries", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/webhooks/test
Ask for a signed test delivery
Sends a synthetic webhook.test event — the WEBHOOKS.md §1 envelope over a test booking (external_ref WEBHOOK-TEST-…, data.test: true), signed like a real delivery (§2) — so you can prove your receiver before any booking exists. A self-serve sandbox account is never armed: its test is captured by the hosted inspector, returned here and on your account page, and nothing leaves the host. An armed account's is posted once to its endpoint, the result synchronous (delivered_to, status, ok). Otherwise 409 webhooks_disabled. Never retried, never in the delivery log.
- Operation:
sendWebhookTestEvent· Scope:write:webhooks - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — WebhookTestRequest
No members.
Example request:
{}
Responses
- 200 — The test delivery, captured (self-serve) or delivered once (armed). ·
data: object
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# sendWebhookTestEvent — POST /v1/webhooks/test
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/webhooks/test" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// sendWebhookTestEvent — POST /v1/webhooks/test
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/webhooks/test`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# sendWebhookTestEvent — POST /v1/webhooks/test
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({}).encode()
req = urllib.request.Request(f"{base}/v1/webhooks/test", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/webhooks/redeliver
Ask us to re-send deliveries to your endpoint
Re-drives deliveries already made, or given up on, to your registered endpoint: the same event_id and sequence, freshly signed, so your receiver needs no new code. Asynchronous: a 202 promises queueing, not delivery, and replays pass through the normal delivery worker and its caps. It replays every selected revision not superseded by a delivered later one — one row for a fully delivered booking, the whole chain for a dead one. It covers deliveries that existed when it was accepted, so re-run a window that reaches now. Read your delivery log first: a replay resets attempts, last_status_code and delivered_at.
- Operation:
redeliverWebhooks· Scope:write:webhooks - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.Idempotency-Key(header) · string · required Required (§1.8) — a network-retried replay must not spend the budget twice.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — WebhookRedeliverRequest
No members.
Example request:
{
"sequence_from": 4120,
"sequence_to": 4180
}
Responses
- 202 — Queued. The counts are the outcome per selected delivery. ·
data: object
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 409 webhooks_disabled · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# redeliverWebhooks — POST /v1/webhooks/redeliver
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/webhooks/redeliver" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"sequence_from": 4120,
"sequence_to": 4180
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// redeliverWebhooks — POST /v1/webhooks/redeliver
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
import { randomUUID } from 'node:crypto';
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/webhooks/redeliver`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': randomUUID(), 'Content-Type': 'application/json' },
body: JSON.stringify({
"sequence_from": 4120,
"sequence_to": 4180
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# redeliverWebhooks — POST /v1/webhooks/redeliver
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error, uuid
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"sequence_from": 4120,
"sequence_to": 4180
}).encode()
req = urllib.request.Request(f"{base}/v1/webhooks/redeliver", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Idempotency-Key': str(uuid.uuid4()), 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Settlements
GET /v1/settlements
Your settlement periods with this operator — commission expected, received and still outstanding.
Your settlements with this key's operator, most recent period first. There is no partner selector, and figures are never aggregated across operators. Money is {amount, currency} in minor units (§1.6); amount_outstanding = amount_expected + amount_adjusted − amount_received, what is still owed to you. Commission rates and the operator's net are never returned. The order (period_start descending, settlement_id ascending) is total, so an unfiltered walk returns every period that existed when it started exactly once. A status filter applies page by page and can miss a period that changes mid-walk: reconcile from an unfiltered walk. Continue with meta.next_page as page_token; the token keeps the walk's filters. meta.as_of is fixed at the walk's start: replay it as updated_since.
- Operation:
listSettlements· Scope:read:settlements - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.status(query) · unspecified —open·partial·settled·disputed· optional Filter by settlement status.disputedmeans the operator has placed an explicit hold on that period's figures — see §11.1 for how a dispute is raised.limit(query) · integer · optionalpage_token(query) · string · optional Opaque, endpoint-specific cursor from meta.next_page — pass back verbatim, never construct (§1.9). A token from a different endpoint is rejected 422. When present, updated_since is ignored.updated_since(query) · string · optional A full ISO-8601 datetime with an explicit offset (for example2026-07-04T10:00:00Z): pass back the previous response'smeta.as_of. A date or an offsetless value is refused422. Ignored whenpage_tokenis present.GET /v1/reference/policiesandGET /v1/bookingsnarrow on it and page with a keyset cursor — a true incremental sync. The five catalogues (locations, vehicles, extras, protections, fees) accept it but return everything in one page: upsert each record by id.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK — your settlement periods, most recent first. ·
data: array of Settlementmetaon this response carries:next_page· string or null · optional Opaque cursor for the next page; null when exhausted (§1.9). Pass it back verbatim aspage_token— never construct or parse one, and never replay a token against a different endpoint: tokens are endpoint-specific and a foreign one is rejected 422. While a cursor is in playupdated_sinceis ignored, because the cursor owns the walk position.suppliers_unavailable· array of string · optional Present only when this page could not read every supplier your key reaches (1.46.0). Each entry is asupplier_idwhose rows are withheld here: unread, not deleted and not empty. Such a page is partial, so never read it as evidence that records were removed, and re-request before you reconcile. The suppliers that answered are complete.as_of_by_supplier· object · optional Theas_ofof each supplier that answered, keyed bysupplier_id(1.46.0), for diagnosis rather than for resuming. Replay the top-levelas_of, which is the earliest of them: a per-supplier value sits ahead of the slowest supplier, and replaying it skips that supplier’s rows permanently.as_of· string · optional Server snapshot time — store and pass as the next updated_since (§1.10). It is the instant this page was read, so storing it after a partial walk would skip what you have not fetched: follownext_pageto exhaustion first, then store. Three of the eight in-loop collections narrow on the value you pass back; the other five return the full catalogue and are safe to build the same loop against.
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [],
"meta": {
"next_page": null,
"as_of": "2026-09-07T06:54:23.028016+00:00"
}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listSettlements — GET /v1/settlements
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/settlements" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listSettlements — GET /v1/settlements
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/settlements`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listSettlements — GET /v1/settlements
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/settlements", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
GET /v1/settlements/{settlement_id}
One settlement period with its per-booking lines, keyed to your own booking references.
One settlement period with its lines: allocations (commission earned per booking) and adjustments (signed corrections — a clawback after a cancellation, a top-up after an upward modify). Lines carry your external_ref, the join key, and the operator's booking_ref; either may be null after anonymisation. Each array pages 1000 lines at a time, independently: allocations_next_page / adjustments_next_page continue as allocations_page_token / adjustments_page_token, and an omitted token re-serves the first page, so keep a finished flag per array. Sum the lines, never the headers; reconcile against the last page's header, and re-walk before disputing a sum that does not close (§11.2). A settlement that is not yours answers 404.
- Operation:
getSettlement· Scope:read:settlements - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.settlement_id(path) · string · requiredallocations_page_token(query) · string · optional Opaque cursor from a previous response'sallocations_next_page— pass back verbatim, never construct (§1.9). A token from another endpoint, another array, or another settlement is rejected 422.adjustments_page_token(query) · string · optional Opaque cursor from a previous response'sadjustments_next_page— pass back verbatim, never construct (§1.9). A token from another endpoint, another array, or another settlement is rejected 422.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: SettlementDetail
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# getSettlement — GET /v1/settlements/{settlement_id}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <settlement_id> = a settlement_id from GET /v1/settlements.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/settlements/<settlement_id>" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// getSettlement — GET /v1/settlements/{settlement_id}
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <settlement_id> = a settlement_id from GET /v1/settlements.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/settlements/<settlement_id>`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# getSettlement — GET /v1/settlements/{settlement_id}
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <settlement_id> = a settlement_id from GET /v1/settlements.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/settlements/<settlement_id>", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Credentials
GET /v1/credentials
The API keys on your own account — display prefix, scopes, status and any rotation deadline.
Every key on your account, including keys that no longer work, so you can watch a rotated key's overlap end. Scoped to this key's account; there is no account selector. No key material is returned: only a SHA-256 hash of each key is stored, and a key is shown once, at issue. Identify keys by prefix. To ask whether a key works now, read authenticable, not status: a key retired by a rotation keeps status: "active" even after its expires_at passes.
- Operation:
listCredentials· Scope:read:credentials - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK — every key on your account, newest first. ·
data: array of ApiCredential
Example response (200):
{
"request_id": "req_EXAMPLE00000000",
"data": [
{
"key_id": "11111111-2222-4333-8444-555555555555",
"prefix": "chk_EXAMPLE0",
"scopes": [
"read:reference"
],
"status": "active",
"authenticable": true,
"created_at": "2026-09-07 06:54:23.540533+00",
"expires_at": null,
"rotated_at": null,
"replaced_key_id": null,
"revoked_at": null,
"is_current": true
}
],
"meta": {}
}
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listCredentials — GET /v1/credentials
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/credentials" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listCredentials — GET /v1/credentials
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/credentials`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listCredentials — GET /v1/credentials
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/credentials", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/credentials/rotate
Issue a successor to the key you are presenting, with an overlap window in which BOTH keys work.
Rotates the key that signed this request and issues a successor with exactly its scopes; only the operator changes scopes. The new key is returned once, in this response. Until replaces.expires_at both keys authenticate, so deploy on your own clock; after it, the old key answers 401 like an unknown key. One rotation at a time: while an overlap is open, another rotate answers 409 rotation_in_progress — present the new key to POST /v1/credentials/{key_id}/revoke to retire the old one, then rotate again. Lost the new key? Ask the operator to revoke it; the old key can then rotate again before its deadline. After a compromise, send a short overlap_seconds and revoke the old key once the new one is live.
- Operation:
rotateCredential· Scope:write:credentials - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — CredentialRotateRequest
overlap_seconds· integer · optional · ≥ 300; ≤ 604800 How long the key being replaced keeps working, in seconds. Default 86400 (24h) when omitted. Minimum 300 — below that an "overlap" cannot survive a rolling restart, so it would be a hard cut-over wearing a window's name. Maximum 604800 (7 days) — a retired credential should not outlive a support cycle. Rotating because a key may be compromised? Pass a short window and revoke the old key as soon as the new one is live.
Example request:
{
"overlap_seconds": 86400
}
Responses
- 200 — Rotated. The plaintext key in
keyis shown once and is not recoverable afterwards. ·data: RotatedCredential
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 409 rotation_in_progress · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# rotateCredential — POST /v1/credentials/rotate
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/credentials/rotate" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"overlap_seconds": 86400
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// rotateCredential — POST /v1/credentials/rotate
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/credentials/rotate`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
"overlap_seconds": 86400
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# rotateCredential — POST /v1/credentials/rotate
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"overlap_seconds": 86400
}).encode()
req = urllib.request.Request(f"{base}/v1/credentials/rotate", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
POST /v1/credentials/{key_id}/revoke
Retire your key's rotation neighbour — how you end an overlap early, or recover a lost reveal.
Revokes the key your key replaced (replaces.key_id from the rotation that issued it), immediately and permanently. After a compromise: rotate, deploy the successor, then present the successor here to end the old key's overlap early. Any other target answers 409 — the key itself, its successor, an unrelated key, or anything while your key has never rotated; ask the operator for those. The narrowness is deliberate: a thief holding the old key cannot revoke or rotate your replacement. If a rotation response is lost, keep using the old key until its deadline and ask the operator to revoke the successor, which reopens rotation. A key not on your account answers 404.
- Operation:
revokeCredential· Scope:write:credentials - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.key_id(path) · string · required Thekey_idof the key to retire, as returned by GET /v1/credentials. Never the key material itself.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — CredentialRevokeRequest
reason· string · optional · max length 500 Optional free text recorded against the retired key and in the operator's audit trail — why you revoked it. Write something an operator can act on ("suspected leak in CI logs", "routine post-rotation cleanup"). It is stored in an immutable audit record and is visible to the operator, so it must carry no secret and no personal data: never a key, a token, a password, a customer name or an email address.
Example request:
{
"reason": "replaced by its successor"
}
Responses
- 200 — Revoked — the key's final state. This operation is idempotent: revoking a key that is already revoked also answers 200 with that key, because the end state you asked for holds. A retry after a lost response therefore succeeds rather than erroring. ·
data: ApiCredential
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 404 not_found · 409 not_your_predecessor · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# revokeCredential — POST /v1/credentials/{key_id}/revoke
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <key_id> = a key_id from GET /v1/credentials.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X POST "$CHANNEL_BASE_URL/v1/credentials/<key_id>/revoke" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"reason": "replaced by its successor"
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// revokeCredential — POST /v1/credentials/{key_id}/revoke
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// <angle-bracket> values come from an earlier call: <key_id> = a key_id from GET /v1/credentials.
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/credentials/<key_id>/revoke`, {
method: 'POST',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
"reason": "replaced by its successor"
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# revokeCredential — POST /v1/credentials/{key_id}/revoke
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# <angle-bracket> values come from an earlier call: <key_id> = a key_id from GET /v1/credentials.
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"reason": "replaced by its successor"
}).encode()
req = urllib.request.Request(f"{base}/v1/credentials/<key_id>/revoke", method='POST', headers={'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
partner-codes
GET /v1/partner-codes
The objects you can read, each with the code you keep for it (§3.15).
One row for every vehicle group, protection, plan, station and accessory the reference reads publish to you, with code (the code you keep for it) or null. It is the set PUT accepts. Accept: text/csv returns the same rows as a file with the columns kind,id,name,code, to fill in and send back. meta.self_service.enabled says whether the supplier has switched on your recording your own codes; reading never needs it.
- Operation:
listPartnerCodes· Scope:read:partner-codes - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Responses
- 200 — OK ·
data: array of PartnerCodeRowmetaon this response carries:self_service· object · optional Your standing with this supplier for recording your own codes.enabled· boolean · required True when the supplier has switched on your account recording its own codes. While false,PUTanswers 403.saves_per_hour· integer · required The most saves that change something you may make in a rolling hour.
Errors this operation answers (each one the envelope in ERRORS.md): 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# listPartnerCodes — GET /v1/partner-codes
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X GET "$CHANNEL_BASE_URL/v1/partner-codes" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json"
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// listPartnerCodes — GET /v1/partner-codes
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/partner-codes`, {
method: 'GET',
headers: { 'X-API-Key': key, 'Accept': 'application/json' },
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# listPartnerCodes — GET /v1/partner-codes
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
req = urllib.request.Request(f"{base}/v1/partner-codes", method='GET', headers={'X-API-Key': key, 'Accept': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
PUT /v1/partner-codes
Record, change or take off your own codes — all or nothing, every problem reported at once (§3.15).
Sets code on the objects you name and takes it off with null; an object you do not list is untouched. JSON, or text/csv with the columns kind,id,code. The shape is checked first; then every entry is checked and reports all its own problems, and the request is refused whole with them (422). dry_run=true writes nothing and does not test the hourly cap. Off until the supplier switches it on for you (403, issue partner_codes_not_enabled); 30 saves an hour (429).
- Operation:
savePartnerCodes· Scope:write:partner-codes - Response headers:
Content-Language,Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id
Parameters
X-API-Version(header) · string · optional Optional and never required. Send1, or the exact version your account is pinned to (GET /v1/whoami), or omit it; anything else is 409 version_mismatch. It asserts the version you expect and never selects one — naming a version you are not pinned to is refused, not served. The path is the major.dry_run(query) · boolean · optional Whentrue, every check on the request runs and nothing is written; the response counts what a real save would do. It does not test the hourly cap, which depends on other saves, so a dry run that answers200can be followed by429.X-Request-Id(header) · string · optional Optional. Supply your own request id (1–128 characters of A–Z, a–z, 0–9,_,.,:,-) and every response carries it back, both in the X-Request-Id header and as request_id in the envelope; send nothing and one is minted for you. Quote it when you ask about a specific call (§1.5, §13). A value outside that alphabet is not an error: it is ignored and a minted id is used instead.X-Supplier-Id(header) · string · optional Optional (§1.4): every id you send is unique across suppliers, external_ref resolves across every supplier your key reaches, and an id-less call covers them all. Send it to narrow a call to one supplier, using asupplier_idfrom GET /v1/suppliers; a value you cannot sell is 422 unknown_supplier. A delivery sequence range, listed or replayed, counts one supplier's stream: a multi-supplier key sends this header, or gets 409 supplier_required.
Request body — PartnerCodeSaveRequest
entries· array of PartnerCodeEntryInput · required · min items 1; max items 2000 The objects to put a code on or take a code off. An object you do not list is untouched. One object may appear once.
Example request:
{
"entries": [
{
"kind": "vehicle",
"id": "veh_econ_a",
"code": "A1"
},
{
"kind": "location",
"id": "1",
"code": "HER1"
},
{
"kind": "accessory",
"id": "extra_1",
"code": null
}
]
}
Responses
- 200 — Applied, or (with
dry_run=true) what a save would do. ·data: PartnerCodeSaveResultmetaon this response carries:saves_per_hour· integer · optional The most saves that change something you may make in a rolling hour.
Errors this operation answers (each one the envelope in ERRORS.md): 400 malformed_request · 401 unauthenticated · 403 forbidden · 422 validation_error · 429 rate_limited · 503 service_unavailable
Samples — runnable against the sandbox with CHANNEL_BASE_URL and CHANNEL_API_KEY in the environment; the same files ship in this pack under samples/ and are executed against the contract in CI.
#!/usr/bin/env bash
# savePartnerCodes — PUT /v1/partner-codes
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
set -euo pipefail
: "${CHANNEL_BASE_URL:?set CHANNEL_BASE_URL to your sandbox base URL}"
: "${CHANNEL_API_KEY:?set CHANNEL_API_KEY to your sandbox key}"
KEY="$(printf '%s' "$CHANNEL_API_KEY" | tr -d '[:space:]')" # a key never contains whitespace; a stray space must not dodge the guard below
if [[ "$KEY" == chk_live_* && "${CHANNEL_ALLOW_LIVE:-}" != "1" ]]; then echo "refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)" >&2; exit 3; fi
RESPONSE="$(mktemp)"
curl -sS -o "$RESPONSE" -w "STATUS %{http_code}\n" -X PUT "$CHANNEL_BASE_URL/v1/partner-codes" \
-H "X-API-Key: $KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
--data @- <<'JSON'
{
"entries": [
{
"kind": "vehicle",
"id": "veh_2a5bcba25e8b",
"code": "A1"
},
{
"kind": "location",
"id": "1",
"code": "HER1"
},
{
"kind": "accessory",
"id": "extra_1",
"code": null
}
]
}
JSON
cat "$RESPONSE"; echo; rm -f "$RESPONSE"
// savePartnerCodes — PUT /v1/partner-codes
// Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
// These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
// Prints the status on one line, then the JSON body.
const base = process.env.CHANNEL_BASE_URL;
const key = (process.env.CHANNEL_API_KEY ?? '').trim(); // a key never contains whitespace; a stray space must not dodge the guard below
if (!base || !key) throw new Error('set CHANNEL_BASE_URL and CHANNEL_API_KEY');
if (key.startsWith('chk_live_') && process.env.CHANNEL_ALLOW_LIVE !== '1') throw new Error('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)');
const res = await fetch(`${base}/v1/partner-codes`, {
method: 'PUT',
headers: { 'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
"entries": [
{
"kind": "vehicle",
"id": "veh_2a5bcba25e8b",
"code": "A1"
},
{
"kind": "location",
"id": "1",
"code": "HER1"
},
{
"kind": "accessory",
"id": "extra_1",
"code": null
}
]
}),
});
console.log('STATUS ' + res.status);
console.log(JSON.stringify(await res.json(), null, 2));
# savePartnerCodes — PUT /v1/partner-codes
# Values are the sandbox fixture; your code reads its own from GET /v1/reference/* (Channel API §3).
# These samples are for your SANDBOX: a production key (chk_live_…) is refused unless CHANNEL_ALLOW_LIVE=1.
# Prints the status on one line, then the JSON body.
import json, os, urllib.request, urllib.error
base = os.environ['CHANNEL_BASE_URL']
key = os.environ['CHANNEL_API_KEY'].strip() # a key never contains whitespace; a stray space must not dodge the guard below
if key.startswith('chk_live_') and os.environ.get('CHANNEL_ALLOW_LIVE') != '1':
raise SystemExit('refusing a production key — these samples are for the sandbox (set CHANNEL_ALLOW_LIVE=1 to override)')
body = json.dumps({
"entries": [
{
"kind": "vehicle",
"id": "veh_2a5bcba25e8b",
"code": "A1"
},
{
"kind": "location",
"id": "1",
"code": "HER1"
},
{
"kind": "accessory",
"id": "extra_1",
"code": None
}
]
}).encode()
req = urllib.request.Request(f"{base}/v1/partner-codes", method='PUT', headers={'X-API-Key': key, 'Accept': 'application/json', 'Content-Type': 'application/json'}, data=body)
try:
with urllib.request.urlopen(req, timeout=20) as res:
status, raw = res.status, res.read()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read()
print('STATUS ' + str(status))
print(json.dumps(json.loads(raw), indent=2))
Objects
Location
supplier_id· string · optional · min length 1 The supplier this station belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.location_id· string · required · min length 1 Opaque, stable identifier of this station: map your own location ids to it; availability, quote and booking accept only this form (§2.1). Treat it as a string — never parse, construct or retype one. It is never reused for another station, and a desk that closes and re-opens returns under the same id. A closed desk leaves the list, and its id is then refused at availability, quote and booking like an unknown one (§3.2): keep the list on the §1.10 sync loop and stop offering a station that has left it.code· string or null · optional The operator's own short label for this desk (for exampleHER-APT), unique within the operator. Once set it never changes, is never cleared and is never reissued to another desk, even after this one is withdrawn — so it is safe to map on, per operator. Oftennull: operators are still assigning codes. Preferlocation_idwhere you can; for an airport,iata(§2.1) names the airport, which may have several desks. A withdrawn desk leaves this collection, so a code you no longer see means that desk is not currently sellable, never that the code means something else.partner_code· string or null · optional · min length 1; max length 128 Your own code for this station, such asHER1, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this station. It is notcode, which is the supplier's own label, and it never changes whatlocation_ididentifies.type· string —airport·port·office·city·hotel·address·other· (extensible: new members may appear) · required What kind of station this is — coarse: it cannot tell an in-terminal desk from an off-airport shuttle depot, so readshuttle_infoandterminalfirst.airport(withiata, where stated),port(a ferry or cruise terminal),office(a downtown or standalone branch),city(a city-centre meeting point),hotelandaddress(an operator-defined delivery area, not where your customer stays) — both typically carrydelivery.otherfits none of these; you will receive it, so handle it (§8.2).name· string · required · min length 1 The station's display name, as the operator wrote it. For display only — never match, branch or reconcile on it;location_idis the identifier.iata· string or null · optional · pattern^[A-Z]{3}$The three-letter IATA code of the airport this station serves, uppercase. Useful as a secondary key when you map an airport station into your own catalogue. NULL means the operator has not stated one — never that the station is not at an airport, and never a code you may derive from the name (§2.1: absence and null both mean 'the operator has not stated this').address· object · required The station's postal address. The object itself is always present; every member inside it is optional and each is omitted individually where the operator has not stated it, so an address carrying only some members is the normal case and not a truncated response.line1· string · optional Street line — building number and street, as the operator typed it. Optional and omitted where not stated, like every member of this object, so within one response some stations carry it and some do not; an address renderer must never require it. Absence means not stated, never an empty string (§2.1).city· string · optional Town or city. Optional and omitted where the operator has not stated it — absence is 'not stated', never an empty string.region· string · optional Region, state or province. Optional and omitted where not stated.postal_code· string · optional Postal or ZIP code, as the operator entered it — free-form text, not validated against any country's format. Optional and omitted where not stated.country· string · optional · pattern^[A-Z]{2}$ISO 3166-1 alpha-2 country code, uppercase. Optional and omitted where the operator has not stated it.phone· string · optional A contact number for the desk, as the operator entered it — free-form, with no guaranteed dialling format. Optional and omitted where not stated. It is a station contact for a traveller, not a support channel for this API.
geo· object or null · required The station's coordinates, WGS84 decimal degrees. NULL means the operator has not stated them — never (0, 0), and never a point you may derive from the address. Where the object is present both members are present. Use it to place a station on a map, never to decide whether two stations are the same one:location_idis the identity.lat· number · required · ≥ -90; ≤ 90 Latitude in decimal degrees, −90 to 90. A genuine decimal: §1.6's integer-minor-units rule governs money, not coordinates.lng· number · required · ≥ -180; ≤ 180 Longitude in decimal degrees, −180 to 180. A genuine decimal, likelat.
timezone· string · required · min length 1 IANA time zone name (Region/City) — the clock this station's rules run on: itshours, the out-of-hours refusal and fee, and the billed day count (§6.2) all resolve local time in it, so a case near opening, closing or midnight can be settled from the catalogue. It does not say whether the zone was stated for this desk or inherited from the operator, and a catalogue read is a snapshot: a re-stated zone applies from then on, so the booking call is the authoritative answer (§2.1).hours· array of object or null · required The desk's weekly opening schedule — the one window both the out-of-hours refusal and the out-of-hours fee are judged against. A weekday with no entry is closed all day.nullmeans no readable schedule is stated, not always open; when it isnullor empty the desk's window cannot be derived from the catalogue, and the desk still refuses out-of-hours handovers wherever it has answeredout_of_hours.available: false(§2.1).holidays· array of object · optional Dates on which this desk departs from its weeklyhours— public holidays and other closures from the operator's calendar, as local dates in the desk'stimezone. Absent: the operator has not said whether the desk observes closures. Empty: it keeps its weekly hours all year.closedis always present:truemeans shut all day;falsemeans open fromopentoclose, which replace that date's weekly entry. The list holds upcoming dates and may keep a day or two already past at this desk, so match on the exactdate. Never present whenhoursisnull.date· string · required · pattern^\d{4}-\d{2}-\d{2}$The local calendar date at this desk, in its owntimezone.closed· boolean · required true = the desk is shut all day and no handover is possible in ordinary hours; false = the desk is open on the window stated beside this member.open· string · optional · pattern^\d{2}:\d{2}$Local opening time on that date, present only whenclosedis false. Acloseearlier thanopencrosses midnight, and acloseof00:00means end of day, exactly as inhours.close· string · optional · pattern^\d{2}:\d{2}$Local closing time on that date, present only whenclosedis false.
terminal· string · optional · min length 1 Free-text arrival detail naming the terminal, hall or level the desk sits in (1.7.0). Optional and omitted where the operator has not stated it. Feature-detect it with thereference_location_detailcode on GET /v1/capabilities rather than assuming every deployment publishes it.shuttle_info· string · optional · min length 1 Free-text arrival detail describing how a customer reaches the desk — read it before presenting A station as in-terminal (1.7.0).typeis too coarse to tell an in-terminal desk from an off-airport shuttle depot, and this is the member that can. Optional and omitted where not stated; feature-detected byreference_location_detailon GET /v1/capabilities.pickup_instructions· string · optional · min length 1 Free-text instructions for finding the desk or meeting point, to show a customer before travel (1.7.0). Optional and omitted where not stated; feature-detected byreference_location_detailon GET /v1/capabilities.out_of_hours· object or null · required Whether this desk performs a collection or a return outside its publishedhours, and what that costs. Three states, and the third is a refusal.null: not answered — an out-of-hours handover is accepted, and charged where a fee applies.{"available": true}: accepted, and charged where a fee applies.{"available": false}: refused on the booking path and, with the end-terms opt-in, at search. Never readnullas no, orfalseas a missing price (§2.1). Inside the published hours nothing changes.available· boolean · requiredfalse— this desk does not perform out-of-hours handovers, and one is refused: 422 validation_error naming the offending end, or 409 availability_changed where you are consuming a quote.true— it does, and a fee may apply. Required whenever the object is present, so the object is never a bare{}; the 'not stated' state is the whole object beingnull, not this member being absent.fee· Money · optional What an out-of-hours handover costs at this desk, in integer minor units (§1.6). Absent means the price is not stated, never that the service is free. It never accompaniesavailable: false, and it does not decide a refusal: an operator can refuse out-of-hours handovers without ever pricing one. The charge reaches you asout_of_hoursfee lines on a quote or booking breakdown, one per offending end; bill against those.note· string · optional Free-text detail about the out-of-hours arrangement, as the operator wrote it — for display only. Optional and omitted where not stated.
photos· array of object · optional Photographs of this desk — the building from the street, the entrance, the counter, the parking — to help a customer find the counter; present them in the given order. Nevernulland never empty: a desk with no stated photographs has nophotosmember, so treat absence as not stated, not as no photographs.reference_location_photosonGET /v1/capabilitiessays this deployment serves the member, never that a desk has any.url· string · required · min length 1 Absolute URL of the photograph. Fetch it exactly as given: never construct, rewrite or sign one, and assume no host, path shape or lifetime. It has no signature or expiry. A photograph is never replaced in place — a different picture is a different URL — but it can be withdrawn, and the URL then stops resolving: treat a404as ordinary and re-read the catalogue on your usual sync. Responses carryCache-Control: public, max-age=86400, must-revalidate, so a cache may serve a withdrawn photograph for up to a day.role· string · optional · min length 1; max length 40 Optional free-text hint at what the photograph shows —exterior,desk,parkingand similar. Not an enum and not a closed set: treat an unrecognised role as untagged rather than as a parse failure, and do not branch on it. Omitted where the operator did not tag the photograph, which is the ordinary case.
delivery· object or null · optional Whether the operator delivers a vehicle to this location, and what it costs — the same three states asout_of_hours. Absent ornull: not answered; accepted, and charged where a fee applies.{"available": true}: accepted, and charged where a fee applies.{"available": false}: where this location prices a delivery, every end placed here is refused, a counter collection included, because delivery belongs to the location, not your request; where it prices none, nothing is refused, and the catalogue cannot tell you which. So place neither end at a location publishing{"available": false}. Each end placed at a location that prices a delivery carries its owndeliveryline, and the two ends add (§2.1).available· boolean · requiredfalse— the operator does not deliver here, and an end placed here is refused wherever this location prices a delivery: 422 validation_error namingpickup.location_id/dropoff.location_id, or 409 availability_changed on a quoted create.true— delivery is offered, and a fee may apply. Required whenever the object is present; the 'not stated' state is the whole member being absent ornull, not this boolean being absent.fee· Money · optional What a delivery to this location costs, in integer minor units (§1.6). Optional, and its absence is neither zero nor free — it means the price is not stated. It never accompaniesavailable: false. What is charged reaches you as adeliveryfee line on a quote or booking breakdown — one for each end of the rental placed here (since 1.34.0 delivery is charged per end, so a rental delivered to and collected from this location carries two); there is nodeliveryrecord on GET /v1/reference/fees, because the charge is published here on the location instead (§3.6).radius_km· number · optional · ≥ 0 How far from this location the operator will deliver, in kilometres. Optional and omitted where the operator has not stated it — absence is 'not stated', never 'no limit' and never zero. It is display and pre-filter information: whether a given end is deliverable is answered authoritatively by the quote or booking call.
updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not a per-row stamp: every row's value moves together on every read, so do not detect changes with it — userow_updated_at, which?changed_since=narrows on. This collection does not narrow onupdated_since(§1.10): upsert by id against a full refresh, which is also how you notice a desk that left.row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). An archived desk leaves the list rather than changing here, because §3.2 publishes the operator's current selling estate.
Vehicle
supplier_id· string · optional · min length 1 The supplier this vehicle group belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.vehicle_id· string · required · min length 1 Opaque, stable identifier of this vehicle group — the value you map your own car classes to, and the one you send on availability, quote and booking. Treat it as a string: never parse it, never construct one. A group is a class ("this model or similar"), never a specific vehicle: you are selling the group, and the unit is chosen by the operator at handover.acriss· string or null · required · pattern^[A-Z]{4}$The four-character industry classification code for this group, uppercase, for like-for-like comparison across suppliers (§8.4 decodes it).nullmeans no code is assigned — none is ever invented — so fall back todisplay_name,example_modelsand the physical attributes. The operator can correct a code; a correction moves this group'srow_updated_at, so re-read it through?changed_since=(§1.10) rather than caching it once. A booking keeps its own frozenvehicle.acriss, so a correction never reclassifies a booking.acriss_decoded· object or null · required The fouracrisspositions expanded into human-readable words, so a results page can render them without shipping a decoding table. NULL wheneveracrissIS NULL — the two move together. Where the object is present all five members are present, but each may itself benullfor a position this platform cannot decode. Display these; classify onacriss.category· string or null · required Position 1 expanded — the size/class band (Mini, Economy, Compact, Intermediate, Standard, Fullsize, Premium, Luxury, Special …). Display text, not a closed vocabulary:nullwhere the position could not be decoded.type· string or null · required Position 2 expanded — the body type (2/4-door, wagon/estate, SUV, van, convertible …). Display text;nullwhere the position could not be decoded.transmission· string or null · required Position 3 expanded — manual or automatic. Display text;nullwhere the position could not be decoded. The sibling top-leveltransmissionmember is the operator-verified fact and comes from a different store; where both are present prefer that one and use this for display.drive· string or null · required Position 3's drive component expanded — 2WD/4WD/AWD, or an 'unspecified drive' wording where the code does not say. Display text;nullwhere the position could not be decoded.fuel_air· string or null · required Position 4 expanded — the fuel together with whether air conditioning is fitted, which that single character encodes as one fact. Display text;nullwhere the position could not be decoded. The top-levelfuelandair_conditioningmembers are the operator-verified facts.
name· string · required · min length 1 The operator's internal name for the group. For display and support conversations only — never match or reconcile on it;vehicle_idis the identifier, anddisplay_nameis what a customer should see.display_name· string · required · min length 1 The customer-facing name of the group, written to be shown as-is — conventionally in "a model or similar" form, because what is sold is the group and not a specific model. Show this rather thanname.partner_code· string or null · optional · min length 1; max length 128 Your own code for this vehicle group, such asA1, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this group. It never changes whatvehicle_ididentifies.example_models· array of string · optional Representative models for the group, to illustrate what "or similar" means. Optional and omitted entirely where the operator has not verified them — never invented and never defaulted. Never A booking guarantee: a customer books the group, and no listed model is promised.seats· integer · optional · ≥ 1 How many people the group seats, including the driver. Optional and omitted where the operator has not verified it — absence means 'not stated', never a conventional default. Do not substitute a value of your own on a customer-facing page.doors· integer · optional · ≥ 1 How many doors the group has. Optional and omitted where the operator has not verified it — absence is 'not stated' (seeseats).large_bags· integer · optional · ≥ 0 How many large bags (suitcases) the group holds. Optional and omitted where not verified.0is a stated value and means the group holds none — it is not the same as the member being absent.small_bags· integer · optional · ≥ 0 How many small bags (cabin size) the group holds. Optional and omitted where not verified.0is a stated value, distinct from absence (seelarge_bags).transmission· string —manual·automatic· (extensible: new members may appear) · optional The group's gearbox:manual— the driver changes gear;automatic— it does not. Optional and omitted where the operator has not verified it.acrissposition 3 also encodes this; the two come from different stores and this one is the operator-verified fact, so prefer it where both are present.fuel· string —petrol·diesel·hybrid·electric·plugin_hybrid· (extensible: new members may appear) · optional What the group runs on:petrol·diesel·hybrid(self-charging) ·plugin_hybrid(chargeable, also runs on fuel) ·electric(battery only). Optional and omitted where the operator has not verified it. It is a description of the vehicle, not of the refuelling terms a customer is held to — those are thefuelpolicy on GET /v1/reference/policies.air_conditioning· boolean · optional Whether the group has air conditioning. Optional and omitted where the operator has not verified it — a missing member is 'not stated', andfalseis the positive statement that the group has none. Never collapse the two.gps_included· boolean · optional Whether a navigation device is included in the rate for this group, at no extra charge. Optional and omitted where the operator has not stated it — a missing member is "not stated", andfalseis the positive statement that no device is included. Never collapse the two. This says nothing about whether navigation can be bought as an add-on: that is a priced entry on GET /v1/reference/extras, and the two are independent facts.body_style· string —hatchback·sedan·estate·coupe·convertible·roadster·suv·crossover·mpv·van·minibus·pickup·motorhome·other· (extensible: new members may appear) · optional The group's body style, as a stable code. Absent where the operator has not stated it;otheris a stated value — a real vehicle this list does not name. The second position ofacrissencodes the same thing, and the supplier's screens refuse a contradictory pair, but a contradiction can still reach you from a supplier already connected. If the two disagree, prefer neither silently: raise it with the supplier. Most groups have no ACRISS code, which is why this member exists.electric_range_km· integer · optional · ≥ 1; ≤ 2000 Battery range in kilometres, as stated by the operator. Optional and omitted unless stated, and emitted only for a group whosefueliselectricorplugin_hybrid— the supplier cannot store a range against any other powertrain. Always read it together withelectric_range_basis: the two are present or absent together, and a range without its test cycle is not comparable between suppliers. This is the operator's own figure; the platform does not measure, verify or convert it.electric_range_basis· string —wltp·epa·manufacturer· (extensible: new members may appear) · optional The test cycleelectric_range_kmwas measured on; present exactly when it is.wltpandepaare the cycles this API distinguishes. Others exist (NEDC, CLTC), somanufacturermeans a figure from none of the named ones, and it does not compare with awltporepafigure. The list can grow within/v1. The same car gives materially different ranges on each cycle, so do not show a range without this qualifier.min_driver_age· integer · required · ≥ 16; ≤ 99 The youngest a main driver may be to rent this group, in whole years — A hard gate, not a guideline. A quote or booking whose main driver falls below it fails 422 driver_age_out_of_range with the offending field named indetails[], and a search that carriesdriver_agewithholds the group rather than advertising a price the customer cannot book.max_driver_age· integer · required · ≥ 16; ≤ 99 The oldest a main driver may be to rent this group, in whole years — a hard gate on the same terms asmin_driver_age, with the same 422 and the same withholding behaviour at search.images· array of object · optional Pictures of the group, exactly as the operator supplied them. Optional and omitted entirely where this platform holds none — never a stand-in image. The image host is not part of this contract: fetch eachurlas given, never construct, rewrite or sign one, and do not hardcode a host, a path shape or a lifetime. If your renderer needs a fixed host list (a CSP allow-list, an image proxy), ask rather than reading one out of a sample response.url· string · required · min length 1 Absolute URL of the image, served exactly as the operator stored it. Take it as given: never construct, rewrite or sign one, and never assume the host (see the array's own note).role· string · optional Optional free-text hint about what the image is for (a hero shot, an interior, an angle). An open vocabulary, not an enum: treat a role you do not recognise as untagged rather than dropping the image. Omitted where the operator gave none.
updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not a per-row stamp: every row's value moves together on every read, so do not detect changes with it — userow_updated_at, which?changed_since=narrows on and which anacrisscorrection moves. This collection does not narrow onupdated_since(§1.10): upsert by id against a full refresh.row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). This group's stamp moves on an ACRISS correction, a verified-attribute edit (seats, doors, bags, transmission, fuel, air-conditioning, images, example models) and any change to the cars or rate cards the group is aggregated from.
Extra
supplier_id· string · optional · min length 1 The supplier this extra belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.extra_id· string · required · min length 1 Opaque, stable identifier of this extra — the value you send inextras[].extra_idon a quote, a create and a modify. Treat it as a string: never parse it and never construct one. An extra the operator withdraws simply stops appearing in this catalogue, and from then on its id is refused at quote and booking (§3).code· string · required · min length 1 The operator's short machine code for this extra, and the value that appears ascodeon the matchingextraline of a price breakdown — so it is what joins a charged line back to this record. It is stable per record but not guaranteed unique across the catalogue, soextra_idremains the identity.supplier_code· string or null · optional · min length 1; max length 32; pattern^[A-Z0-9][A-Z0-9._-]{0,31}$The supplier's own short code for this accessory, such asCHILD, for keying your table on something that is not our identifier. Upper-case letters, digits,.,_and-, up to 32 characters. Set once and never changed, and never reused for another accessory, even after this one is withdrawn. Unique within one supplier.nulluntil stated. Notcode, which still joins a charged line to this record.ota_code· string or null · optional · min length 1; max length 16; pattern^[A-Za-z0-9.]{1,16}$The OTA equipment code the supplier states for this accessory, for example7or9.EQP.nulluntil stated. It is the supplier's statement and is not checked against any code list, so treat it as a hint for your own mapping, not as a guarantee. The supplier can correct it, and two accessories may share one.partner_code· string or null · optional · min length 1; max length 128 Your own code for this accessory, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this accessory. It is notcode, which joins a charged line to this record, and it never changes whatextra_ididentifies.name· string · required · min length 1 Customer-facing name of the extra, written to be shown as-is. Display only — never match or branch on it.description· string or null · required Free text describing the extra, as the operator wrote it — for display besidename.nullwhere the operator has stated none; that is 'not stated', not an empty description, so render nothing rather than an empty line.category· string —child_seat·additional_driver·navigation·connectivity·winter·protection_upsell·other· (extensible: new members may appear) · required What kind of extra this is, so you can group a selection page without parsingname(§8.8):child_seat(an infant, child or booster seat),additional_driver(a named extra driver — an age surcharge is a fee, not an extra, §2.5),navigation,connectivity(data or a hotspot),winter(snow chains, a ski rack),protection_upsell(an insurance-adjacent add-on, the likeliest to carry amutually_exclusive_group), andother, which you will receive, so handle it.pricing_type· string —per_day·per_rental· (extensible: new members may appear) · required Howunit_priceis charged, and enforced — a quote prices on it:per_dayis once per rental day,per_rentalonce for the whole rental. It can be an assumption rather than a stated fact: a row on which the operator never stated a basis is published, and charged, asper_day. New extras cannot be created without a basis, and an operator can require one on every active row, after which no assumed value remains.unit_price· Money · required What one unit of this extra costs, in integer minor units (§1.6), charged on the basispricing_typenames. It is the catalogue price and not the charge: the amount actually billed appears as the matchingextraline on a quote or booking breakdown, andquantity × unit_priceneed not equal that line'stotal— a cap or the day count can move it, so read the line rather than deriving it (§6.1).price_cap· NullableMoney · required The ceiling on what a single unit of this extra can be charged for one rental — a per-day extra that stops accruing after so many days, for instance. Always present, never omitted: a{amount, currency}object where a cap is set and the literalnullwhere there is none. Branch on the value, never on the key. It caps the per-unit charge under both pricing types.quantity· object · required The selectable quantity range for this extra, for building a stepper or a checkbox. Always present, and all three members with it. On a countable extra it is a real range; on one whererequires_quantityis false,maxis 1 — that pairing is guaranteed by the emitter, which derivesrequires_quantityfrom this object, and not by this schema, whose keyword subset cannot tie two members together.min· integer · required · ≥ 0 The fewest units that may be selected.0— the normal case — means the extra is optional; a positive value means the operator requires at least that many whenever the extra is taken.max· integer · required · ≥ 1 The most units that may be selected. A request above it is refused 422 validation_error naming the field. Note the request schemas capextras[].quantityat 9 independently of this value, so the effective ceiling is the lower of the two.default· integer · required · ≥ 0 The quantity to pre-select in a picker.0means 'not selected by default', which is the normal case; it is a UI hint and nothing is added to a price unless you actually send the extra.
requires_quantity· boolean · required Whether this extra is countable.true— the customer chooses how many (seats, additional drivers), withinquantity.min…quantity.max.false— it is a yes/no product, taken once or not at all; send quantity 1 to take it and omit the extra entirely to decline it. Render a stepper fortrueand a checkbox forfalse.mutually_exclusive_group· string or null · required An opaque group label: extras sharing one non-null value cannot be combined on the same booking, so a picker must let a customer choose at most one of them. Always present, never omitted — the literalnullwhere the extra combines freely, so branch on the value rather than on the key. The value is a label, not an identifier you can look up: treat it as opaque and compare it only against other extras' values.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this extra is due (§8.3).prepaid— it falls in the prepaid part of the total.at_pickup— it is payable on arrival. It is what routes this extra's charge into thepay_now/pay_at_pickupsplit of a breakdown'stotals(§6). Note that no money moves through this API in either case: this states when the amount is due, not that anything is collected here.tax_rate· number · required · ≥ 0; ≤ 1 The tax rate that applies to this extra, as a decimal fraction between 0 and 1 (0.24 means 24%) — deliberately not a percentage and not minor units. It is the one non-integer numeric on this catalogue and it is display information: the tax actually charged is the engine's owntax_breakdownon a quote or booking, extracted per line, so never derive tax from this rate — §6.2 says why an extraction done once over a total disagrees with the invoice by cents.active· boolean · required Alwaystrue: a withdrawn extra is not served at all, so never wait forfalse. Withdrawal reaches you as an absence from this full-refresh collection, and an absence is not a deletion — upsert by id and keep the record (§1.10). The authoritative signal is the failing call:422validation_errornaming the extra, or409availability_changedon a quoted create (§3).updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not A per-row edit stamp: the endpoint returns the full current catalogue on every call rather than narrowing, so every row's value moves together on every read. Store it and order by it; do not build change detection on it — the siblingrow_updated_atis the real per-row edit stamp, and?changed_since=narrows on it (§1.10).row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). It moves on this extra's own changes, including a price or availability override that applies to you.
Protection
supplier_id· string · optional · min length 1 The supplier this protection belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.protection_id· string · required · min length 1 Opaque, stable identifier of this protection product — the value you send asprotection_idon a quote and on a direct create, where it is required: there is no default tier, so a customer's choice must be presented and sent. Treat it as a string and never construct one. It is scoped tovehicle_id: an id that does not belong to the selected group is rejected 422 at quote and at booking.vehicle_id· string or null · required The vehicle group this product is scoped to — a protection tier belongs to one group, so there is no single global ladder to map once. Read the tiers for the group you are selling.nullis reserved for an operator-wide included cover and is published as nullable for that reason; no /v1 catalogue serves such a row today, because partner catalogues are always group-scoped.code· string · required · min length 1 The operator's short machine code for this product, and the value that appears ascodeon the matchingprotectionline of a price breakdown — so it is what joins a charged line back to this record. Stable per record but not guaranteed unique across the catalogue;protection_idremains the identity.name· string · required · min length 1 Customer-facing name of the product, written to be shown as-is. Display only — never match or branch on it, and never rank a ladder by it.tier· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · required The slot this product fills in a group's cover ladder: one product per tier per group. Read the numbers, not the word.basic,mediumandpremiumare nominal: nothing makespremiumcost or cover more, nothing makesbasicfree, and each operator maps its own products onto them, so the words do not compare across operators.no_deposit_with_excessis the one name with enforced terms —depositzero andexcessabove zero — and it is a slot, not a rung abovepremium. Rank options byprice; describe them fromexcessanddeposit.partner_code· string or null · optional · min length 1; max length 128 Your own code for this protection product, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this product. It never changes whatprotection_ididentifies.description· string or null · required Free text describing what this product covers.nulltoday on every operator and every tier, without exception — the projection emits the literal null and consults no column, so it is not a per-row emptiness you can wait out. Do not build a coverage panel that depends on this member arriving; when it is populated it will arrive as a string, which a client reading this schema already handles.excess· Money · required The customer's maximum liability for damage under this product, in integer minor units (§1.6) — a term of the rental agreement the desk applies, not a rule this API enforces: nothing here charges, holds or releases it. Show it before the sale; it is frozen onto a booking at creation. It is the damage excess only. A separate theft figure appears astheft_excess; when that is absent the supplier has not said whether theft differs, so do not present this number as covering theft or copy it into a theft-excess field.theft_excess· Money · optional The customer's liability when the vehicle is stolen, where the supplier states it separately from the damageexcess— often a higher figure. Absent means unstated, not equal toexcess: show the damage excess and say nothing about theft, or ask the supplier, but never copyexcessinto it. The supplier states it once per vehicle group and tier, so it is the same for every distributor, unlikeexcess; a supplier whose figure differs by contract leaves it absent. It is the supplier's statement, not checked here. Same shape and currency asexcess.deposit· Money · required The security deposit the desk blocks at pickup under this product, in integer minor units (§1.6) — a term, not a rule this API enforces. It is a hold, not a charge (§6.4), and no deposit is taken, held or released through this API. Readdeposit_payment_methodson GET /v1/reference/policies for what the desk accepts to block it, and show both before checkout.price· Money · required Readprice_basisbefore you render this number. When it isminimum, this is the least this product can cost per billed day, not what a quote charges: the supplier prices it as a share of the car rate, so show it as a "from" price or show the quote's figure instead. When it is absent the price is exact and is what a quote charges. It is the catalogue unit price in minor units (§1.6) on the basispricing_typenames, never a window total: a quote, a booking create ortotals_for_windowcarries the real figure. Unlikeexcessanddepositthe price is enforced, so rank tiers by it, with care where a ladder mixes exact and minimum prices.pricing_type· string —included·per_day· (extensible: new members may appear) · required Howpriceis charged:included— already inside the rate, so nothing is added at checkout;per_day— charged once per billed rental day (§6.2 defines the day count).includedtracks a zero price and not a tier: do not read it as naming the lowest rung. There is no per-rental protection product today. A product whoseprice_basisis"minimum"is alwaysper_day, and the day count is the multiplier for its exact price — which you obtain from a quote, not fromprice(read that member's description before multiplying anything here).price_basis· string —minimum· (extensible: new members may appear) · optional How to readprice.minimum:priceis a lower bound — the least the product can cost per billed day — and not what a quote charges, so render it as a "from" price or show the quote's figure instead. Absent:priceis exact, the normal case. The supplier prices such a product as a percentage of the car rate, andprice_percentstates the terms. Any surface that carries a rental — a quote, a booking, an availability row — has the exact figure, and the quote is the authority. An open enum: ignore a value you do not recognise (§1.2).price_percent· object · optional The terms behind aminimumprice; present exactly whenprice_basisis. The charge for a stay isshare_bpsbasis points of its base-rate total, rounded half up once, then held to at leastmin_per_dayand at mostmax_per_daytimes the billed days where present. These are the default price list's terms; the rate row you price from carries the ones the quote applies at its stations. Contract §3.5 has the steps and test vectors. The quote is the authority.share_bps· integer · required · ≥ 1; ≤ 10000 The share of the rental's base-rate total, in basis points: 1500 is 15%. A whole number from 1 to 10000.min_per_day· Money · optional The least the supplier charges for each billed day of the rental, in minor units, so the charge is never belowmin_per_day× the billed days. Absent means the supplier states no floor.max_per_day· Money · optional The most the supplier charges for each billed day of the rental, in minor units, so the charge is never abovemax_per_day× the billed days. Absent means the supplier states no cap.
product_type· string —waiver·insurance· (extensible: new members may appear) · optional Whether this product is a damage waiver or insurance, which carry different disclosure duties.waiver: the operator limits the renter's liability by contract, with no insurer and no policy.insurance: an insurer takes the risk under a policy, and in most EU jurisdictions selling it is regulated insurance distribution, with information duties on you as the distributor. Branch on this, never onname. Absent means unstated, notwaiver: present the product as neither. It is the operator's statement, not a legal conclusion of this API; the binding words are in its rental terms.covers· array of string —administration_fee·animal_collision·attempted_theft·bodywork·cross_border_use·documents·driver_injury·driving_under_influence·ferry_or_sea_transport·fire·glass·immobilisation·interior·keys·lights·mirrors·natural_hazard·off_road_use·passenger_injury·personal_effects·replacement_vehicle·roadside_assistance·roof·theft·third_party_liability·towing_and_recovery·tyres_and_wheels·unauthorised_driver·undercarriage·vandalism·wrong_fuel· (extensible: new members may appear) · optional What this tier covers, as codes to branch on and to render in your customer's language: parts of the vehicle (glass,undercarriage,tyres_and_wheels) and events (theft,fire,wrong_fuel). The legal extent of the cover is in the operator's rental terms, which bind. The list grows: ignore a code you do not recognise. A code never appears in bothcoversandexcludes. The supplier states it once per tier, for every vehicle category and every distributor, and this platform does not verify it. Absent — a common state — when the supplier has not stated cover or its cover differs within the tier; never an empty array.excludes· array of string —administration_fee·animal_collision·attempted_theft·bodywork·cross_border_use·documents·driver_injury·driving_under_influence·ferry_or_sea_transport·fire·glass·immobilisation·interior·keys·lights·mirrors·natural_hazard·off_road_use·passenger_injury·personal_effects·replacement_vehicle·roadside_assistance·roof·theft·third_party_liability·towing_and_recovery·tyres_and_wheels·unauthorised_driver·undercarriage·vandalism·wrong_fuel· (extensible: new members may appear) · optional What this product does not cover — the vocabulary ofcovers, rendered the same way, and shown to the customer before booking. Empty and absent differ:[]is the operator stating that nothing is excluded (a genuine zero-excess waiver); absent means nothing is stated either way, so never tell a customer "no exclusions" on an absent member.coversis never empty. A code never appears in both lists.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this product'spriceis due (§8.3).prepaid— it falls in the prepaid part of the total.at_pickup— it is payable on arrival. It is what routes this charge into thepay_now/pay_at_pickupsplit of a breakdown'stotals(§6), and a tier whosepayablediffers from another's moves money between those two buckets without changing the grand total. No money moves through this API in either case.active· boolean · required Alwaystrue, on every record — a withdrawn product is not served at all rather than served withfalse, so a client that branches on this member branches on a constant. It is declared and required so you can read it; it is never the thing to act on. Withdrawal reaches you as an absence from this full-refresh collection, and an absence is not a deletion: upsert by id and keep the record (§1.10). The authoritative signal is the failing call — 422 validation_error naming the protection, or 409 availability_changed on a quoted create (§3).updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not A per-row edit stamp: the endpoint returns the full current catalogue on every call rather than narrowing, so every row's value moves together on every read. Store it and order by it; do not build change detection on it — the siblingrow_updated_atis the real per-row edit stamp, and?changed_since=narrows on it (§1.10).row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). Coarse on this catalogue: an operator's protection rows share one stamp, because each is derived from the insurance configuration and the price book, so a hit returns the whole catalogue — re-upsert the set.
Fee
supplier_id· string · optional · min length 1 The supplier this fee belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.fee_id· string · required · min length 1 Opaque, stable identifier of this fee rule — the value a chargedfeeline carries asfee_idso you can join the charge back to this record for its conditions, basis, amount and cap. Treat it as a string and never construct one. You never send a fee_id: fees are conditional charges the engine resolves, never something a partner selects.code· string · required · min length 1 The operator's short machine code for this rule, and thecodea chargedfeeline carries. It is not unique on A breakdown: an out-of-hours window emits two lines that both carry the same out-of-hours code, one per end. Classify a charge on the line'stype, not oncodeand never onlabel.type· string —young_driver·senior_driver·one_way·out_of_hours·premium_location·additional_location·cross_border·winter_equipment· (extensible: new members may appear) · required The kind of surcharge — the member to classify a charge on (§8.5). Live —young_driverandsenior_driver(age bands, one line per qualifying driver indrivers[], §6.3);one_way(in the direction the rule names);out_of_hours(a handover outside the desk'shours, one line per end);premium_location(a collection, not a return, atapplies_when.pickup_location_id);winter_equipment(collected insideapplies_when.pickup_date_between);cross_border(on the customer'scross_borderdeclaration). Reserved —additional_location. It has no producer, so it never appears; reserved is not deprecated. A record exists only where this operator configured one. Adeliveryline is priced on the location, never here, andconstants.fee_typeslists every charged-line type (§3.1). No type encodes a prohibition (§2.6).name· string · required · min length 1 Customer-facing name of the surcharge, written to be shown as-is. Display only — never match or branch on it;typeis what classifies a charge.applies_when· object · required The conditions under which this rule fires, so a surcharge can be explained up front. Present on every record; an empty object means no stated condition. Every key present must hold. Out-of-hours has two live forms and you must handle both: a desk with publishedhoursuses the*_outside_hoursbooleans, and a desk without a schedule keeps a fixed*_local_time_beforecutoff scoped bypickup_location_id. Neither form is being retired on a date you can rely on. The engine'sfeelines on a quote or booking are the applied amount (§2.5).driver_age_between· array of integer · optional · min items 2; max items 2 An inclusive age band as exactly [low, high] in whole years: the rule fires for a driver whose age falls inside it, endpoints included. Applied per qualifying driver acrossdrivers[], so a party with two drivers in the band produces two charged lines (§6.3).driver_age_gte· integer · optional The rule fires for a driver aged at or above this many whole years. Applied per qualifying driver acrossdrivers[].driver_age_lte· integer · optional The rule fires for a driver aged at or below this many whole years. Applied per qualifying driver acrossdrivers[].pickup_location_id· string · optional The rule fires only when the rental is collected at thislocation_id. On a one-way rule it is paired withdropoff_location_idand the pair is directional: the reverse journey is a different rule, and where none exists that direction carries no such charge. On apremium_locationrule it stands alone and is the entire condition: that station levies a surcharge on collection, and returning a vehicle there is not what this charges — so read the member you are given rather than assuming a station-wide surcharge.dropoff_location_id· string · optional The rule fires only when the rental is returned at thislocation_id. Seepickup_location_idfor how the two combine on a directional one-way rule.pickup_outside_hours· boolean · optionaltrue— the rule fires when the collection falls outside the station's own publishedhours, resolved in that station's publishedtimezone. This is the per-station form, used wherever the station has a published schedule; a station without one carriespickup_local_time_beforeinstead. Whether such a handover is refused outright is a separate fact, published asout_of_hours.availableon the location — a fee prices what the operator performs, it does not license it.dropoff_outside_hours· boolean · optionaltrue— the rule fires when the return falls outside the station's own publishedhours, resolved in that station's publishedtimezone. The return counterpart ofpickup_outside_hours, and an offending rental can trigger both at once — two lines, one per end.pickup_local_time_before· string · optional · pattern^([01]\d|2[0-3]):[0-5]\d$The other out-of-hours vocabulary: the rule fires when the collection's local wall-clock time is earlier than this HH:MM, resolved in the station's publishedtimezone. A fixed cutoff rather than a schedule, used for a station whosehoursare not published yet, and scoped to that station by an accompanyingpickup_location_id. Handle it alongsidepickup_outside_hours, not instead of it.dropoff_local_time_before· string · optional · pattern^([01]\d|2[0-3]):[0-5]\d$The return counterpart ofpickup_local_time_before: the rule fires when the return's local wall-clock time is earlier than this HH:MM in the station's publishedtimezone.pickup_date_between· array of string · optional · min items 2; max items 2 A recurring season as[from, to]in zero-paddedMM-DD, inclusive at both ends and without a year. The rule fires when the rental is collected on a day inside it, in the pickup desk'stimezone; the return date does not matter. A season may wrap the year end:["11-01", "03-31"], withfromlater thanto, means 1 November to 31 March, and a plainfrom <= day <= totest is false on every day of it. The engine's chargedfeeline on a quote or booking is the amount that applies (§2.5).cross_border· boolean · optionaltrue: the rule fires when the customer declared an intent to take the vehicle out of the country of hire, sent as the optionalcross_bordermember on a quote or a create. It is compared by value — a rule statingfalsewould be the inverse rule, not a disabled one; none exists today, but read the value, not the key. Sendingcross_border: trueto an operator with no such rule changes nothing.
pickup_location_partner_code· string or null · optional · min length 1; max length 128 Your own code for the station named byapplies_when.pickup_location_id, as the supplier recorded it for you. Present only when the rule names a pickup station and the supplier has recorded codes for you;nullwhere it has none for that station.dropoff_location_partner_code· string or null · optional · min length 1; max length 128 Your own code for the station named byapplies_when.dropoff_location_id, as the supplier recorded it for you. Present only when the rule names a drop-off station and the supplier has recorded codes for you;nullwhere it has none for that station.pricing_type· string —per_day·per_rental· (extensible: new members may appear) · required Howamountis charged when the rule fires:per_day— once per billed rental day (§6.2 defines the day count);per_rental— once for the whole rental, however long.cap, where non-null, caps the charge under both.amount· Money · required What this rule charges when it fires, in integer minor units (§1.6), on the basispricing_typenames. It is the rule's amount and not the charge: what is actually billed appears as the matchingfeeline on a quote or booking breakdown, where a cap, the day count or a per-driver multiplication can move it. Read the line, never re-derive it (§2.5 — fees are computed by this platform, never by the partner).cap· NullableMoney · required The ceiling on what this rule can charge for one rental — a per-day surcharge that stops accruing after so many days, for instance. Always present, never omitted: a{amount, currency}object where a cap is set and the literalnullwhere there is none. Branch on the value, never on the key.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this surcharge is due (§8.3).prepaid— it falls in the prepaid part of the total.at_pickup— it is payable on arrival. It is what routes the charge into thepay_now/pay_at_pickupsplit of a breakdown'stotals(§6). No money moves through this API in either case: this states when the amount is due, not that anything is collected here.taxable· boolean · required Whether this surcharge is subject to tax. It tells you which lines tax is computed over, not what the tax comes to: the amount is the engine's owntax_breakdownon a quote or booking, extracted per line (§6.2). Never derive tax from this flag.active· boolean · required Alwaystrue, on every record — a withdrawn rule is not served at all rather than served withfalse, so a client that branches on this member branches on a constant. It is declared and required so you can read it; it is never the thing to act on. Withdrawal reaches you as an absence from this full-refresh collection, and an absence is not a deletion: upsert by id and keep the record (§1.10).updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not A per-row edit stamp: the endpoint returns the full current catalogue on every call rather than narrowing, so every row's value moves together on every read. Store it and order by it; do not build change detection on it — the siblingrow_updated_atis the real per-row edit stamp, and?changed_since=narrows on it (§1.10).row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). Coarse on this catalogue: an operator's fee rows share one stamp, because each is synthesised from operator settings and one-way pairs, so a hit returns the whole catalogue — re-upsert the set.
Rate
supplier_id· string · optional · min length 1 The supplier this rate belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. This collection is a snapshot you replace per supplier (§3.12): a row that is no longer served for a supplier has gone.vehicle_id· string · required · min length 1 The vehicle group this rate prices — the samevehicle_idsurrogate GET /v1/reference/vehicles publishes. That read lists the groups of your default price list: a group sold only at a station with its own price list, or only under another plan, can appear here and not there.vehicle_partner_code· string or null · optional · min length 1; max length 128 Your own code for the vehicle group this rate prices, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this group.plan· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · required The protection plan this rate is sold with: the tier the book includes in its rate. One row per plan, because each plan has its own card.plan_partner_code· string or null · optional · min length 1; max length 128 Your own code for this plan, yourProduct Code, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this plan.default_plan· boolean · required True when these stations are served this plan by default — the plan a search or quote that names none prices from.valid_from· string · required · pattern^\d{4}-\d{2}-\d{2}$First day this card applies. Inclusive. A rental is priced from the period its pickup date falls in; where the supplier prorates (cross_periodinmeta.pricing_by_supplier), each further night of a longer rental is priced from the period that night falls in.valid_to· string · required · pattern^\d{4}-\d{2}-\d{2}$Last day this card applies. Inclusive. Periods that ended before today are not listed; seevalid_fromfor how a rental across two periods is priced.stations· object · required The pickup stations whose price book this row is. A station with a price book of its own replaces the general one entirely, so the list is explicit and never an all-except.all· boolean · required True when every sellable station prices from this book and plan.location_ids· array of string · required Thelocation_idvalues from GET /v1/reference/locations, in ascending order.location_partner_codes· array of string or null · optional Your own code for each station inlocation_ids, in the same order, as the supplier recorded it for you:nullfor a station it has no code for. Present only once the supplier has recorded codes for you.
totals· object · required The base rental total for exactly 1 to 7 billing days, tax included. A rental of N days over 7 costs N timesextra_day. A length with no price is null: the quote refuses it. A length outside the supplier's rental-length limits (rental_daysinmeta.pricing_by_supplier) is refused by the quote whatever the card says.1· NullableMoney · required The total for 1 billing day.2· NullableMoney · required The total for 2 billing days.3· NullableMoney · required The total for 3 billing days.4· NullableMoney · required The total for 4 billing days.5· NullableMoney · required The total for 5 billing days.6· NullableMoney · required The total for 6 billing days.7· NullableMoney · required The total for 7 billing days.
extra_day· NullableMoney · required The price of each billing day from the 7th on, tax included;totals["7"]is seven of them. Null when the card has no 7-day price.protection· object · required The plan's product for this group, as GET /v1/reference/protections publishes it. A total for a rental is the base total plus this charge: a rate without it under-prices a plan that carries one.protection_id· string · required · min length 1 Theprotection_idto send on a quote — the same surrogate GET /v1/reference/protections publishes. That read lists the products of your default price list, so another plan's id can appear here and not there.pricing_type· string —included·per_day· (extensible: new members may appear) · requiredincluded: nothing is added to the base total.per_day:priceis charged once per billing day.price· Money · required The per-day charge. Readprice_basisfirst: when it isminimumthis is the least the product can cost per day, and the quote gives the exact figure.price_basis· string —minimum· (extensible: new members may appear) · optional Present only whenpriceis a lower bound because the operator prices this product as a share of the base rate.price_percent· object · optional Present exactly whenprice_basisis. These are the terms the quote applies at this row's stations. For N billed days the charge isshare_bpsbasis points of the stay's base total — from the rows of the periods the stay touches, blended as this supplier'scross_periodsays — rounded half up once, then held to at leastmin_per_day× N and at mostmax_per_day× N where present. The quote is the authority.share_bps· integer · required · ≥ 1; ≤ 10000 The share of the base total, in basis points: 1500 is 15%.min_per_day· Money · optional The least charged for each billed day. Absent means no floor; a stored 0 is present and never binds.max_per_day· Money · optional The most charged for each billed day. Absent means no cap.
excess· Money · required The customer's maximum liability for damage under this product.deposit· Money · required The security deposit the desk blocks at pickup under this product.theft_excess· Money · optional The customer's liability when the vehicle is stolen, where the supplier states it separately. Absent means unstated.
StopSale
supplier_id· string · optional · min length 1 The supplier this stop sale belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. This collection is a snapshot you replace per supplier (§3.13): a stop that has been lifted is simply no longer listed.vehicle_id· string · required · min length 1 The vehicle group that is closed — the samevehicle_idsurrogate GET /v1/reference/rates publishes. Only groups you can sell appear here; GET /v1/reference/vehicles lists only your default price list, so a group sold at a station's own book or under another plan can appear here and not there.vehicle_partner_code· string or null · optional · min length 1; max length 128 Your own code for the closed vehicle group, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this group.stations· object · required The stations this stop applies to. A stop sale closes the whole group at every station, soallis always true; the member exists so that a later, narrower scope is an addition and never a change.all· unspecified —true· (extensible: new members may appear) · required Always true: the group is closed at every station.
start· string · required · pattern^\d{4}-\d{2}-\d{2}$First closed day, as a calendar date on the operator's own clock. Inclusive. A stop that began before today is published from its stored start. Asold_outrow that starts today may have started earlier: the run is computed from today.end· string · required · pattern^\d{4}-\d{2}-\d{2}$Last closed day. Inclusive. A rental is refused while its pickup date is on or beforeendand its dropoff date is afterstart: a rental that returns on thestartday is not refused, one that picks up on theendday is. Stops that ended before today are not listed. On asold_outrow a value equal tometa.rules_by_supplier[*].sold_out_throughis where the computed horizon stops, not necessarily where the closure does.source· string —manual·sold_out· (extensible: new members may appear) · required Why the group is closed.manual: the operator stopped it.sold_out: the group has no unit left to sell. More reasons may be added, so treat an unknown value as a closure of unknown cause.
Policy
supplier_id· string · optional · min length 1 The supplier this record belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but what you persist is organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.policy_id· string · required · min length 1 Opaque identifier of this policy record. Treat it as a string and never parse it. One row per operator today; this endpoint stays a list so per-location or per-class policies can arrive additively (§1.2), so read the collection rather than assuming a single element.mileage· object · required The operator's mileage term — a term of the rental agreement the desk applies, not a rule this API enforces or meters. It is resolved for you: where you hold an in-force override it is your negotiated term, otherwise the operator's default, andsourcenames which. A booking echoes the same key but carries the operator default untilper_broker_booking_display_policiesreads true on GET /v1/capabilities (§2.6), so read your own terms here rather than from a booking.type· string —unlimited·limited· (extensible: new members may appear) · requiredunlimited— no distance allowance applies, so no per-kilometre term is stated.limited— an allowance applies andincluded_km_per_daystates it. There is no published price for A kilometre beyond the allowance: this contract publishes what is included and deliberately publishes no excess rate, because nothing on this API charges one (§2.6). Do not present an overage cost you have derived. Enforced on the write side (the operator cannot store any other value); not enforced against a returned vehicle.included_km_per_day· integer · optional · ≥ 0 Present ontype: "limited"only — the allowance in kilometres per billed rental day (§6.2 defines the day count), so the rental's whole allowance is this figure multiplied by that count. Absent onunlimited, where there is no allowance to state. What kilometre N+1 costs is not on this API: the operator's tariff is stored but deliberately unpublished while nothing charges it.source· string —operator_default·broker_terms· (extensible: new members may appear) · optional Which rule you are seeing:operator_default— the operator's standing policy;broker_terms— an override negotiated with you, in force and replacing the default for this key. Optional and additive: treat its absence asoperator_default.
fuel· object · required The operator's fuel term — a term of the rental agreement the desk applies, not a rule this API enforces or meters. Resolved for you on the same footing asmileage: your in-force override where you hold one, otherwise the operator's default, withsourcenaming which. A booking echoes the key but carries the operator default untilper_broker_booking_display_policiesreads true (§2.6).type· string —full_to_full·full_to_empty·same_to_same·prepaid· (extensible: new members may appear) · requiredfull_to_full— collected full, returned full; the customer refuels.full_to_empty— collected full and paid for up front, returned at any level with nothing refunded for what is left.same_to_same— returned at whatever level it was collected at.prepaid— the tank is bought at the start. These are terms the desk applies, and no fuel charge is computed or collected through this API (§8.6). Enforced on the write side (the operator cannot store any other value); not enforced against a returned vehicle.source· string —operator_default·broker_terms· (extensible: new members may appear) · optional Which rule you are seeing:operator_default— the operator's standing policy;broker_terms— an override negotiated with you and in force for this key. Optional and additive: treat its absence asoperator_default.
cancellation· object · required The cancellation rule in force for you — the operator’s default, or an override negotiated with you where one applies;sourcenames which. Since 1.10.0 this carries the rule itself and not only its label, which is what lets you show what cancelling will cost before a customer commits. The fee the operator charges you at POST /v1/bookings/{ref}/cancel is computed from this rule as it stood when the booking was created (§5.4). §2.6 is normative for how to read each type.type· string —free_until·non_refundable·tiered· (extensible: new members may appear) · required Which rule shape applies, and therefore which members below are present: free_until, non_refundable, or tiered.source· string —operator_default·broker_terms· (extensible: new members may appear) · optional Which cancellation rule you are seeing:operator_default— the operator's standing rule;broker_terms— a ladder negotiated with you and in force. Optional and additive: treat its absence asoperator_default. It does not coverfee_basis, which is never per-broker negotiable and therefore carries the operator's value even on an object whose source readsbroker_terms.free_until_hours_before_pickup· integer · optional · ≥ 0free_untilonly: cancel with at least this many hours' notice before pickup and there is no fee; with less,late_fee_percentapplies. A standing rule in hours, not a timestamp. Notice counts in whole hours and never below zero, so a cancellation at or after pickup is judged at 0 hours. A threshold of 0 is a valid rule — free up to pickup, stored only with a zero or absentlate_fee_percent— under which every cancellation is free, even after pickup.late_fee_percent· integer · optional · ≥ 0; ≤ 100 free_until only, and optional: what is charged once the grace above has passed. Absent means zero — treat a missing member as no late fee, not as unknown.tiers· array of object · optional · min items 1 tiered only. The fee ladder, longest notice first, always ending in a band at 0 hours. The band that applies is the one with the greatest hours_before_pickup at or below the notice actually given, so the final band is what a very late cancellation costs.hours_before_pickup· integer · required · ≥ 0 The notice threshold of this band, in whole hours before pickup. The band that governs a cancellation is the one with the greatest value at or below the notice actually given, which is why the ladder always ends at a band of 0 — that terminal band is what a very late cancellation costs, and notice is clamped at 0, so cancelling after the pickup instant lands on it rather than falling off the ladder.fee_percent· integer · required · ≥ 0; ≤ 100 What this band charges, as a whole-number percentage (0–100) of the amountfee_basisnames — never of a figure of your own choosing, and never itself an amount of money.0means this band is free.
fee_basis· string —grand_total·pay_now·base_rate· (extensible: new members may appear) · optional All types. What every fee_percent here is a percentage OF. Read it rather than assuming: grand_total, pay_now and base_rate are materially different amounts, and this is the operator’s setting, so it varies between operators. It is not per-broker negotiable, so it is the operator’s value even where source reads broker_terms — a negotiated rule changes the ladder, never what the ladder is measured against.
no_show· object · optional The no-show fee in force for you — present only when a no-show is charged; absent means a no-show costs nothing. The fee applies to a booking made now: each booking freezes the rule at creation and states it ascancellation_policy.no_show_fee_percent, and the amount charged arrives in the booking'sno_showobject when the operator records the no-show.type· string —percent· (extensible: new members may appear) · required The rule's shape.percent— a percentage of the amountfee_basisnames. Tolerate a value you do not know: a later shape is additive.fee_percent· integer · required · ≥ 1; ≤ 100 What a no-show costs, as a whole-number percentage (1–100) of the amountfee_basisnames, charged when the operator records the booking as a no-show.fee_basis· string —grand_total·pay_now·base_rate· (extensible: new members may appear) · required Whatfee_percentis a percentage of: the operator's cancellationfee_basis. It is not negotiable per broker.source· string —operator_default·broker_terms· (extensible: new members may appear) · optional Which no-show rule you are seeing:operator_default, the operator's standing rule, orbroker_terms, a rule negotiated with you and in force. Independent ofcancellation.source.
min_rental_days· integer · required · ≥ 1 The shortest rental this operator will sell, in billed days — and it is enforced, not advisory. A selection below it is rejected at POST /v1/quote and at POST /v1/bookings, and a modify that re-schedules a booking below it is rejected too, with adetails[]entry naming themin, themaxand thevaluecomputed. Compare against therental_daysa quote returns rather than your own date subtraction: the count is the billed one (§6.2), not a naive difference. Whether this bound also reaches GET /v1/availability is a per-account opt-in that is off by default (§2.6).max_rental_days· integer · required · ≥ 1 The longest rental this operator will sell, in billed days, enforced on exactly the same terms asmin_rental_days— same surfaces, samedetails[]shape, same billed-day count. If the operator tightens either bound after a quote is minted, consuming that quote fails 409 availability_changed. Re-quote: where the new bounds still admit your window nothing about the selection has changed, and where they do not the re-quote refuses and names the newmin,maxand the computedvalue— so read the re-quote rather than assuming the selection survived.min_lead_hours· integer · optional · ≥ 1 The minimum whole hours between your request and the pickup instant, enforced atPOST /v1/quote, at booking create and on a modify that moves the pickup; a pickup exactly this far away passes. Absent when the operator publishes none, and then none is enforced. It is judged at each call on the server's clock, so a quote (firm for 30 minutes) can age out: minted with 2h01m of lead and redeemed at 1h31m, it is refused409availability_changed. The refusals —422on a quote, direct create or modify,409on a quoted create — carryissue: "lead_time_too_short",min_lead_hours, your pickup asvalue, andas_of, the judging clock.deposit_payment_methods· array of string —credit_card·debit_card·cash· (extensible: new members may appear) · required What the desk accepts for the security deposit at pickup — a term, not a rule this API enforces: no deposit is taken here and nothing on the booking path reads it, so a customer without an accepted method is refused at the desk. Show it before checkout: the wrong card is the commonest avoidable distribution failure. It names methods only. The card terms — whether the card must be in the main driver's name, and whether debit, prepaid or virtual cards are accepted — aredeposit_card_rules; variation by vehicle group is not modelled.restrictions· array of string —commercial_passenger_transport·cross_border_use·driving_instruction·driving_under_influence·ferry_or_sea_transport·motorsport_or_track_use·off_road_use·overloading·pushing_or_towing·smoking·subletting·transport_of_animals·transport_of_hazardous_goods·unauthorised_driver·unpaved_roads· (extensible: new members may appear) · optional What this operator's rental agreement forbids the renter from doing, as codes fromrestriction_codes(GET /v1/reference/constants). A term of the agreement, applied at the desk — not a rule this API enforces: nothing at quote or booking evaluates it, so show it to the customer before they book. An empty array states that nothing beyond the general law is prohibited; an absent member means the operator has not said. It never encodes where a vehicle may be collected or returned — that ismovement_restrictions, which the API does enforce (§2.6). One statement per operator, covering every vehicle group and every partner.movement_restrictions· object · optional Which directed (pickup, dropoff) movements this supplier performs — a booking-time rule this API enforces, unlike the rental terms inrestrictions. Readpolicyfirst:forbidden_pairsrefuses only the pairs listed inforbidden_pairs, andpermitted_pairs_onlyserves only the pairs inpermitted_pairs. A pair says nothing about its reverse, and a rental that collects and returns at one location is never restricted or listed. Everylocation_idis oneGET /v1/reference/locationspublishes. Absent: no policy is stated, and any pair of published locations can be booked. Pre-filter against it and a quote will not refuse you422movement_not_permitted; with the opt-in, availability reports it asmeta.unsellable_movement.policy· string —forbidden_pairs·permitted_pairs_only· (extensible: new members may appear) · required How to read this object; the two values are opposites.forbidden_pairs: the supplier performs movements generally, andforbidden_pairslists those it does not; anything unlisted is bookable.permitted_pairs_only: it performs only the movements inpermitted_pairs; every other cross-location pair is refused, and a same-location rental is always fine. Exactly one of the two lists is present, named differently so that a client which skips this member finds its list absent rather than inverting it. If you do not recognise the value, read neither list and let the quote's422decide.forbidden_pairs· array of MovementPair · optional The movements this supplier will not perform. Everything not listed here is bookable. Directed: a listed HER→CHQ does not forbid CHQ→HER.permitted_pairs· array of MovementPair · optional The only cross-location movements this supplier will perform. Anything not listed is refused at quote and at booking. A same-location rental is always permitted and is not listed here. Directed: a listed HER→CHQ does not permit CHQ→HER.
licence_rules· object · optional What the operator's rental agreement requires of the driving licence. A term the desk applies at pickup, not a rule this API enforces: a booking carries the driver's age and country but no licence data, and this API accepts none. Show it before the customer books — a customer turned away for a licence held eleven months was failed by an omission. Absent means the operator has not stated licence terms, never that there are none. One statement per operator. The minimum driver age is not here: it ismin_driver_ageandmax_driver_ageper vehicle group, and those are enforced at quote and booking.min_years_held· integer · required · ≥ 0; ≤ 25 The minimum number of full years the driver must have held their licence.0is a statement, not an omission — it means the operator requires no minimum holding period. An operator who has not been asked has nolicence_rulesobject at all.international_permit_required_for· array of string —all_foreign_licences·licence_issued_outside_eu_eea·licence_not_in_latin_script· (extensible: new members may appear) · required When an International Driving Permit must be presented alongside the licence. An empty array is the statement that one is never required; the member is never absent whenlicence_rulesis present. Codes are country-neutral by construction:all_foreign_licencesmeans issued outside the country the rental takes place in.
deposit_card_rules· object · optional What the desk requires of the card that carries the security deposit, beside the method families indeposit_payment_methods. A term, not a rule this API enforces: a customer with the wrong card is refused at the desk, so check it before checkout (§7.4 — a refused debit card is the most common avoidable failure). Absent means the operator has not stated card terms, never that any card is accepted. One statement per operator, covering every vehicle group. If it contradictsdeposit_payment_methods—debit_cardlisted againstdebit_cards_accepted: false— treat both as unstated and ask.holder_must_be_main_driver· boolean · requiredtrue— the deposit card must be in the name of the main driver, so a card belonging to a travelling companion is refused.falseis an equally deliberate statement, never a default.debit_cards_accepted· boolean · required Whether a debit card is accepted for the deposit.prepaid_cards_accepted· boolean · required Whether a prepaid or top-up card is accepted for the deposit.virtual_cards_accepted· boolean · required Whether a virtual card (a wallet-issued or single-use card number with no physical card) is accepted for the deposit.
complaint_handling· array of object · optional · min items 1 How this supplier handles complaints and which alternative-dispute-resolution (ADR) entity covers it, one entry per language stated, for you to show your customer (CRD 6(1)(g),(t); ADR Directive 2013/11 Art. 13). Absent means not stated, never nothing to state. Each entry is the supplier's own statement for itself as a whole, not verified here. No entry links the EU ODR platform, which closed when Regulation (EU) 524/2013 was repealed with effect from 20 July 2025.language· string · required · pattern^[a-z]{2}$ISO 639-1 two-letter code for the languagecomplaint_policyis written in. Deliberately not a full BCP-47 tag: nothing on this platform resolves a language range, sodeandde-ATwould both be present with no rule saying which to show.complaint_policy· string · required · min length 1; max length 4000 The supplier's complaint-handling policy as renderable text inlanguage— how a customer complains, to whom, and what happens next. Supplier-authored; never generated, defaulted or translated by this platform.adr_status· string —committed·not_committed· (extensible: new members may appear) · required Whether the supplier is committed to, or covered by, an ADR entity.committed—adr_entityis present and names it.not_committed— the supplier is covered by none, andadr_entityis absent. Both are complete answers, which is why this is a stated value rather than the mere presence ofadr_entity: “covered by none” and “nobody has been asked” are different facts, and a consumer-facing disclosure must not confuse them.adr_entity· object · optional The ADR entity this supplier is covered by. Present only whenadr_statusiscommitted.name· string · required · min length 1; max length 200 The ADR entity's name, as the supplier states it.url· string · required · min length 12; max length 500; pattern^https://The ADR entity's own web address, https only. Never the repealed EU ODR platform — see this member's $comment.
tax_inclusive· boolean · optional Always present.truemeans every amount on this API already includes tax: display amounts as given and read the tax fromtax_breakdownortotals, never adding it on top or deriving it, because §6.2 extracts tax per line. It describes the pricing engine, not the operator — the same for every operator on a deployment and never negotiated, so it has nosource. The same value is onGET /v1/capabilities, readable without a key: read it once at integration time.updated_at· string · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$When this policy record was last considered changed. This collection narrows onupdated_sinceand pages with a keyset cursor (§1.10), so unlike the five full-refresh catalogues this stamp is usable for a real incremental sync — followmeta.next_pageto exhaustion, and never read an absence from a narrowing collection as a withdrawal: there it only means 'unchanged'.
TermsDocument
supplier_id· string · optional · min length 1 The supplier this record belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but what you persist is organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.document_id· string · required · min length 1 Opaque, stable identifier for this document — the value to store against anything you sold under it, and the one to pass to GET /v1/reference/terms/{document_id}. It resolves forever, including after this document stops being the current one: that is the durability guarantee, and it is what lets you keep serving and evidencing the terms a customer actually accepted. A change to what a supplier publishes always produces a new document_id, and an existing one never changes meaning — so comparing ids is a complete change detector on its own.language· string · required · pattern^[a-z]{2,3}$BCP 47 primary subtag of the language this document is written in — not a translation hint. A consumer must be given contract terms in a language they understand, so treat this as the member that decides whether you may present this document to a given customer at all. At most one current document per language per supplier; a supplier that publishes no document in your customer's language has not made one available, and that is a fact to act on rather than a rendering detail.version· integer · required · ≥ 1 The supplier's own version number for its terms, as it appears on the contract a customer signs — not a version of this API and not a counter this platform invents. Monotone per supplier: a higher version is later text.title· string · required · min length 1 The document's own title, in its own language, as the supplier wrote it — what would head the printed contract. Display it as given; do not translate it and do not substitute one of your own, because this names the instrument the customer is agreeing to.format· string —clauses/v1· (extensible: new members may appear) · required How to readbodyon the single-document route.clauses/v1is a tree of numbered clauses carrying plain-text runs — data, never markup: render it in your own style. Clause numbers are the supplier's frozen text (7.2 stays 7.2 when a clause is later inserted above it), so they are safe to cite in a dispute. New formats arrive as new values: do not fail on an unknown one, and do not try to render it.content_hash· string · required · pattern^[0-9a-f]{64}$SHA-256 of this document's canonical form, lower-case hex: one comparison tells you whether a document you hold has changed, and it proves later that your stored bytes are the ones published. It never changes for adocument_id. To recompute it, serialisebodyas JSON with every object's keys sorted at every level (arrays keep their order), no insignificant whitespace, escaped as JavaScript'sJSON.stringifyescapes, encoded UTF-8, and hash those bytes. This is not RFC 8785 (JCS): the two agree for documents whose leaves are all strings, as everyclauses/v1document's are, and nothing more is promised.published_at· Timestamp · required When this document was made available to distributors. Not when the supplier's desk began issuing under it — those are two different acts, and this is the one that concerns you: it is the instant from which this text is what you were given to show a customer.withdrawn_at· string or null · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$nullon a current document — the one to show a customer today. A timestamp means this document is no longer current, either superseded by a newer one or withdrawn outright, and from that instant it must not be presented as the terms of a new sale. It stays retrievable so you can still evidence what you sold under before then. The list at GET /v1/reference/terms carries only current documents, so on that route this member is always null; a non-null value appears only on the single-document route, for an id you already hold.updated_at· Timestamp · required The snapshot instant of this response, carried on every record of every reference catalogue for consistency — it is not a per-row change stamp and must not be used as one. To detect a change, comparedocument_id(orcontent_hash) against what you hold: a document's own content never changes, so a differing id is the change.
TermsDocumentDetail
supplier_id· string · optional · min length 1 The supplier this record belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but what you persist is organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.document_id· string · required · min length 1 Opaque, stable identifier for this document — the value to store against anything you sold under it, and the one to pass to GET /v1/reference/terms/{document_id}. It resolves forever, including after this document stops being the current one: that is the durability guarantee, and it is what lets you keep serving and evidencing the terms a customer actually accepted. A change to what a supplier publishes always produces a new document_id, and an existing one never changes meaning — so comparing ids is a complete change detector on its own.language· string · required · pattern^[a-z]{2,3}$BCP 47 primary subtag of the language this document is written in — not a translation hint. A consumer must be given contract terms in a language they understand, so treat this as the member that decides whether you may present this document to a given customer at all. At most one current document per language per supplier; a supplier that publishes no document in your customer's language has not made one available, and that is a fact to act on rather than a rendering detail.version· integer · required · ≥ 1 The supplier's own version number for its terms, as it appears on the contract a customer signs — not a version of this API and not a counter this platform invents. Monotone per supplier: a higher version is later text.title· string · required · min length 1 The document's own title, in its own language, as the supplier wrote it — what would head the printed contract. Display it as given; do not translate it and do not substitute one of your own, because this names the instrument the customer is agreeing to.format· string —clauses/v1· (extensible: new members may appear) · required How to readbodyon the single-document route.clauses/v1is a tree of numbered clauses carrying plain-text runs — data, never markup: render it in your own style. Clause numbers are the supplier's frozen text (7.2 stays 7.2 when a clause is later inserted above it), so they are safe to cite in a dispute. New formats arrive as new values: do not fail on an unknown one, and do not try to render it.content_hash· string · required · pattern^[0-9a-f]{64}$SHA-256 of this document's canonical form, lower-case hex: one comparison tells you whether a document you hold has changed, and it proves later that your stored bytes are the ones published. It never changes for adocument_id. To recompute it, serialisebodyas JSON with every object's keys sorted at every level (arrays keep their order), no insignificant whitespace, escaped as JavaScript'sJSON.stringifyescapes, encoded UTF-8, and hash those bytes. This is not RFC 8785 (JCS): the two agree for documents whose leaves are all strings, as everyclauses/v1document's are, and nothing more is promised.published_at· Timestamp · required When this document was made available to distributors. Not when the supplier's desk began issuing under it — those are two different acts, and this is the one that concerns you: it is the instant from which this text is what you were given to show a customer.withdrawn_at· string or null · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$nullon a current document — the one to show a customer today. A timestamp means this document is no longer current, either superseded by a newer one or withdrawn outright, and from that instant it must not be presented as the terms of a new sale. It stays retrievable so you can still evidence what you sold under before then. The list at GET /v1/reference/terms carries only current documents, so on that route this member is always null; a non-null value appears only on the single-document route, for an id you already hold.updated_at· Timestamp · required The snapshot instant of this response, carried on every record of every reference catalogue for consistency — it is not a per-row change stamp and must not be used as one. To detect a change, comparedocument_id(orcontent_hash) against what you hold: a document's own content never changes, so a differing id is the change.body· object · required The document itself. Present on GET /v1/reference/terms/{document_id} and absent from the list at GET /v1/reference/terms — the list is a catalogue you sync, and a full contract per row would make it one. Fetch a body once per document_id and store it: it can never change for that id, so there is nothing to re-poll.title· string · required · min length 1 The document's title, inside the body it belongs to. Equal to the record'stitle.language· string · required · pattern^[a-z]{2,3}$The body's own BCP 47 primary subtag. Equal to the record'slanguage— 122's publication guard refuses a document whose two disagree.clauses· array of TermsClause · required · min items 1 The contract, in order. A document with no clauses is not a contract and is not storable.
Constants
booking_statuses· EnumList · required The booking lifecycle states (§8.1) —pending,confirmed,cancelled,no_show,completed. The list may grow within/v1(seeforward_compatibility.extensible_enums): read it at runtime, and treat a status you do not recognise as neither confirmed nor finished — keep the booking and read it again (§8.1). Meanings are onBooking.status.fee_types· EnumList · required The fee vocabulary (§8.5): the value set oftypeon a chargedfeeline. It is one entry wider than the rulesGET /v1/reference/feesserves, because a delivery charge is published on the location (Location.delivery.fee): adeliveryline can reach you while nodeliveryrecord does. Classify a charge on this vocabulary, never on a line'scodeorlabel. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).extra_categories· EnumList · required The category vocabulary for extras (§8.8) — whatExtra.categorycan carry, so a selection page can group products without parsing their names. Meanings are onExtra.category. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).location_types· EnumList · required The station-type vocabulary (§8.2) — whatLocation.typecan carry. Deliberately coarse: it cannot tell an in-terminal desk from an off-airport depot, which is whatLocation.shuttle_infois for. Meanings are onLocation.type. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).payable_timings· EnumList · required When a charge falls due (§8.3) —prepaidorat_pickup. It is the value set of thepayablemember on extras, protections, fees and every price line, and it is what routes an amount into thepay_now/pay_at_pickupsplit of a breakdown's totals. No money moves through this API in either case. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).protection_tiers· EnumList · required The cover-ladder slots, the value set ofProtection.tier:basic,medium,premiumandno_deposit_with_excess. The first three are nominal — nothing on this API enforces what they mean, and they do not compare across operators.no_deposit_with_excessalone is enforced (deposit0,excessabove 0), so it may be read literally. Rank a group's options bypriceand describe them fromexcessanddeposit. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).protection_coverage_codes· EnumList · required The coverage vocabulary shared byProtection.coversandProtection.excludes: parts of the vehicle (glass,undercarriage,tyres_and_wheels) and events (theft,fire,wrong_fuel). The codes carry no language — render them in your customer's language. They do not state the legal extent of any cover; the operator's rental terms bind. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).restriction_codes· EnumList · required Every activity codePolicy.restrictionscan carry — what an operator's rental agreement forbids the renter from doing. Read it at runtime: it grows, and you must tolerate an unknown code. It says nothing about where a vehicle may be collected or returned: a movement code such ascross_border_useorferry_or_sea_transportconcerns how the hired vehicle is used, while refused pickup-dropoff pairs arePolicy.movement_restrictions. Five codes are spelled asProtection.excludesspells them because they name the same activity: there, damage arising that way is not covered; here, the agreement forbids it.international_permit_scopes· EnumList · required When an International Driving Permit must be presented alongside the licence — the value set ofPolicy.licence_rules.international_permit_required_for. Country-neutral by construction:all_foreign_licencesmeans issued outside the country the rental takes place in, so the vocabulary reads the same for every operator on this platform. An empty list on a policy is the operator stating that a permit is never required; the member is never absent whenlicence_rulesis present.fuel_policies· EnumList · required The fuel-term vocabulary (§8.6) — whatPolicy.fuel.typecan carry. These are terms the desk applies; no fuel charge is computed or collected through this API. Meanings are onPolicy.fuel.type. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).transmissions· EnumList · required The gearbox vocabulary — whatVehicle.transmissioncan carry (manual,automatic). The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).fuels· EnumList · required The fuel-type vocabulary — whatVehicle.fuelcan carry. It describes the vehicle; the refuelling terms a customer is held to arefuel_policiesabove. Meanings are onVehicle.fuel. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).body_styles· EnumList · required The body-style vocabulary — whatVehicle.body_stylecan carry. It describes the shape of the vehicle, andotheris a stated value meaning a real vehicle this list does not name, never a stand-in for "not stated" (an unstated body style is an absent member). Meanings are onVehicle.body_style, which also explains how it relates toacriss_decoded.type. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).electric_range_bases· EnumList · required The test cyclesVehicle.electric_range_basiscan name forVehicle.electric_range_km: a range compares across suppliers only on the same cycle, so the two members are present or absent together.wltpandepaare the cycles this API distinguishes;manufactureris not a cycle but a figure from an unnamed cycle or from none. Meanings are onVehicle.electric_range_basis. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).mileage_types· EnumList · required The mileage-term vocabulary (§8.7) — whatPolicy.mileage.typecan carry (unlimited,limited). Meanings are onPolicy.mileage.type. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).currencies· EnumList · required Every currency this deployment can price in, as ISO 4217 alpha-3 codes. Acurrencyoutside this list is refused: availability returns emptydatawithmeta.unservable_currency, and POST /v1/quote answers 422. Your supplier's own iscurrencyon GET /v1/whoami, per key — pin that one. The list grows within/v1(forward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).locales· EnumList · required Every language tagAccept-Languagecan actually be honoured in today (§1.4) — not a wish list. Read it here rather than sending a language on the strength of a documentation example. The list grows within/v1(seeforward_compatibility.extensible_enums): read it at runtime and treat an unknown value as unknown (§1.2).acriss· object · required The four ACRISS position legends (§8.4) — an object of four maps, not a list, which is why this member is shaped differently from the twelve above. It is the same table GET /v1/reference/vehicles decodes with, soVehicle.acriss_decodedand these legends can never disagree. Its keys are single-character codes, so the value objects are deliberately left structurally open here: read a code you do not recognise as undecodable rather than failing the parse.category· object · required Position 1 — the size/class band. Maps each single-letter code to its display string. Keys are dynamic (the legend grows as codes are added), so read the map rather than hardcoding it.type· object · required Position 2 — the body type. Maps each single-letter code to its display string; keys are dynamic, so read the map rather than hardcoding it.transmission_drive· object · required Position 3 — transmission and drive, which that one character encodes together. Unlike the other three legends its values are objects, carrying atransmissionand adrivestring, because one code answers two questions. Keys are dynamic; read the map.fuel_air· object · required Position 4 — fuel together with whether air conditioning is fitted, which that one character encodes as a single fact. Maps each single-letter code to its display string; keys are dynamic, so read the map.
Error
error· object · required The error object. This envelope is not the success envelope (§1.5): a failing response carrieserrorand does not carrydata/meta, so branch on the HTTP status or on the presence of this key, never on adatayou expect to be there. It is the same shape on every failing status, including a bare 401 and a 404.code· string —malformed_request·unauthenticated·forbidden·not_found·booking_conflict·invalid_transition·price_changed·revision_conflict·idempotency_in_progress·version_mismatch·rate_expired·quote_expired·validation_error·idempotency_key_reused·driver_age_out_of_range·no_availability·rate_limited·internal_error·service_unavailable·not_sellable·availability_changed·quote_consumed·reference_set_too_large·credential_inactive·rotation_in_progress·credential_state_changed·self_revocation_refused·rotation_required·not_your_predecessor·no_webhook_secret·webhooks_disabled·unknown_supplier·supplier_not_enabled·supplier_paused·ambiguous_ref·supplier_required·supplier_mismatch·distribution_channel_not_approved· (extensible: new members may appear) · required The machine-readable reason. Branch on it — never onmessage, and never on the HTTP status alone: one status carries several codes with different remedies (§9). Retry only429,5xxandidempotency_in_progress(afterRetry-After), with exponential backoff and jitter; every other code needs a decision. The catalogue behinddoc_url(ERRORS.md) gives every code its status, meaning, retry answer and next step. The list grows within/v1: treat an unrecognised code as an unknown error of its status (§1.2). Reserved, published so the enum is stable and returned by nothing today:rate_expired(410), which is notrate_limited;supplier_not_enabled(403);supplier_paused(409).message· string · required · min length 1 A human-readable explanation, for your logs and your support desk. For display and diagnosis only: its wording is not part of the contract and can change without an api_version bump, so never parse it and never branch on it —codeis the machine-readable reason anddetails[]names the field.request_id· string · required · min length 1 The id of this request, echoed so you can quote it to support and correlate it with your own logs. It is the same value the success envelope carries asrequest_id, and it is present on every error. Log it on every failure: it is the fastest route to an answer about one specific call.doc_url· string · optional A link to the live public catalogue page for thiscode— the same table §9 carries, always current. Always populated, on every error: its absence from therequiredarray above is a publishing convention (it was published optional from birth and a published guarantee is not changed after the fact), never permission for an error to omit it — the same conventionPolicy.tax_inclusiveand availability'smeta.withheldfollow. It is a documentation pointer, not an API endpoint: do not fetch it as part of your error handling, and do not construct one yourself.details· array of object · optional Field-level detail about what to fix, when the code alone is not enough — chiefly onvalidation_error. Optional: absent where there is nothing field-level to say, so null-check it rather than assuming it is there. Where present it can carry more than one entry, and both ends of a rental can be reported at once. Every entry names afield; the other members it carries depend on the surface that raised it, so read what is there and ignore what you do not recognise.field· string · required · min length 1 The path of the offending input, in the request's own vocabulary — a dotted body path on a write, a query parameter name on a read. It is the one member every entry is guaranteed to carry, which is what makes it safe to point a user at the field to fix. Entries also carry anissuestring naming why, and that vocabulary is finer, per-surface, and not covered by the extensibility guaranteeerror.codehas — tolerate anissueyou do not recognise as 'this field was rejected, reason unknown to me'.
Booking
supplier_id· string · optional · min length 1 The supplier this booking belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0). Store it beside the booking and key your own records on (supplier_id, external_ref); never parse it. Present on every /v1 response that carries a booking. Optional here only because this shape is also a webhook delivery'sdata, which does not carry it: a delivery stream belongs to one relationship, so an absent value there means the stream's own supplier, never a different one.booking_id· string · required · min length 1 This platform's identifier for the booking: an opaque UUID string. Store it as a string and never parse it. It is stable, sits at the top level of every webhook envelope (§7.2), and addresses the retrieve, modify and cancel paths with?ref_type=booking_id. Three identifiers name one booking and are not interchangeable (§2.7, §11.2): this one;external_ref, yours and the paths' default; andbooking_ref, the operator's desk reference, which appears only on settlement lines — quote it, never join on it. Reconcile settlements onexternal_ref. Which one a renter shows at the desk is not stated here: ask before printing one on a voucher.plan· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · optional The price-book plan this booking was priced on — the protection tier the operator includes in the rate on that plan (since 1.30.0). Present only when the booking was priced from a named plan (aplanon the quote or on the direct create); absent on every booking priced from the plan served by default, including every booking made before 1.30.0 — absence never means 'no plan', it means 'the served default of its day'. Frozen at create: a modify re-prices on this plan, never on whichever plan the operator serves by default later. Published x-extensible-enum, likeprotection.tier.external_ref· string · required · min length 1 Your own reference, supplied at create and echoed back unchanged. Unique within one supplier's book and never reused by you across suppliers (§1.8), so{ref}resolves it across every supplier your key reaches;?ref_type=booking_idswitches the path tobooking_id. The join key for settlement reconciliation (§11.2). Immutable — a modify cannot change it; re-sending it with a different intent is 409 booking_conflict. Seebooking_idfor how the three identifiers relate.quote_id· string or null · required The quote this booking was created from, ornullfor a direct booking made without one. Persisted so the search → quote → book chain is auditable end to end. It is historical, not actionable: a consumed quote cannot be booked again (409 quote_consumed), so never re-send this value.status· string —pending·confirmed·cancelled·no_show·completed· (extensible: new members may appear) · required Where the booking is in its lifecycle (§8.1). Every/v1create isconfirmedat once.pendingis declared for manual approval; no/v1create produces it.cancelled,no_showandcompletedare terminal. When the desk setsno_showorcompleted, a newrevisioncarries it, with abooking.no_showorbooking.completedevent (since 1.21.0; earlier ones only on re-read). A desk reinstatement fromno_showtoconfirmedmovesstatuswith no revision or event, so keep polling the list (§5.5). A modification never movesstatus;revisionrecords it. Open list: unknown means unconfirmed and unfinished; re-read. Pinned below 1.64.0, you see only these five (§8.1).revision· integer · required · ≥ 1 The /v1 write counter for this booking: 1 at create, +1 on each modify and each cancel. It is the ordering key — apply only what is newer than what you already hold, and discard a stale update on both sides. It does not count everything that can change: a desk-sideno_showorcompletedmovesstatusandupdated_atand leaves this where it was, so it orders the /v1 write history and nothing else. A no-op patch also reuses it rather than bumping it. To notice a desk-side change, poll and comparestatus(§5.5).created_at· Timestamp · required When the booking was created, as an ISO-8601 instant with an explicit offset. It never moves.updated_at· Timestamp · required When this booking last changed — including a desk-side change that leftrevisionwhere it was, which is why this member andrevisioncan disagree and why the list poll (§5.5) comparesstatusrather than waiting for a revision bump.pickup· BookingStop · required Where and when the rental is collected. The instant it carries is the one the out-of-hours rules and the billed day count are judged against, resolved in the station's own publishedtimezone(§2.1).dropoff· BookingStop · required Where and when the rental is returned, on the same terms aspickup— same station vocabulary, same clock, same rules.rental_days· integer · required · ≥ 1 The billed day count for this rental as the engine computed it (§6.2) — not a naive date subtraction, which can differ. It is what every per-day price on this booking was multiplied by, and the figure to compare against an operator'smin_rental_days/max_rental_days.vehicle· object · required The vehicle group this booking is for — a class, never a specific unit; the unit is chosen by the operator at handover. Frozen at creation: these values are the snapshot taken when the booking was made, so a later catalogue edit cannot re-identify a settled booking.vehicle_id· string · required · min length 1 The group's opaque, stable identifier — the same value GET /v1/reference/vehicles publishes. This is the identifier you sell and re-send; treat it as a string and never parse it.acriss· string or null · required The group's industry classification code as it stood when the booking was made — four characters, as the catalogue published it. It carries nopattern, deliberately: a booking is re-validated when read, so a constraint would bind every snapshot ever stored; read it as recorded data.nullwhere the group had no code; a code is never invented. A later correction of the group's code never reaches this booking.allocated_vehicle_id· string · optional · min length 1 The vehicle group this booking is allocated to, as the idGET /v1/reference/vehiclespublishes. A group withdrawn from sale leaves that catalogue and the id then no longer resolves there, but it stays comparable: the same value is the same group, always, so keep your own mapping. Read it instead of the deprecatedallocated_category: it names the same group without the operator's internal label and survives a rename. Today it always equals thevehicle_idyou booked; it is separate because it tracks the allocation, not your selection. Not inrequired, which is frozen for/v1, but present on every booking this API creates or re-states.allocated_category· string · required · min length 1 Deprecated: readallocated_vehicle_id, which identifies the same group without naming it. The operator-side group this booking was allocated to, as an opaque label, recorded on the snapshot so a later catalogue change cannot re-identify a settled booking. Display it if you like; never map it, parse it or use it in place ofvehicle_id. It is served, unchanged, for the life of/v1; theDeprecationandSunsetheaders anddeprecations[]onGET /v1/capabilitiescarry the dates.display_name· string · required · min length 1 The customer-facing name of the group as it stood at creation, safe to show on a voucher. Display only — never match on it.
protection· object · required The protection product selected for this booking, and the source of the customer'sexcessanddeposit. Exactly one is selected on every booking — there is no default tier — and it is frozen at creation.protection_id· string · required · min length 1 The opaque identifier of the selected protection product — the same value GET /v1/reference/protections publishes for this group.tier· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · required The slot the selected product occupies in the group's ladder:basic,medium,premiumorno_deposit_with_excess. The three nominal names are nominal and nothing on this API enforces what they mean or how they rank;no_deposit_with_excessis the exception, and its terms are enforced — describe the cover to a customer fromexcessanddeposit, never from this word.excess· Money · required The customer's maximum liability for damage under the selected product, in integer minor units (§1.6). A term the desk applies, not a rule this API enforces — nothing here charges, holds or releases it. Frozen at creation, so it cannot move underneath a sold rental.deposit· Money · required The security deposit the desk blocks at pickup under the selected product, in integer minor units (§1.6). A hold, not A charge (§6.4), and nothing is taken, held or released through this API. Frozen at creation. Readdeposit_payment_methodson GET /v1/reference/policies for what the desk accepts to block it.
extras· array of object · required The extras on this booking, priced and frozen at creation. An empty array means none were taken — it is a stated fact, not a missing member.extra_id· string · required · min length 1 The opaque identifier of the extra — the same value GET /v1/reference/extras publishes. Join on it to render the extra's name and description.quantity· integer · required · ≥ 1 How many units of this extra are on the booking, at least 1. A declined extra is absent from the array rather than present with a quantity of 0.unit_price· Money · required What one unit was priced at, in integer minor units (§1.6), frozen at creation.total· Money · required What this extra line charges for the whole rental, in integer minor units (§1.6) — the authoritative figure.quantity × unit_priceneed not equal it: a per-day extra is multiplied by the billed day count and a price cap can hold it down. Read this, never derive it (§6.1).payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this extra is due:prepaid— it falls in the prepaid part of the total;at_pickup— it is payable on arrival. It is what routes this amount into thepay_now/pay_at_pickupsplit ofprice.totals. No money moves through this API in either case.
cross_border· boolean · optional Present andtrueonly where the customer declared an intent to take the vehicle out of the country of hire, and the booking was priced accordingly. Absent means no declaration was made, which is what every booking taken before this member existed means too — so read an absence as 'not declared', never as 'declined' and never as an error (§1.2). It is immutable:PATCHdoes not accept it, because changing a declared crossing changes what the operator insures and not merely what they charge. A modify carries the original declaration forward unchanged.drivers· array of object · required · min items 1 Everyone who will drive, main and additional. At least one entry. Send every driver you intend to list or the price you show will be short: age fees are applied per qualifying driver across this whole array, and an additional driver is separately charged as an extra (§6.3).first_name· string · optional The driver's given name. Optional on the wire; send it for every driver you intend to list, because the desk checks the licence against the name.last_name· string · optional The driver's family name. Optional on the wire, on the same footing asfirst_name.age· integer · required The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across every entry in this array, main and additional alike, so two drivers in the same band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· string —main·additional· (extensible: new members may appear) · requiredmain— the driver the rental is contracted to, of which there is exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra (§2.3) while an age surcharge is charged as a fee (§2.5): they are different mechanisms and both may apply to the same person.country· string · optional The driver's country of licence/residence as an ISO 3166-1 alpha-2 code, uppercase. Optional.
customer· object · required Who the booking is for — the contracting renter, who need not be a driver. Distinct fromdrivers[]: this object never affects the price.first_name· string · required The renter's given name. Required.last_name· string · required The renter's family name. Required.email· string · required The renter's email address. Required — it is how the operator reaches the customer about this rental.phone· string · optional The renter's telephone number, with a country prefix where you have one. Optional, and worth sending: it is what the desk uses when a handover goes wrong.country· string · optional The renter's country of residence as an ISO 3166-1 alpha-2 code, uppercase. Optional. It is a customer attribute; the driver country that can affect pricing is the one on eachdrivers[]entry.locale· string · optional The renter's preferred language as a BCP-47-style tag. Optional. Readconstants.localesfor what can actually be honoured today rather than assuming a tag is supported (§1.4).
policies· object · required The three policy keys echoed onto this booking —mileage,fuelandcancellation. All three keys are always present; each may be an empty object where the operator has stated no such policy, so branch on whether an object has Atype, never on whether the key exists. This is the operator default, not necessarily your terms: a booking's copy carries nosourceand does not resolve a rule negotiated with you, so read your own terms from GET /v1/reference/policies or from availability'smeta.policies(§2.6).mileage· object · required The mileage term echoed onto this booking, carrying itstype(andincluded_km_per_daywhere the type is limited) and nothing else.{}where the operator has stated no mileage policy. It is the operator default untilper_broker_booking_display_policiesreads true on GET /v1/capabilities; feature-detect rather than assuming (§2.6).fuel· object · required The fuel term echoed onto this booking, carrying itstypeand nothing else.{}where the operator has stated no fuel policy. Governed by the sameper_broker_booking_display_policiesflag aspolicies.mileage.cancellation· object · required The cancellation term echoed onto this booking: the baretypeonly, without the fee schedule;{}where the operator states no default terms. It is the operator's default for the life of/v1, so for a broker with a negotiated rule it is not the governing rule, and on its own it says nothing about what cancelling costs. The governing rule iscancellation_policy; before you sell, readGET /v1/reference/policiesor availability'smeta.policies, and take the charged amount from thecancellationobject the cancel call returns (§2.6, §5.4).
price· object · required The full §6 price breakdown for this booking — a snapshot taken when the booking was created or last re-priced, not a live calculation. Every amount is integer minor units (§1.6), and every amount already has tax inside it wherever this deployment reportstax_inclusive: true.currency· string · required The currency of every amount in this breakdown, which tells you how many minor units make a major one. It carries nopattern, as its sibling arrays carry noitems, because a stored booking is re-validated when read and a constraint would bind every snapshot ever written. Today's engine writes an ISO 4217 alpha-3 code, and a fresh quote's breakdown declares that shape; on a stored booking, display the value as recorded.rental_days· integer · required · ≥ 1 The billed day count this breakdown was computed over (§6.2) — the same value as the booking's ownrental_days, repeated here so the breakdown is self-contained.line_items· array of object · required · min items 1 Priced lines in the same per-line shape as the quote breakdown's line_items (see Breakdown). Deliberately not constrained field-by-field here: a booking's price is the snapshot taken when the booking was created or last re-priced, and its lines carry that moment's breakdown fields. Validate fresh quotes against Breakdown; treat stored booking lines as data to display and reconcile.tax_breakdown· array of object · required Tax lines in the same per-line shape as the quote breakdown's tax_breakdown (see Breakdown), frozen at the same moment as line_items above.totals· object · required The rolled-up totals for this booking. Read these rather than summing the lines yourself: the engine's own arithmetic is what the operator bills.subtotal_ex_tax· Money · required The total of the priced lines excluding tax, in integer minor units. It is not the base of every tax line: where any line is non-taxable, a tax line'staxable_baseis smaller than this figure, so the two are different numbers and neither derives the other.tax_total· Money · required The tax inside this booking's total, in integer minor units — extracted per line by the engine and summed (§6.2). Read it; never derive it. Extracting once from the grand total disagrees with the invoice by cents.grand_total· Money · required What the rental costs in full, in integer minor units — tax included wherever this deployment reportstax_inclusive: true. It is the sum ofpay_nowandpay_at_pickup, and the amount a cancellation fee is a percentage of whereverfee_basisreadsgrand_total.pay_now· Money · required The prepaid part of the grand total, in integer minor units — the lines whosepayableisprepaid. Nothing is collected through this API: there is no payment instrument here, so this states which part of the total is prepaid, not that any money has moved.pay_at_pickup· Money · required The part of the grand total payable on arrival, in integer minor units — the lines whosepayableisat_pickup. Show it to a customer as an amount still to pay when they collect, so a prepaid figure is never mistaken for the whole cost.
deposit· object · required The security-deposit descriptor for this booking. It is structurally open: a current snapshot carries the amount, that it is a pre-authorisation rather than a charge, whether it is refundable, and that it is taken at pickup — but a booking's price is frozen when it was made, so an older snapshot may differ. Model it on the Breakdown schema'sdeposit, which a fresh quote is validated against, and display a stored booking's copy rather than re-validating it. Nothing is held or released through this API (§6.4).
cancellation· object · optional Present only once the booking has been cancelled — what cancelling cost and when it happened. Its absence means the booking has not been cancelled; there is no null form of this object.fee· Money · required What cancelling cost you: the charge from the cancellation rule frozen onto this booking at creation, evaluated against the notice you gave — under one rule an early cancellation can be free and a late one charged. It is the band's percentage of thefee_basisamount, rounded to the minor unit, so read it rather than re-deriving it: a percentage above 0 can still round to zero. The rule itself iscancellation_policy(a booking'spolicies.cancellationcarries only its type); before you sell, read it onGET /v1/reference/policiesor availability'smeta.policies(§2.6). An amount you owe the operator, not money moved: nothing is collected through this API. Seerefund.refund· Money · required Always zero, and that is a different statement from the sibling fee member. Nothing is collected through this API — there is no payment instrument here — so there is nothing to give back, and no refund is ever reported. Do not build refund reconciliation on fee and refund; settle the fee through the billing relationship you already hold with the operator.reason· string or null · optional The reason supplied on the cancel call, echoed back verbatim, ornullwhere none was given. Free text: never parse it, and note that it is yours, not a machine-readable cause.cancelled_at· Timestamp · optional When the cancellation was recorded, as an ISO-8601 instant with an explicit offset. It is the instant the notice was given, which is what the cancellation ladder was evaluated against.
no_show· object · optional Present only whilestatusisno_showand the no-show rule frozen onto the booking charges a fee: what the no-show cost, and when it was recorded. Absent on a no-show means the no-show cost nothing. A reinstated booking omits it.fee· Money · required What the no-show cost:cancellation_policy.no_show_fee_percentof thefee_basisamount, rounded to the minor unit. An amount owed to the operator; nothing is collected through this API.recorded_at· Timestamp · required When the operator's desk recorded the no-show, as an ISO-8601 instant with an explicit offset.
flight_number· string or null · optional The customer's inbound flight, so the desk can hold a vehicle for a delayed arrival.nullwhere none was supplied. Free text as you sent it — it is operational information for the desk, and it does not affect the price and does not move the pickup instant.notes· string or null · optional Free-text detail for the desk, echoed back as you sent it, ornull. A person at the desk reads it; nothing parses it, so never rely on it being machine-actioned. On a delivery-priced location it carries the hotel's name and address. Structured fulfilment requests belong inspecial_requests.meta· object · optional Your own open object (§1.8b), stored verbatim and never reinterpreted. It is outside the intent hash (§1.8), so changing it never causes a409, and you may enrich it on a later modify. Two namespaces are reserved:meta.billing, the invoicing identity a company rental needs (tax identifier, company name, address, invoice email), andmeta.partner, your correlation data (booking id, agent, campaign), which is never parsed; both are optional. It stays structurally open, so do not expect it to be validated or rejected.distribution_channel· object · optional The downstream channel this booking was sold through, as recorded when you named it. Absent when none was recorded, which never means the booking was sold directly.distribution_channel_id· string or null · required The operator's register entry the code matched, as listed by GET /v1/distribution-channels; null when the code matched no entry.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, exactly as you sent it.approval· string —approved·unregistered·proposed·suspended·deactivated·not_yet_effective·expired· (extensible: new members may appear) · required What the register said about the channel when it was recorded. Alwaysapprovedunless your agreement accepts and flags bookings through channels the operator has not approved; then it names why the channel was not approved at that moment.
cancellation_policy· object or null · optional The cancellation rule this booking was sold under, frozen at creation: the rule you display is the rule the charge follows. It is notcancellation, the fee applied at cancel time, norpolicies.cancellation, the operator's default.sourcesays whose rule it is. Readtype, then its member:free_untilcarriesfree_until(an inclusive deadline) andlate_fee_percent;tieredcarriestiers;non_refundablecarries neither.fee_basissays what each percentage is of. Absent only when the latest revision predates 1.21.0 and has not been touched since — readGET /v1/reference/policiesbysourceinstead.nullis reserved: treat it aspolicies.cancellation. Display only: the charge is computed by the server and returned incancellation.type· string —free_until·non_refundable·tiered· (extensible: new members may appear) · required Which rule shape applies, and therefore which member below is present:free_until— free up to a deadline, then charged;tiered— a ladder of notice bands;non_refundable— charged in full whatever the notice.free_untilandtiersare mutually exclusive and only the one matching this value appears;non_refundablecarries neither.source· string —operator_default·broker_terms·legacy_zero_fee· (extensible: new members may appear) · required Whose rule was frozen onto this booking:operator_default, the operator's standing rule as it stood then;broker_terms, a rule negotiated with you;legacy_zero_fee, a booking made before cancellation charging existed on this API, sold with free cancellation at any notice, exactly as its ladder shows. Always present in the object, and never moved by a later edit to the standing rule. It does not coverfee_basis, which is never negotiated and carries the operator's value even underbroker_terms. The set may grow: treat an unknown value as a rule we will state on request.free_until· string · optional · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$Ontype: "free_until"only: the instant, ISO-8601 with an explicit offset, up to and including which cancelling this booking is free; after itlate_fee_percentapplies. It is the pickup instant minus the frozen hours of notice, in the pickup's own offset, so you can show the renter's wall-clock time. A modification that moves the pickup restates it; the rule does not change.GET /v1/reference/policiesgives the standing rule in hours (free_until_hours_before_pickup); this is the deadline for this booking.late_fee_percent· integer · optional · ≥ 0; ≤ 100 Present ontype: "free_until"only — what cancelling after thefree_untildeadline costs, as a whole-number percentage (0–100) of the amountfee_basisnames. Always stated on afree_untilrule,0meaning cancellation stays free right up to and beyond the pickup instant. Added at 1.21.0 so afree_untilrule can be stated whole: a deadline with nothing after it would leave you no way to tell a customer what happens one minute later. Display it, do not apply it: the amount actually charged is server-computed and arrives in the siblingcancellationobject at cancel time.tiers· array of object · optional · min items 1 Present ontype: "tiered"only — the fee ladder as frozen onto this booking, ordered byhours_before_pickupdescending (longest notice first), always ending in a band at 0 hours (what a very late cancellation costs), with at least one band.hours_before_pickup· integer · required · ≥ 0 The notice threshold of this band, in whole hours before pickup. The band that governs is the one with the greatest value at or below the notice actually given.fee_percent· integer · required · ≥ 0; ≤ 100 What this band charges, as a whole-number percentage (0–100). Display it, do not apply it: the amount actually charged is server-computed and arrives in the siblingcancellationobject at cancel time.
fee_basis· string —grand_total·pay_now·base_rate· (extensible: new members may appear) · optional What every percentage in this object is a percentage OF —grand_total(the whole booking),pay_now(the prepaid portion) orbase_rate(the vehicle rate alone). Always present, and frozen with the rest of the rule: the three are materially different amounts of money, and a percentage without its basis is only half a rule. Never per-broker negotiable — it is the operator's basis even whensourcereadsbroker_terms. Added at 1.21.0 alongsidelate_fee_percent, for the same reason.no_show_fee_percent· integer · optional · ≥ 1; ≤ 100 The no-show fee frozen onto this booking, as a whole-number percentage of the amountfee_basisnames. Absent means a no-show costs nothing. The amount charged arrives in the booking'sno_showobject when the no-show is recorded.
special_requests· object or null · optional Structured fulfilment preferences a desk can act on, as distinct from free text.null, absent and an empty object all mean the same thing — no special requests. It never prices anything: a chargeable child seat remains a separateextrasline. Free-text detail belongs in the top-levelnotes, which is why this object deliberately carries none of its own. Reserved and not emitted today, and the request side is not accepted either: a create carrying it is rejected 422 rather than silently dropped.structured_special_requestson GET /v1/capabilities is what discloses when it goes live.child_seats· object · optional A type→quantity map, not an array — a type structurally cannot repeat, so 'one entry per type' is enforced by the shape rather than by a rule. A present key means that many seats of that type; an absent key means none. A present-but-empty map means 'no child seats requested' and implies nothing about other request types — do not read the wholespecial_requestsobject as empty merely because this map is. It carries the age band and count for fulfilment only; a chargeable seat is still a pricedextrasline.infant· integer · optional · ≥ 1; ≤ 4 How many rear-facing infant seats are requested, 1–4. Absent means none. Fulfilment only — it prices nothing.child· integer · optional · ≥ 1; ≤ 4 How many forward-facing child seats are requested, 1–4. Absent means none. Fulfilment only.booster· integer · optional · ≥ 1; ≤ 4 How many booster seats are requested, 1–4. Absent means none. Fulfilment only. The wire caps each type at 4; the total across types is bounded by the vehicle's seat capacity as a desk rule, not by this schema.
change· object · optional Why the operator changed this booking, on the revision it minted (booking.modified). A later revision omits it unless also an operator change, so absence never means the booking was never changed. It has nonullform and never movesstatus. One per desk save, the most specific of three sources: the operator could not honour the allocated group and moved it to another internal class —reason: 'overbooking',vehicle.allocated_categoryrestated; the desk stated a publishedreason, with an optionalnote; or (since 1.66.0) the desk corrected price or dates, no reason stated —reason: 'operator_adjustment'.vehicle_id,acrissanddisplay_namestay as sold. Treat it as a prompt to talk to us, never as a re-allocation you can resell.actor· string —operator·partner· (extensible: new members may appear) · required Who made the change:operator— the desk or the operator's own system;partner— you, through this API. Required whenever the object is present.reason· string —breakdown·overbooking·force_majeure·operator_adjustment·vehicle_replaced·cannot_fulfil·other· (extensible: new members may appear) · required Why the change was made.breakdown: the allocated vehicle became unusable.overbooking: the shared fleet was oversold and the original allocation could not be honoured.vehicle_replaced: the operator replaced the allocated vehicle or group (where the class moved,vehicle.allocated_categoryis restated).cannot_fulfil: the booking cannot be fulfilled as made and was changed — expect a call, and read the moved members andnote.force_majeure: an event outside the operator's control.operator_adjustment: a deliberate correction.other: seenote. Always present in the object. An open set: an unknown value means the operator changed the booking — read what moved and talk to us.changed_at· Timestamp · required When the change was recorded, as an ISO-8601 instant with an explicit offset.note· string or null · optional Free-text detail about the change, ornull. For display and for your support desk — never parse it, and never branch on it:reasonis the machine-readable cause.
payment· object · optional How the customer pays for this rental under your agreement with the operator: how much falls due up front, how much at the desk, and who takes each part. Absent whenever it cannot be stated truthfully — a booking older than this block, one sold under no commercial term, or a term that was defaulted rather than chosen — so absence never means nothing is prepaid. It is notprice.totals.pay_now, which is the operator's product-level split, the same for every partner; this is your relationship's schedule. It states what the customer pays, not what either party owes the other: remittances are onGET /v1/settlements. No money moves through this API.model· string —pay_on_arrival·prepaid·partial· (extensible: new members may appear) · required The commercial model that applied to this booking: pay on arrival (the customer pays the desk), prepaid (the whole total up front) or partial (a share up front, the rest at the desk). The exact member values are the enum above. It is the model your agreement makes the default unless the booking named one you may sell.amount_prepaid· Money · required The part ofprice.totals.grand_totaldue up front, as Money in its currency; below a 1.65.0 pin, a bare integer in minor units.amount_prepaid + amount_on_arrivalalways equalsgrand_totalexactly: the remainder is taken by subtraction, never rounded twice. Recomputed, not frozen: it follows the booking's current total, so a modification that re-prices the rental moves it. The agreement is frozen at sale; the money it applies to is what the rental is worth now.amount_on_arrival· Money · required The part ofgrand_totaldue at the desk, shaped asamount_prepaidis for your pin. Taken by subtraction from the prepaid part.who_collects· object · required Who takes the money, stated per portion rather than once for the booking — because on apartialbooking the two portions can genuinely have different collectors, and any single value would be true of one amount and false of the other sitting beside it. A key is present exactly when its portion exists undermodel, so nothing is ever asserted about a portion that does not exist. It is not A statement of who the customer's payment counterparty is, and not a statement of who invoices the customer — this API does not model either.prepaid· string —operator·partner· (extensible: new members may appear) · optional Who receives the customer's up-front payment. Present only when the model has an up-front portion — so it is absent on a pay on arrival booking, where there is none.on_arrival· string —operator·partner· (extensible: new members may appear) · optional Who receives the at-the-desk payment. Always the operator when present, structurally: the desk is the operator's. Absent onprepaid, where nothing is due at the desk.
settlement_basis· string —net·gross· (extensible: new members may appear) · required The basis your agreement settles on —gross(you are owed a commission off the total) ornet(the total already is what the operator receives). The basis only: no commission rate or amount is published on this API.
CreateBookingRequest
plan· string —basic·medium·premium·no_deposit_with_excess· optional The price-book plan to price this direct booking from — which protection tier the rate includes. Optional: omit it and the plan the operator serves your account by default applies. Send theplanof the availability row you are booking (rows carry it unlessavailability_plansis off for your account). A plan your account holds no book for is422unknown_plan. The plan is frozen on the booking (plan), and every later modify re-prices on it. On the quoted path the plan is the quote's.external_ref· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own reference for the booking — required, unique within one supplier's book and never reused across your suppliers (§1.8); the value{ref}takes by default on every later read, modify and cancel. Immutable once created: a different intent under it is 409 booking_conflict, the same intent replays the original. Letters, digits, dot, underscore and hyphen, 1–64 characters.pickup· StopInput · required One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. This one is the collection.dropoff· StopInput · required One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. This one is the return.vehicle_id· string · required · min length 1 The vehicle group to book, from GET /v1/reference/vehicles. Required on this direct path. You are booking a class, not a unit.protection_id· string · required · min length 1 The protection product to book, from GET /v1/reference/protections. Required — there is no default tier, so the customer's choice must be presented and sent. It must belong to the selectedvehicle_id, or the request is rejected 422.extras· array of object · optional The extras to add. Optional — omit it entirely to take none; an empty array means the same thing. Each entry is a separate product, so send one entry perextra_idrather than repeating one.extra_id· string · required · min length 1 The opaque identifier of the extra, from GET /v1/reference/extras. An id that is no longer offered is rejected 422 naming the field — that is the authoritative withdrawal signal, not the catalogue'sactiveflag.quantity· integer · required · ≥ 1; ≤ 9 How many units of this extra, 1–9. To decline an extra, omit it from the array — do not send a quantity of 0. The catalogue's ownquantity.maxcan be lower than 9, and the effective ceiling is the lower of the two; a request above it is rejected 422. A non-countable extra (requires_quantity: false) takes 1.
cross_border· boolean · optional The customer's declared intent to take the vehicle out of the country of hire; sendtrueonly when they have said so — absent is not a declaration. Where the operator configures a cross-border rule it changes the price, and it is part of a booking's identity forexternal_ref(§1.8): a retry with a different value answers409booking_conflict. It is not a one-way, which aone_wayrule prices, but a border crossed between collecting and returning at one desk, which the locations cannot show. A missing cross-border fee is not permission to cross: the API does not publish whether the operator allows it (§2.6).drivers· array of object · required · min items 1; max items 9 Everyone who will drive, at least one and at most nine. Send every driver you intend to list or the price will be short: age fees are applied per qualifying driver across this array, and an additional driver is separately charged as an extra (§6.3).first_name· string · optional · min length 1 The driver's given name. Optional on the wire; send it for every driver, because the desk checks the licence against the name.last_name· string · optional · min length 1 The driver's family name. Optional on the wire, on the same footing asfirst_name.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across this whole array, main and additional alike, so two drivers in one band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· unspecified —main·additional· requiredmain— the driver the rental is contracted to; send exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra while an age surcharge is charged as a fee: different mechanisms, and both can apply to the same person.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
customer· object · required · no other members Who the booking is for — the contracting renter, who need not be one of thedrivers. It never affects the price.first_name· string · required · min length 1 The renter's given name. Required.last_name· string · required · min length 1 The renter's family name. Required.email· string · required · pattern^[^@\s]+@[^@\s]+\.[^@\s]+$The renter's email address. Required — it is how the operator reaches the customer about this rental. Pattern-checked for shape only; a syntactically valid address that does not exist is accepted here and fails later.phone· string · optional · pattern^\+?[0-9 ().-]{5,32}$The renter's telephone number, digits with an optional leading+and common separators. Optional, and worth sending: it is what the desk uses when a handover goes wrong.country· string · optional · pattern^[A-Z]{2}$The renter's country of residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional. It is a customer attribute; the driver country is the one on eachdrivers[]entry.locale· string · optional · pattern^[a-z]{2}(-[A-Z]{2})?$The renter's preferred language as a BCP-47-style tag (en, oren-GB). Optional. Readconstants.localeson GET /v1/reference/constants for what can actually be honoured rather than assuming a tag is supported.
currency· string · optional · pattern^[A-Z]{3}$The ISO 4217 alpha-3 currency to price in, uppercase. Optional: omit it and the offer's supplier prices in its own currency. One that supplier does not price in is refused — readcurrencyper supplier on GET /v1/suppliers and pin that.expected_price· object · optional · no other members The price-consent gate: the supplier grand total you are consenting to, never a figure you computed or added fees to. If the computed total differs, the request fails409price_changedwith the full new breakdown, so you can re-consent and resubmit; nothing is ever repriced silently. Send it on every direct create; omitting it is422unless your account accepts the supplier price without a consent gate. A quoted booking takes none: sending one is422.grand_total· MoneyInput · required The supplier total you are consenting to, in integer minor units with its currency (§1.6) — the same figure a quote reports astotals.grand_total, tax included. It is compared exactly; it is not a tolerance.
flight_number· string or null · optional The customer's inbound flight, so the desk can hold a vehicle for a delayed arrival. Optional;nullis accepted and means the same as omitting it. It does not affect the price and does not move the pickup instant.notes· string or null · optional Free-text detail for the desk. Optional;nullmeans the same as omitting it. A person at the desk reads it; nothing parses it, so it is not machine-actioned. On a delivery-priced location, put the hotel's name and address here.meta· object · optional Your own open object (§1.8b), stored verbatim with the booking and returned on it, never reinterpreted. It is outside the intent hash (§1.8), so changing it never causes a409, and you may enrich it on a later modify. Two namespaces are reserved:meta.billing, the invoicing identity a company rental needs (tax identifier, company name, address, invoice email), andmeta.partner, your correlation data (booking id, agent, campaign), which is never parsed; both are optional. It stays structurally open, so a payload accepted today is never refused later.distribution_channel· object · optional · no other members The downstream channel you sold this booking through, when your agreement lets you distribute onward. Your agreement's policy, on GET /v1/whoami, says whether it is refused, optional or required. A channel the operator has not approved is refused withdistribution_channel_not_approved, or accepted and flagged where your agreement says so. Outside the intent hash: a retry naming another channel replays the original booking.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
mode· unspecified —direct· optional Optional. Names the shape you are sending, so a mismatch is caught at the field you got wrong. On this direct path the only accepted value isdirect; omitquote_idand you are on this path whether or not you send it. If you sendmode:"quoted"but noquote_id, this request is read as a direct create and rejected 422 namingmode— which is the point of the member. It is optional and stays optional for the whole life of /v1: sending it changes nothing about how the request is priced or routed.payment_model· unspecified —pay_on_arrival·prepaid·partial· optional Optional: the payment model you sell this booking under. It must be one your agreement with the operator permits — agreed off the API — and one it does not permit is refused422, never replaced by your default. Omit it and your agreement's default applies. It stays optional throughout/v1.PATCHcannot change it: the terms are fixed at the sale.
CreateQuotedBookingRequest
quote_id· string · required · pattern^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$The quote to book: the UUIDPOST /v1/quotereturned. Required, and its presence selects this shape — without it the request is a direct create. It is pattern-checked, so a truncated id fails422, not404. An unexpired quote books at exactly its price and the selection lives in the quote, so this shape refuses route, date, vehicle, protection, extras and currency fields. Failures:404(no such quote),409quote_expired,409quote_consumed, and409availability_changed— re-quote; the selection is usually still fine.external_ref· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own reference for the booking — required, unique within one supplier's book and never reused across your suppliers (§1.8); the value{ref}takes by default on every later read, modify and cancel. Immutable once created: a different intent under it is 409 booking_conflict, the same intent replays the original. Letters, digits, dot, underscore and hyphen, 1–64 characters.drivers· array of object · required · min items 1; max items 9 The names for the drivers the quote already priced — at least one and at most nine. It re-states the priced set, it does not change it: the{age, role}multiset must equal the quote's. Names may differ and order does not matter, but a different age or role is 422 validation_error — re-quote to change the priced driver set.first_name· string · optional · min length 1 The driver's given name. Optional on the wire; send it for every driver, because the desk checks the licence against the name.last_name· string · optional · min length 1 The driver's family name. Optional on the wire, on the same footing asfirst_name.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years — it must match the quote's priced driver set, so this is a restatement rather than an input. A value the quote did not price is 422; re-quote instead.role· unspecified —main·additional· requiredmainoradditional— and, likeage, it must match the quote's priced driver set rather than change it.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
customer· object · required · no other members Who the booking is for — the contracting renter, who need not be one of thedrivers. It never affects the price.first_name· string · required · min length 1 The renter's given name. Required.last_name· string · required · min length 1 The renter's family name. Required.email· string · required · pattern^[^@\s]+@[^@\s]+\.[^@\s]+$The renter's email address. Required — it is how the operator reaches the customer about this rental. Pattern-checked for shape only; a syntactically valid address that does not exist is accepted here and fails later.phone· string · optional · pattern^\+?[0-9 ().-]{5,32}$The renter's telephone number, digits with an optional leading+and common separators. Optional, and worth sending: it is what the desk uses when a handover goes wrong.country· string · optional · pattern^[A-Z]{2}$The renter's country of residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional. It is a customer attribute; the driver country is the one on eachdrivers[]entry.locale· string · optional · pattern^[a-z]{2}(-[A-Z]{2})?$The renter's preferred language as a BCP-47-style tag (en, oren-GB). Optional. Readconstants.localeson GET /v1/reference/constants for what can actually be honoured rather than assuming a tag is supported.
flight_number· string or null · optional The customer's inbound flight, so the desk can hold a vehicle for a delayed arrival. Optional;nullis accepted and means the same as omitting it. It does not affect the quoted price.notes· string or null · optional Free-text detail, read by desk staff and never machine-actioned. On a delivery-priced location, put the hotel's name and address here. Optional;nullmeans omitted.meta· object · optional Your own open object (§1.8b), stored verbatim with the booking and returned on it, never reinterpreted. It is outside the intent hash (§1.8), so changing it never causes a409, and you may enrich it on a later modify. Two namespaces are reserved:meta.billing, the invoicing identity a company rental needs (tax identifier, company name, address, invoice email), andmeta.partner, your correlation data (booking id, agent, campaign), which is never parsed; both are optional. It stays structurally open, so a payload accepted today is never refused later.distribution_channel· object · optional · no other members The downstream channel you sold this booking through, when your agreement lets you distribute onward. Your agreement's policy, on GET /v1/whoami, says whether it is refused, optional or required. A channel the operator has not approved is refused withdistribution_channel_not_approved, or accepted and flagged where your agreement says so. Outside the intent hash: a retry naming another channel replays the original booking.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
mode· unspecified —quoted· optional Optional. Names the shape you are sending, so a mismatch is caught at the field you got wrong. On this quoted path the only accepted value isquoted; sendingquote_idputs you on this path whether or not you send it. If you sendmode:"direct"alongside aquote_id, the request is rejected 422 namingmoderather than quietly pricing something you did not mean. It is optional and stays optional for the whole life of /v1: sending it changes nothing about how the request is priced or routed.payment_model· unspecified —pay_on_arrival·prepaid·partial· optional Optional: the payment model you sell this booking under. It must be one your agreement with the operator permits — agreed off the API — and one it does not permit is refused422, never replaced by your default. Omit it and your agreement's default applies. It stays optional throughout/v1.PATCHcannot change it: the terms are fixed at the sale.
CreateBookingRequestBody
No members.
WebhookRedeliverByWindow
Re-drive everything that occurred in a time window.
occurred_since· string (date-time) · required Full ISO-8601 with offset. Must fall inside the retention window — a selector reaching past it is refused 422 naming the window, never silently shortened.occurred_until· string (date-time) · optional Full ISO-8601 with offset. Optional upper bound; omit it and the window runs to now(). Must not precedeoccurred_since. A window running up to now() covers the deliveries that existed when we accepted it — if you widen one to the present, re-run it rather than treating a single call as final.
WebhookRedeliverByIds
Re-drive an explicit list of events, by id from your delivery log.
event_ids· array of string (uuid) · required · min items 1 The exact events to re-drive, byevent_idfrom your delivery log. De-duplicated before the per-request cap is applied, so a repeated id is one delivery and costs you one. An id that is not yours, or does not exist, is counted innot_foundrather than failing the whole request. Case-insensitive: an upper-case uuid is the same identity, not a second one.
WebhookRedeliverBySequence
Re-drive an inclusive range of your own delivery sequence for this subscription.
sequence_from· integer · required · ≥ 1; ≤ 9007199254740991 Inclusive lower bound of a deliverysequencerange. Sequences are your stream's, starting at 1, and are never re-minted by a replay — a re-driven row keeps the sequence it already had. May be sent as a decimal string.sequence_to· integer · required · ≥ 1; ≤ 9007199254740991 Inclusive upper bound of thesequencerange. Must not precedesequence_from. Both bounds are required together — a range with only one end is refused 422.
WebhookTestRequest
An empty object. The test event has no parameters — what it does depends on your account, not on the request.
No members.
WebhookRedeliverRequest
No members.
CancelBookingRequest
expected_revision· integer · required · ≥ 1 Therevisionyou last read for this booking. Required — cancelling a booking the desk has since changed is the same stale-write race a modify runs, so a stale value fails 409 revision_conflict carrying the current booking: re-read it, re-decide, and resubmit if you still mean to cancel. A desk-sideno_showORcompleteddoes not bumprevision, so a booking can have moved to a terminal state without this value going stale — such a cancel fails 409 invalid_transition instead.reason· string or null · optional Why you are cancelling, as free text — echoed back on the booking'scancellation.reasonand visible to the operator. Optional;nullis accepted and means the same as omitting it. It is not machine-readable and it does not affect what cancelling costs: the fee comes from the rule frozen onto the booking, evaluated against the notice actually given (§5.4).distribution_channel· object · optional · no other members The downstream channel the booking was sold through, if you send your channel code on every request (since 1.54.0). Optional under every agreement — a cancel is never refused for lacking it. When sent it must be the channel recorded on the booking (itsdistribution_channel.code), or the cancel is refused 422validation_errorwith issuebooking_channel_mismatchand nothing changes. It never changes the booking's channel; a modify does that.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
ModifyBookingRequest
expected_revision· integer · required · ≥ 1 Therevisionyou last read for this booking. Required on every patch. It is enforced only when the PATCH actually changes something: a patch that resolves to no field change is a no-op — 200, the revision is reused rather than bumped, no event is emitted, and this value is not checked. A patch with a real change enforces it, and a stale value fails 409 revision_conflict carrying the current booking: re-read, re-decide, resubmit.pickup· StopInput · optional One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. Send it only to move the collection. Moving an end re-checks availability and re-prices; an end you do not touch keeps the answer it was sold under.dropoff· StopInput · optional One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. Send it only to move the return, on the same terms aspickup.vehicle_id· string · optional · min length 1 The vehicle group to move the booking to. Send it only to change the group — it is one of the two fields that re-check availability, so a group with no free unit for the window fails 409 availability_changed and nothing is written: the booking stands exactly as it was.protection_id· string · optional · min length 1 The protection product to move the booking to. Send it only to change the cover. Since 1.32.0 a price plan sells exactly one package and a modify re-prices on the booking's own plan, so within a booking there is no other package to change to: any other package's id is 422unknown_protection. It is pricing-relevant, so the patch re-prices and needsexpected_price; it must belong to the booking's vehicle group.extras· array of object · optional The extras to add. Optional — omit it entirely to take none; an empty array means the same thing. Each entry is a separate product, so send one entry perextra_idrather than repeating one. It replaces the whole set rather than adding to it: send the full list you want the booking to end with, and send an empty array to remove them all.extra_id· string · required · min length 1 The opaque identifier of the extra, from GET /v1/reference/extras. An id that is no longer offered is rejected 422 naming the field — that is the authoritative withdrawal signal, not the catalogue'sactiveflag.quantity· integer · required · ≥ 1; ≤ 9 How many units of this extra, 1–9. To decline an extra, omit it from the array — do not send a quantity of 0. The catalogue's ownquantity.maxcan be lower than 9, and the effective ceiling is the lower of the two; a request above it is rejected 422. A non-countable extra (requires_quantity: false) takes 1.
drivers· array of object · optional · min items 1; max items 9 The full driver list the booking should end with, at least one and at most nine — it replaces the existing set rather than adding to it. Pricing-relevant, because age fees are resolved per qualifying driver across it.first_name· string · optional · min length 1 The driver's given name. Optional on the wire; send it for every driver, because the desk checks the licence against the name.last_name· string · optional · min length 1 The driver's family name. Optional on the wire, on the same footing asfirst_name.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across this whole array, main and additional alike, so two drivers in one band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· unspecified —main·additional· requiredmain— the driver the rental is contracted to; send exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra while an age surcharge is charged as a fee: different mechanisms, and both can apply to the same person.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
customer· object · optional · no other members Who the booking is for — the contracting renter, who need not be one of thedrivers. It never affects the price. Send it to correct the renter's details; it replaces the stored object rather than merging into it, and it is not pricing-relevant.first_name· string · required · min length 1 The renter's given name. Required.last_name· string · required · min length 1 The renter's family name. Required.email· string · required · pattern^[^@\s]+@[^@\s]+\.[^@\s]+$The renter's email address. Required — it is how the operator reaches the customer about this rental. Pattern-checked for shape only; a syntactically valid address that does not exist is accepted here and fails later.phone· string · optional · pattern^\+?[0-9 ().-]{5,32}$The renter's telephone number, digits with an optional leading+and common separators. Optional, and worth sending: it is what the desk uses when a handover goes wrong.country· string · optional · pattern^[A-Z]{2}$The renter's country of residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional. It is a customer attribute; the driver country is the one on eachdrivers[]entry.locale· string · optional · pattern^[a-z]{2}(-[A-Z]{2})?$The renter's preferred language as a BCP-47-style tag (en, oren-GB). Optional. Readconstants.localeson GET /v1/reference/constants for what can actually be honoured rather than assuming a tag is supported.
expected_price· object · optional · no other members The price-consent gate: the supplier grand total you are consenting to, never a figure you computed or added fees to. If the computed total differs, the request fails409price_changedwith the full new breakdown, so you can re-consent and resubmit; nothing is ever repriced silently. Required only for a pricing-relevant change (vehicle, protection, extras, drivers or dates): omitting it there is422unless your account accepts the supplier price without a consent gate. A metadata-only patch ignores it.grand_total· MoneyInput · required The supplier total you are consenting to, in integer minor units with its currency (§1.6) — the same figure a quote reports astotals.grand_total, tax included. It is compared exactly; it is not a tolerance.
flight_number· string or null · optional The customer's inbound flight. Send it to set or correct one;nullclears it. Not pricing-relevant, so a patch carrying only this is a metadata-only change.notes· string or null · optional Free-text detail;nullclears it. Replaces the stored value, never appends, and does not re-price. Read by desk staff, never machine-actioned; for a delivery-priced location it carries the hotel.meta· object · optional Your own open object (§1.8b), stored verbatim and returned on the booking. It is outside the intent hash (§1.8), so changing it never causes a409. Two namespaces are reserved:meta.billing, the invoicing identity a company rental needs (tax identifier, company name, address, invoice email), andmeta.partner, your own correlation data (booking id, agent, campaign), which is never parsed; both are optional. It stays structurally open, so a payload accepted today is never refused later. On a patch it replaces the stored object rather than merging: send the whole object.distribution_channel· object or null · optional · no other members Corrects the downstream channel this booking was sold through. An object names a channel, judged against the operator's register now;nullremoves the recorded channel unless your agreement requires one. Omit it to keep the recorded channel: it is not judged again, even if that channel has since been suspended. Re-sending the recorded code changes nothing.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
Capabilities
api_version· string · required · pattern^[0-9]+(\.[0-9]+){0,2}$The contract version of the surface answering you — the same string every webhook envelope carries asapi_version, so a receiver can branch during a major-version transition. It is one to three dot-separated numeric components: do not assume three and do not parse it as major.minor.patch. Treat it as an opaque version string, read whatever components are present, and never hardcode it (§1.2).environment· string —production·sandbox· (extensible: new members may appear) · optional Which deployment plane answered this request:sandbox— the isolated certification environment, whose data is fixture data and whose bookings are not real;production— the live plane. Optional and present-when-known: an assembly that was not told which plane it is omits the member rather than guessing, so read an absence as 'not stated', never as an error (§1.2). Use it to assert that a certification run really is pointed at the isolated environment. Keys are issued per environment, so when a known-good key is refused, check the base URL you are pointing at before you look here.versions· array of object · required · min items 1 One entry per path major this deployment answers, so a migration window is visible on the wire rather than announced only in prose. At least one entry. A future major appears here additively, before the current one is ever deprecated.version· string · required · pattern^[0-9]+$The major version as a bare number string — the same number that appears in the path prefix. Not a full version:api_versionabove is the surface version.status· string —current·deprecated·sunset· (extensible: new members may appear) · requiredcurrent— fully supported.deprecated— still answered, but migrate beforesunset_at.sunset— announced and no longer served. Branch on this rather than on the presence of a sunset date.sunset_at· string or null · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$When this major version is (or was) removed, as an ISO-8601 instant with an explicit offset. Required-and-nullable: the member is always present, and it isnullexactly whenstatusiscurrent, because a supported version has no removal date. Branch on the value, never on the key.
features· array of object · required The feature-detection registry — every 'feature-detect as …' in this contract resolves to a code here. Each entry is a{code, enabled}pair, andcodeis unique within the array. Ignore A code you do not recognise: it is a capability you have not built against, never an error, and the list grows additively (§1.2).code· string · required · min length 1 The stable identifier of a capability; ignore a code you do not recognise, and a code states what this deployment supports, never your account's opt-ins (§3.0). Known today:reference,availability,quotes,bookings,settlements,suppliers(the path family is served);webhooks(delivery is armed here);webhook_delivery_logandwebhook_redelivery(GET /v1/webhooks/deliveriesandPOST /v1/webhooks/redeliverare served; bounds inwebhooks.redelivery);availability_enrichment,availability_driver_age,availability_full_breakdown,availability_duration_bounds,availability_protection_ladder,availability_protection_ladder_pricing,availability_fee_identity,availability_vehicle_attributes,availability_withheld_reasons,availability_end_terms,availability_plans,availability_movement_restrictions(§4.1);reference_location_detail,reference_changed_since(?changed_since=on the five full-refresh catalogues, coarse on protections and fees);per_broker_reference_policies,per_broker_booking_display_policies,per_broker_cancellation_policy,structured_special_requests,operator_initiated_change(§2.6, §5.1).enabled· boolean · required Whether this deployment serves the capability. Deployment-wide, not per-account — this endpoint takes no key, so it has no account to answer for. Three availability behaviours are additionally enabled per account (availability_duration_bounds,availability_end_termsandavailability_movement_restrictions) and their codes answer for you in neither direction: do not readfalseas a fault on your account, do not readtrueas your own screening, and do not poll one waiting for your turn. Ask us which is on for you.
webhooks· object · required The webhook capability descriptor — whether this deployment delivers events, how they are signed, and which types the wire carries. Always present, even where delivery is not armed.enabled· boolean · required Whether this deployment is delivering events.falsemeans the shape is published and nothing is being sent, so a receiver can be built and verified against the contract before delivery is switched on. It is deployment-wide, not per-account.signature· SignatureScheme · required The primary signing scheme in force — the one to verify against in the ordinary case. During a rotationschemesbelow carries every accepted scheme including this one.schemes· array of SignatureScheme · optional · min items 1 Every signing scheme currently accepted, including the primary — so a rotation window is visible on the wire and a receiver can verify against the old and the new scheme at once. Optional: present only while a scheme migration is in flight and absent the rest of the time, so null-check it and fall back tosignature. Accept a delivery whose signature verifies under any listed scheme.event_types· array of string · required · min items 1 The eventtypevalues this deployment's envelopes can carry. An open set, declared inforward_compatibility.extensible_enums: do not close it in your receiver — a type you do not recognise must be acknowledged and skipped, never rejected, or a future event type becomes days of retries and a dead letter.redelivery· object · optional The bounds POST /v1/webhooks/redeliver applies, so you can size a recovery before you start rather than discover them one 422 at a time. Present whenever this deployment serves the route (webhook_redeliveryincapabilities). Read these rather than hardcoding: they are read from the same module the endpoint enforces them from, so they cannot advertise a limit the route does not apply.enabled· boolean · required Whether POST /v1/webhooks/redeliver is served here. Independent ofwebhooks.enabled— a replay is most useful precisely when delivery has been failing.max_events_per_request· integer · required · ≥ 1 The most deliveries one accepted replay may select. A selector matching more is refused 422 rather than silently truncated.window_days· integer · required · ≥ 1 The widestoccurred_sincewindow one request may span.retention_months· integer · required · ≥ 1 How far back a delivery remains replayable at all. A selector reaching past this is refused rather than answered with a short set.cooldown_seconds· integer · required · ≥ 0 The minimum gap between two accepted replays for your account (§4.3). A request inside it is answered 429.
limits· object · required The effective bounds to size your sync loops against, read live rather than copied out of prose. Always present.list_limit_default· integer · required · ≥ 1 The page size a list or reference call uses when you send nolimit(§1.9).list_limit_max· integer · required · ≥ 1 The largestlimita list or reference call accepts; a larger value is rejected 422 (§1.9).reference_set_cap· integer · required · ≥ 1 The single-page full-refresh ceiling: a reference collection larger than this hard-fails 500 reference_set_too_large rather than serving a truncated page, and the operator is alerted. It is not a page size you can raise withlimit— the five full-refresh catalogues return one page by design (§1.10).availability_rows_max· integer · required · ≥ 1 Since 1.31.0: the row cap onGET /v1/availability, 500 by default. A page that reaches it carriesmeta.truncated(rows_omitted,rows_max) and keeps the first rows in (vehicle_id,plan) order; the withheld accounting closes oncerows_omittedis added (§4.1). Read it here rather than hardcoding it.
deprecation_notice_days· integer · optional · ≥ 1 The minimum number of days a deprecation must be announced before it may be removed — a constant, not per-deprecation data, so your tooling can assertsunset_at − deprecated_at >= deprecation_notice_daysprogrammatically instead of reading a number out of a document. Optional in the schema but always emitted.deprecations· array of object · optional Everything currently announced as deprecated. Empty or absent is the normal state and means nothing is deprecated. It mirrors the Deprecation/Sunset response headers, so a scheduled poll of this endpoint and a header-watching client see the same facts.code· string · required · min length 1 The stable identifier of the deprecated thing, in one of three frozen forms: an endpoint asMETHOD /v1/path; a field as a dottedschema.path; an enum value asschema.field=value. The same code appears here and in the header context, so the two can be correlated.deprecated_at· string · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$When the deprecation was announced, as an ISO-8601 instant with an explicit offset. It is the start of the notice windowdeprecation_notice_daysbounds.sunset_at· string or null · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)$When the deprecated thing is removed, as an ISO-8601 instant with an explicit offset. Required-and-nullable:nullmeans announced with the date not yet finalised — the notice window applies once a date is set — so branch on the value, never on the key.note· string or null · optional A human-readable explanation and, where one exists, what to move to.nullwhere none was given. For a reader, not for a parser.
currency· string · optional · pattern^[A-Z]{3}$The ISO 4217 alpha-3 currency this deployment's own operator prices in, uppercase — the deployment default, not necessarily yours: read without a key, it cannot know your supplier. Yours iscurrencyon GET /v1/whoami, per key — pin that one. Absent only when this deployment could not read its operator's configuration at boot, so check rather than assume.tax_inclusive· boolean · optionaltrue— every Money this deployment emits already has tax inside it. Display our amounts as given, and read the tax component out of a response'stax_breakdown/totalsrather than adding tax on top or deriving it: §6.2 extracts per line, so extracting once from a grand total disagrees with the invoice by cents. Getting the convention backwards is a large error on a customer-facing price, which is why it is published here where you can read it before you authenticate. It describes the pricing engine, so it is deployment-global rather than per-operator and carries nosource. Always emitted.quote_ttl_seconds· integer · optional · ≥ 1 How long a minted quote stays bookable before it lapses — the price-lock window (§4.2). Size your re-quote cadence against this number rather than hardcoding one. Always emitted.idempotency_retention_hours· integer · optional · ≥ 1 How long a completed Idempotency-Key is retained and honoured for exact replay (§1.8) — the window within which a retry is guaranteed to replay the original response rather than re-execute. Beyond it the same key executes afresh. Always emitted.forward_compatibility· object · optional The tolerate-unknown contract, machine-readable — what your client must do with what it does not recognise, so every additive change on this side stays non-breaking on yours. Always emitted.unknown_fields· string · required · min length 1 How to treat an object field you do not recognise. Alwaysignoretoday. Deliberately A plain string and not A closed enum, so a future policy value cannot break a strict validator.unknown_enum_members· string · required · min length 1 How to treat an enum value you do not recognise. Alwaystoleratetoday — these sets grow additively within /v1. A plain string rather than a closed enum, for the same reason asunknown_fields.extensible_enums· array of string · optional The concrete wire paths whose value sets are known to grow, so you can open exactly those in a strict generated client and leave the rest closed. An enum not listed here is not promised extensible. The booking status is listed (§8.1).
HealthStatus
ok· boolean · required Readiness. false — served with HTTP 503 — means this host cannot reach its database or is leaving service. It is not a per-route or per-account signal: a fault on one endpoint, or on your key, will not move it.api_version· string · required The contract version this host is running — the same value GET /v1/capabilities reports inapi_version. May present as major, major.minor or major.minor.patch: treat it as an opaque version string and do not assume a component count (§1.2).exposed· boolean · required Whether this host has its /v1 surface switched on. While false, the authenticated /v1 routes answer 404 here whatever key you present, and this host's own GET /v1/openapi.json lists only the paths it actually mounts (§1.1).
WebhookEvent
event_id· string · required · min length 1 The stable id of this event — your dedupe key. Delivery is at-least-once, so the sameevent_idcan arrive more than once and a retry carries the same value as the original attempt; record it and skip a repeat. It is an opaque string: never parse it.type· string —booking.confirmed·booking.modified·booking.cancelled·booking.no_show·booking.completed·webhook.test·supplier.added·supplier.paused· (extensible: new members may appear) · required Which event this is:booking.confirmed(created),booking.cancelled,booking.modified(the snapshot changed without a status change — see the booking'schange),booking.no_showorbooking.completed(the desk marked the booking, at a newrevision).supplier.addedandsupplier.pausedare reserved: nothing sends them today.webhooks.event_typesonGET /v1/capabilitieslists what this deployment sends. The set is open: acknowledge and skip a type you do not recognise; rejecting it earns retries, then a dead letter.occurred_at· Timestamp · required When the state change committed, as an ISO-8601 instant with an explicit offset — not when this delivery was attempted, and not when you received it. A retry days later still carries the original instant, so never use it to measure delivery latency and never order on it:revisionis the ordering key.api_version· string · required · pattern^[0-9]+(\.[0-9]+){0,2}$The contract version that produced this payload's shape, so a receiver can branch during a major-version transition. The same string GET /v1/capabilities reports asapi_version. One to three dot-separated numeric components — treat it as opaque and do not assume three.operator_id· string · required · min length 1 The opaque identifier of the operator this booking belongs to. Treat it as a string and never parse it. It matters where one callback endpoint receives events from more than one operator.booking_id· string · required · min length 1 The platform's opaque UUID for the booking, surfaced at the envelope top level so routing and dedupe never need to parsedata. Equal todata.booking_id. See the Booking schema for how it relates toexternal_refand to the operator's own settlement reference.external_ref· string · required · min length 1 Your own reference, at the envelope top level so you can route a delivery without parsing the payload. Equal todata.external_ref; unique within one supplier's book, never reused across suppliers (§1.8).revision· integer · required · ≥ 1 The ordering key. Per booking it rises by one with every write that mints a revision: a/v1write that changes something (a no-op patch mints nothing), and the desk acts that reach you — a cancellation, an operator change, a no-show or a completion. Equal todata.revision. Delivery is at-least-once and unordered, so apply an event only if itsrevisionis newer than the one you hold. A desk reinstatement mints no revision and sends no event; the list poll (§5.5) is the backstop for it.sequence· integer · optional · ≥ 1 Delivery counter, strictly consecutive per delivery subscription — one stream per callback URL, i.e. per operator whose events you receive — how you detect a delivery you never received. It is the envelope's one optional member: an event may arrive without it and that is not a gap, so skip such an event rather than counting it as a hole. A retry carries the same value as the original attempt. Full protocol, including how long to hold a hole open before escalating, is in the Webhook Receiver Guide.supplier_id· string · optional Which supplier this event belongs to — the same opaque valueGET /v1/suppliersreturns and every/v1object carries. Key your handler on it, not on which endpoint the delivery arrived at: one callback stream is one supplier today, but that is a fact about how your credential is granted, not a promise of the contract. Absent on a delivery whose supplier label has not been synced yet; nevernull.data· object · required The full booking snapshot at this event's revision — the same shape as the Booking schema and as GET /v1/bookings/{ref}, with no wrapper object inside it. Left structurally open here on purpose: an event payload is frozen at the revision it describes, so a constraint published here would be a claim about every snapshot ever stored rather than about the current shape. Model it as Booking and treat an older event's payload as data to apply, not to re-validate.
Breakdown
currency· string · required · pattern^[A-Z]{3}$ISO 4217 alpha-3 code, uppercase, of the currency every amount in this breakdown is expressed in — and therefore how many minor units make a major one, which is what makes those integers renderable.rental_days· integer · required · ≥ 1 The billed day count this breakdown was priced over (§6.2), not a date subtraction: the number of 24-hour periods between pickup and dropoff on wall-clock time in the station's timezone — so daylight saving never changes it — with an operator grace allowance before a partial period rounds up, and at least one. The grace differs per operator and is not published; it is already inside this figure. Read it rather than computing your own:min_rental_daysandmax_rental_daysare enforced against it.line_items· array of object · required · min items 1 The itemised lines behind this price — everything that makes up the total, so an itemised receipt can be rendered without guessing or deriving anything. At least one line. A line'scodeis not unique within the array: an out-of-hours window emits two lines under one code (one per end) and an age fee emits one line per qualifying driver, so treat the array as a list of charges and never as a map keyed by code.kind· string —base_rate·protection·extra·fee·discount· (extensible: new members may appear) · required What kind of charge this line is, so lines can be grouped and labelled generically without parsinglabel.base_rate— the rental itself, time and mileage.protection— the selected cover.extra— an ancillary product.fee— a conditional surcharge (§2.5).discount— a reduction, carried as a negativetotal. There is deliberately notaxkind: tax lives only intax_breakdown, and a tax line here would double-count against it.code· string · required · min length 1 The stable machine identifier for this line (TIME_MILEAGE, the protection/extra/fee/discount code). Match, branch and reconcile on this.label· string · required · min length 1 Human-readable caption for display only. It is operator configuration — its wording, language and currency formatting follow the operator's own settings and may change without an api_version bump. Never parse it, never key off it:codeis the identifier.quantity· integer · required · ≥ 1 The number of units this line was priced over — days for a per-day charge, units for a countable extra. Display it, do not multiply by it:quantity × unit_priceneed not equaltotal, because a cap can truncate the charge and tier-card pricing derivesunit_pricefor display only.totalis the number that counts.unit_price· Money · required The per-unit amount for this line, in integer minor units (§1.6). For display: on a tier-card base rate it is derived from the line total for presentation, so it is not a factor you can re-multiply. Readtotalfor what is charged.total· Money · required The authoritative amount for this line, in integer minor units (§1.6) — tax included wherever this deployment reportstax_inclusive: true. Display it; never derive it fromquantityandunit_price, which need not multiply out to it. Adiscountline carries a negative value here.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this line is due:prepaid— it falls in the prepaid part of the total and is summed intototals.pay_now;at_pickup— it is payable on arrival and is summed intototals.pay_at_pickup. No money moves through this API in either case: this states when an amount is due, not that anything has been taken.taxable· boolean · required Whether this line is subject to tax. It tells you which lines the tax computation covers, not what the tax comes to — that istax_breakdown, extracted per line by the engine. Never derive a tax amount from this flag.
tax_breakdown· array of object · required The tax inside this price, decomposed one entry per rate (§6.2). It can legitimately be empty where no line is taxable. Display it; never recompute it — the algorithm extracts per line and sums, so an extraction done once over a total disagrees by cents, and a dispute is resolved against the stored snapshot.code· string · required · min length 1 The identifier of the tax this entry is for, as the operator names it. Display and reconciliation text; the applicable rate is alwaysratebelow, never something to infer from this string.rate· number · required · ≥ 0; ≤ 1 The rate this entry was computed at, as a decimal fraction between 0 and 1 (0.24 means 24%) — deliberately not a percentage. Never assume or hardcode A rate: the applicable one is always carried here, per response, which is what makes a multi-jurisdiction integration safe.taxable_base· Money · required The base this entry's tax was computed over, in integer minor units — the sum of (line total − line tax) for the lines at this rate. It is nottotals.subtotal_ex_taxwhenever any line is non-taxable: the two are different numbers and neither derives the other.amount· Money · required The tax at this rate, in integer minor units — the sum of the per-line extractions, which is why it cannot be reproduced by applyingrateto a total. Display it.
totals· object · required The rolled-up totals for this price. Read these rather than summing the lines yourself: the engine's arithmetic is what is billed.subtotal_ex_tax· Money · required The total of the priced lines excluding tax, in integer minor units — derived by subtraction (grand_total − tax_total) and never independently rounded, which is what makes the totals sum exactly. It is not the base of every tax entry: seetax_breakdown[].taxable_base.tax_total· Money · required The tax insidegrand_total, in integer minor units — the sum of the per-line extractions (§6.2). Read it; never derive it.grand_total· Money · required What the rental costs in full, in integer minor units — tax included wherever this deployment reportstax_inclusive: true. It equalspay_now + pay_at_pickupexactly. It does not includedeposit, which is a hold rather than a charge (§6.4).pay_now· Money · required The prepaid part ofgrand_total, in integer minor units — the sum of the lines whosepayableisprepaid, tax included. Nothing is collected through this API: there is no payment instrument here, so this states which part of the total is prepaid, not that any money has moved.pay_at_pickup· Money · required The part ofgrand_totalpayable on arrival, in integer minor units — the sum of the lines whosepayableisat_pickup, tax included. Show it as an amount still to pay at collection, so a prepaid figure is never mistaken for the whole cost.
deposit· object · required The security deposit for this rental — surfaced separately from the price so a customer can be warned about the card hold before they book. It is not part ofgrand_totaland never appears inpay_now/pay_at_pickup(§6.4).amount· Money · required How much is held, in integer minor units (§1.6). It comes from the selected protection product'sdeposit, so a zero-excess or premium cover can legitimately set it to zero — and zero is a stated amount, not an absence.type· string —pre_authorization· (extensible: new members may appear) · required How the deposit is taken:pre_authorization— a hold placed on a card, not a charge. It is the only value today; the set may grow additively, so tolerate one you do not recognise.refundable· boolean · required Whether the held amount is released back to the customer. It describes the hold, not a refund of the rental price — and nothing is held or released through this API in any case (§6.4).collected_at· string —pickup· (extensible: new members may appear) · required When the hold is placed:pickup— at the handover, by the desk. The only value today; the set may grow additively, so tolerate one you do not recognise.
Money
amount· integer · required Integer minor units (§1.6) — never a decimal.currency· string · required · pattern^[A-Z]{3}$ISO 4217 alpha-3 code, uppercase, naming the currencyamountis expressed in — and therefore how many minor units make a major one, which is what makes that integer renderable. Every Money on a response carries its own currency; do not assume the deployment currency.
BookingListItem
supplier_id· string · optional The supplier this booking belongs to — the opaquesupplier_idfrom GET /v1/suppliers (§2.0), present on every row. Store it and key your own table on (supplier_id, the record's own id): ids are unique across suppliers, but your catalogue, mapping and settlement are organised per supplier. Never parse it.booking_id· string · required This platform's opaque UUID for the booking — the same value the full Booking object carries. Fetch the full object with GET /v1/bookings/{ref}; this summary is deliberately thin.external_ref· string · required Your own reference for the booking — the join key against your book, and the value the{ref}path segment takes by default.status· string —pending·confirmed·cancelled·no_show·completed· (extensible: new members may appear) · required The member this list exists for. A desk-sideno_showorcompleted(1.21.0) mints a revision and emits its own event; a desk reinstatement back toconfirmedstill changes this value without a revision or an event — so comparing this value across polls is the one method that notices every desk-side move.pendingis not produced by any /v1 create. Open list: read an unknown value as unconfirmed; re-read. Pinned below 1.64.0, you see only these five (§8.1).revision· integer · required · ≥ 1 The /v1 write counter — 1 at create, +1 per modify and per cancel. Do not trigger on it alone: a desk-side transition leaves it where it was, which is whystatusabove is what a reconciliation loop compares.updated_at· string · required When this booking last changed, ISO-8601 with an explicit offset — including a desk-side change that leftrevisionuntouched. This collection narrows onupdated_since, so this is the value your sync loop advances on: followmeta.next_pageto exhaustion, then storemeta.as_of.grand_total· Money · required What the rental costs in full, in integer minor units — the same figure the full booking reports asprice.totals.grand_total, carried here so a reconciliation page needs no second call. Tax included wherever this deployment reportstax_inclusive: true; it excludes the deposit, which is a hold rather than a charge.distribution_channel· object · optional The downstream channel recorded on the booking, as the full Booking carries it; absent when none is recorded.distribution_channel_id· string or null · required The operator's register entry the code matched, as listed by GET /v1/distribution-channels; null when the code matched no entry.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, exactly as you sent it.approval· string —approved·unregistered·proposed·suspended·deactivated·not_yet_effective·expired· (extensible: new members may appear) · required What the register said about the channel when it was recorded. Alwaysapprovedunless your agreement accepts and flags bookings through channels the operator has not approved; then it names why the channel was not approved at that moment.
WebhookEndpointRequest
url· string · required · max length 500 The HTTPS URL we will POST your events to. Must be a public endpoint: https only, no credentials embedded in the URL, and it must not resolve to a private, loopback, link-local, CGNAT or otherwise reserved address — the same check the delivery worker applies before every send, so a URL accepted here is one we can actually deliver to. A URL that fails is refused 422 and nothing is stored. Changing this takes effect immediately for events that have not yet been delivered, and it is recorded in the operator's audit trail together with the URL it replaced.
PartnerCodeRow
kind· string —vehicle·protection·plan·location·accessory· (extensible: new members may appear) · required What kind of object this is. An open enum: ignore a kind you do not recognise (§1.2).id· string · required · min length 1 The object's id as the reference reads publish it:vehicle_id,protection_id, the plan name,location_idorextra_id.name· string · required The object's published display name, to recognise it by. Display only.code· string or null · required Your own code for the object, exactly as stored, ornullwhere you have none.
PartnerCodeSaveRequest
entries· array of PartnerCodeEntryInput · required · min items 1; max items 2000 The objects to put a code on or take a code off. An object you do not list is untouched. One object may appear once.
PartnerCodeSaveResult
applied· object · required What the request did (or, for a dry run, would do).set· integer · required · ≥ 0 Objects that had no code and now have one.changed· integer · required · ≥ 0 Objects whose code was replaced by a different one.cleared· integer · required · ≥ 0 Objects whose code was taken off.unchanged· integer · required · ≥ 0 Entries that restated what you already had, or took a code off an object that had none. Nothing was written for these.released· integer · required · ≥ 0 Codes taken off objects you can no longer read, because this request gave the same code to an object you can. Not entries:set,changed,clearedandunchangedsum to the entries sent, and these come on top.
dry_run· boolean · required True when the request asked fordry_run=true: everything was checked and nothing was written.
WebhookSecretRotateRequest
reason· string · optional · max length 500 Optional free text recorded in the operator's audit trail — why you rotated. Write something they can act on ("suspected leak in CI logs", "routine quarterly rotation"). It is stored in an immutable audit record the operator reads, so it must carry no secret and no personal data: never a key, a token, a password, a customer name or an email address.
WebhookEndpointConfig
url· string or null · required The https endpoint we POST your events to, or null if none is configured yet.enabled· boolean · required Whether the operator has armed delivery for this account. Not writable by you -- arming decides which events you receive from which instant.enabled_at· string or null · optional When delivery was armed (RFC 3339). Events that occurred before this instant are never delivered, so a newly-armed account is not back-flooded with its own history.secret_set· boolean · required Whether a signing secret exists. The secret itself is never returned by any endpoint -- we store only its ciphertext.secret_overlap_open· boolean · required True while a rotation overlap is open, i.e. while we are signing every delivery with two secrets. Your receiver should be accepting either.supplier_id· string · optional The supplier this webhook configuration belongs to: the opaquesupplier_idGET /v1/suppliersreturns. Keep callback and secret state per supplier.suppliers· array of WebhookSupplierConfig · optional Present for a key that reaches several suppliers, when the request named none (1.48.0): each supplier's own configuration, the authoritative record. The members above then answer for all of them: a flag is true only when it is true for every supplier, and a value they do not all share is null.results· array of WebhookWriteResult · optional Present on the answer to a write that applied to several suppliers (1.48.0): each one's outcome, insupplier_idorder. A supplier whose status is notupdatedwas not changed. The write is idempotent, so repeat it to converge.
WebhookSupplierConfig
One supplier's webhook configuration.
supplier_id· string · required The supplier, asGET /v1/suppliersnames it.url· string or null · required This supplier's endpoint, or null if none is configured.enabled· boolean · required Whether the operator has armed this supplier's delivery.enabled_at· string or null · required When this supplier's delivery was armed, or null.secret_set· boolean · required Whether this supplier has a signing secret.secret_overlap_open· boolean · required Whether this supplier signs with two secrets while a rotation overlap is open.
WebhookWriteResult
One supplier's outcome of a webhook write that applied to several.
supplier_id· string · required The supplier, asGET /v1/suppliersnames it.status· string —updated·rotated·not_found·no_webhook_secret·unavailable· (extensible: new members may appear) · requiredupdatedorrotated: written.not_found: this supplier has no webhook registration yet.no_webhook_secret: it has no secret to rotate; the operator sets the first one.unavailable: it could not be reached, so nothing was written; repeat the request.
WebhookReplayResult
One supplier's outcome of a replay that applied to several.
supplier_id· string · required The supplier, asGET /v1/suppliersnames it.status· string —queued·webhooks_disabled· (extensible: new members may appear) · requiredqueued: its selected deliveries are counted below.webhooks_disabled: its delivery is off, so nothing was replayed for it.requeued· integer · required · ≥ 0 Its deliveries queued again.skipped_superseded· integer · required · ≥ 0 Its deliveries a later delivered revision supersedes.already_pending· integer · required · ≥ 0 Its deliveries already queued or in flight.
RotatedWebhookSecret
secret· string · required The new signing secret. Present in this one response and never again -- we hold only its ciphertext. Install it alongside your current secret before you remove that one.reveal· string · required A human-readable restatement thatsecretis one-time. Informational; do not parse it.overlap· string · required A human-readable statement of what is now signing your deliveries and what to do next. Informational; do not parse it --endpoint.secret_overlap_openis the machine-readable form.endpoint· WebhookEndpointConfig · required Your configuration after the rotation.results· array of WebhookWriteResult · optional Present on a rotation that applied to several suppliers (1.48.0): each one's outcome, insupplier_idorder. Every supplier listed asrotatednow signs with this one secret and its own previous one; any other still signs with the secret it had.
WebhookDelivery
One delivery of one event onto your endpoint, as it stands now. Every per-attempt figure is reset by a requeue or a replay — see the endpoint description.
event_id· string (uuid) · required The envelope’sevent_id— your dedupe key, and the id to quote when asking about one delivery.supplier_id· string · optional Which supplier’s stream this delivery belongs to — the same valueGET /v1/suppliersreturns.sequencebelow is counted per stream, i.e. per supplier, so this is the member that tells you which counter a gap is in. Absent — never null — on a delivery whose supplier label has not been synced yet.sequence· integer · optional · ≥ 1 Your stream number for this subscription, the one that makes a gap detectable. Absent — never null — on a delivery materialised before sequences shipped, which is the same omission the envelope makes on that row.type· string · required The event type, e.g.booking.confirmed. An open set:capabilities.webhooks.event_typesis the live list, and a type you do not recognise must be tolerated rather than rejected.booking_id· string (uuid) · required The public booking id, the same value the envelope carries. Never an internal identifier.external_ref· string · required The reference you supplied at create time, so a delivery joins your own book without an id of ours.revision· integer · required · ≥ 1 The booking revision this delivery carries — the same number the envelope carries.occurred_at· string (date-time) · required When the event occurred. This is what the list is ordered by and whatoccurred_since/occurred_untilfilter on — not when we last tried to deliver it.status· string —delivered·retrying·exhausted· (extensible: new members may appear) · required delivered — your endpoint answered 2xx. retrying — queued or in flight; we are still trying, and no action is owed. exhausted — the retry budget ran out and the delivery was dead-lettered; it will not be attempted again without a redelivery. The internal queued/in-flight distinction is deliberately collapsed: in-flight is an instant between a claim and its outcome, and nothing a partner can act on differs across it.attempts· integer · required · ≥ 0 Completed attempts since the last reset, not ever.0means we have not tried yet.last_status_code· integer or null · required The HTTP status your endpoint last answered with; null if it never answered at all (which is whatendpoint_unreachableandnot_deliverablelook like).last_failure_class· string —endpoint_error·endpoint_unreachable·not_deliverable·null· (extensible: new members may appear) or null · required What went wrong, in terms you can act on:endpoint_error— your endpoint answered non-2xx (seelast_status_code);endpoint_unreachable— it could not be reached (DNS, a refused connection, TLS or a timeout);not_deliverable— your URL is refused by the egress policy, and nothing will succeed until you re-point it.nullmeans nothing attributable to your endpoint: not attempted, succeeded, or failed on our side. An open set (webhooks.deliveries.last_failure_classinforward_compatibility.extensible_enums);endpoint_timeoutis expected to split out ofendpoint_unreachable.last_attempt_at· string (date-time) or null · required When we last tried, since the last reset. Null before the first attempt.next_attempt_at· string (date-time) or null · required When we will try next. Null when there is no next attempt — either the delivery succeeded or it isexhausted.delivered_at· string (date-time) or null · required When your endpoint accepted it. Cleared by A replay until the replayed delivery succeeds, so a null here on a row you know arrived means a replay is in flight, not that the original never landed.queued_at· string (date-time) · required When we materialised the delivery onto your stream. Unlike every other timestamp here it is not reset by a requeue, so it remains the age of the delivery itself.
Supplier
supplier_id· string · required The id everything else is keyed on (§2.0): opaque, permanent, and the same valueattribution_tokenon GET /v1/whoami reports for your own account — it survives a key rotation and resolves to nothing outside this deployment. Every object you receive carries it, so store it beside each record and key your own tables on it. Never parse it and never construct one. Two brokers hold different values for the same supplier.name· string · required The supplier’s trading name, for display — a label to show a human next to an offer, never a key. It can change, two suppliers may share one, and it is not unique. Key onsupplier_id.status· string —active·paused· (extensible: new members may appear) · requiredactive— the supplier is selling, and the only value this API emits today.pausedis reserved and never returned yet: when it ships it will mean the supplier has stopped selling, and its offers will simply disappear from GET /v1/availability, so a search keeps reflecting what is sellable and you need not pre-filter on this member. Treat an unrecognised value as "not sellable" and keep searching.currency· string · required · pattern^[A-Z]{3}$The ISO 4217 alpha-3 currency this supplier prices in — read from the same configuration every price it gives you is computed from, so it is the currency of every Money on its availability, quotes and bookings. Pin it per supplier: two suppliers can price in different currencies, andcurrencyon GET /v1/capabilities is a different fact (this deployment's own operator's, the default).tax_inclusive· boolean · required Whether every Money from this supplier already has tax inside it.true— the price you show the customer is the price with tax;false— tax is added on top. Getting it backwards is a ~24% error on the customer-facing number and in most of Europe a legal one, so read it per supplier and never assume the deployment-wide value on GET /v1/capabilities applies to every supplier you sell.payment_models· array of string —pay_on_arrival·partial·prepaid· (extensible: new members may appear) · optional What your agreement permits you to sell from this supplier today (the terms are effective-dated), in the canonical order: nothing up front → some → all. Send one aspayment_modelon a create only if there is more than one; otherwise omit it. Omitted entirely rather than empty when the terms cannot be stated truthfully: absence means "we are not saying", never "you may sell nothing"; a booking's ownpaymentblock stays authoritative for an individual sale.
DistributionChannel
supplier_id· string · required The supplier whose register holds this entry, as on GET /v1/suppliers. Approval is per supplier: one supplier may approve a code another has not.distribution_channel_id· string · required The operator’s permanent id for this entry; a booking’sdistribution_channel.distribution_channel_idnames it.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, as registered. Send exactly this value, case included, asdistribution_channel.codeon a booking.name· string · required The channel’s display name, as the operator recorded it. A label, never a key.legal_name· string or null · required The entity that operates the channel, as recorded; null when none is recorded.website· string or null · required The channel’s website, as recorded; null when none is recorded.markets· array of string · required The markets the channel is registered for, as ISO 3166-1 alpha-2 codes; empty when none are recorded.status· string —proposed·approved·suspended·deactivated· (extensible: new members may appear) · requiredapproved— you may sell through the channel while its effective window covers the moment of sale.proposed— awaiting the operator’s written approval.suspendedordeactivated— you may not sell through it until the operator approves it again in writing. Treat an unrecognised value as not approved.effective_from· string (date-time) or null · required When the most recent approval took or takes effect; null if the channel has never been approved.effective_until· string (date-time) or null · required When that approval ends; null when it is open-ended or the channel has never been approved.approved_at· string (date-time) or null · required When the operator recorded the approval in force; null unlessstatusisapproved.updated_at· string (date-time) · required When this entry last changed. For incremental sync replaymeta.as_of, never this value.
AccountOptIns
Per-account switches. All default false; each one changes what you receive, so an integration that behaves unexpectedly should check here first.
accepts_supplier_price· boolean · required Whether the booking write accepts a price supplied by you for this account.availability_duration_bounds· boolean · required When true, GET /v1/availability returns no vehicles for a window outside the operator's published rental-duration bounds and explains it in meta.unsellable_window. When false you get the pre-1.5.0 surface. Quote and the booking write enforce those bounds for every account either way — this governs the search surface only.availability_end_terms· boolean · required When true, GET /v1/availability returns no vehicles when an end of the searched rental is one the operator declined to handle (an out-of-hours handover at a desk that answered no, a delivery at a desk that does not deliver) and explains it in meta.unsellable_ends. Independent of availability_duration_bounds.availability_movement_restrictions· boolean · required When true, GET /v1/availability returns no vehicles when this operator does not serve the movement between the two locations you searched, and explains it in meta.unsellable_movement. Independent of the other two: it answers for the ordered (pickup, dropoff) pair, which neither end can express, and a pair can be refused while both of its desks are individually fine. Quote and the booking write refuse that movement for every account either way — this governs the search surface only.availability_plans· boolean · required On by default.true:GET /v1/availabilityreturns one row per vehicle group per plan (price-book package) you hold, each carryingplan, andmeta.planslists the plans served — key rows on (vehicle_id,plan).false, set by the supplier for an integration that takes one price per car: one row per group, priced from your default plan, with noplanmember.planonPOST /v1/quoteand the direct create is accepted either way.
Account
distribution_channels· object · required Whether your bookings name the downstream channel they were sold through, and what happens to one the operator has not approved. Null members mean your suppliers differ: readbooks[].policy· string —off·optional·required·null· (extensible: new members may appear) or null · requiredoff— send nodistribution_channel; one is refused.optional— send one when a booking was sold through a downstream channel.required— every booking must name one.enforcement· string —refuse·flag·null· (extensible: new members may appear) or null · requiredrefuse— a channel the operator has not approved is refused withdistribution_channel_not_approved.flag— it is accepted, and the booking records why it was not approved.
api_version· object · required The surface version your account is served at, and the version this deployment serves. Since 1.40.0. Your pinned version, not the deployment’s latest, decides what your responses mean: a change that keeps every shape and alters a meaning reaches you only when your pin moves, and it moves only when you ask the operator. Additive changes reach you regardless. Logpinnedat start-up.pinned· string or null · required · pattern^[0-9]+\.[0-9]+\.[0-9]+$The surface version this account is served at, the one it certified on. Moved only by the operator, at your request, and it can be moved back.latest· string · required · pattern^[0-9]+\.[0-9]+\.[0-9]+$The newest surface version this deployment serves, the same value GET /v1/capabilities publishes asapi_version. A fact about the deployment, not about you; being behind it is normal and costs nothing.upgrade_available· boolean · required Whetherpinnedis belowlatest. When true there is a newer version you can ask to move to; the changelog says what is in it, and everything it labels additive has already reached you.
currency· string or null · required · pattern^[A-Z]{3}$The ISO 4217 alpha-3 currency the supplier your key reaches today prices in, uppercase — a fact about your key, not a promise: readcurrencyper supplier on GET /v1/suppliers, which is where a second supplier’s would appear. Every Money you receive from that supplier is in it, and omitting a requestcurrencyprices in it. Thecurrencyon GET /v1/capabilities is the deployment default, not necessarily yours.attribution_token· string or null · required Opaque, permanent identifier for your account with this operator (this relationship, not this supplier). Stable across key rotations, restarts and deployments, so it can be pinned in configuration to prove a credential is wired to the account you intended. It is not an operator id and cannot be resolved to anything outside this deployment; two accounts with the same supplier carry different values by design, so it can never be used to correlate relationships. Treat it as an opaque string and do not parse it.scopes· array of string · required The scopes this key holds — the exact set the server tests against when authorising a request. Test membership, not order or equality; the set grows additively as this API does.opt_ins· AccountOptIns · required Per-account switches. All default false; each one changes what you receive, so an integration that behaves unexpectedly should read them first.per_broker_policies· array of string · required Policy keys where your negotiated terms are in force today instead of the operator default — exactly the keys whose object carries source: "broker_terms" on GET /v1/reference/policies and in availability's meta.policies. An empty array means you are on the operator's published rules for everything, which is the ordinary case. A future-dated term appears here on the day it takes effect, not before.webhooks· object · required Event delivery as it stands for this account. Both halves matter and they are set in different places: this object is your account’s own switch, while the deployment-wide gate is thewebhooksfeature code on GET /v1/capabilities — an event reaches you only when both are true, which is why a silent stream is diagnosed here rather than by re-reading your endpoint. It is a disclosure of state, not a control: changing it is an operator act.enabled· boolean · required Whether webhook delivery is armed for this account. The deployment-wide gate is thewebhooksfeature code on GET /v1/capabilities; an event reaches you only when both are true.first_enabled_at· string or null · required When webhooks were first enabled for this account (ISO-8601, UTC), or null if they never have been. Set once and never overwritten, so it is a fact about your integration's history rather than about the current delivery stream. It can be non-null whileenabledis false — that is an account whose webhooks were switched off after having been on.
books· array of object · required One entry per supplier this key reaches, and the authoritative answer for anything that can differ between them (1.45.0). The members above summarise your key: a boolean is true only where it is true for every supplier, andattribution_token,currencyandapi_version.pinnedare null when your suppliers disagree. Read this array, keyed onsupplier_id, and your integration is correct whether your key reaches one supplier or ten.distribution_channels· object · required This supplier’s distribution-channel policy for your bookings, whatever the summary above reports.policy· string —off·optional·required· (extensible: new members may appear) · required Whether a booking with this supplier takes, may take or must takedistribution_channel.enforcement· string —refuse·flag· (extensible: new members may appear) · required Whether this supplier refuses or flags a booking naming a channel it has not approved.
supplier_id· string · required The supplier this entry describes — the same id GET /v1/suppliers returns and every object of that supplier carries.opt_ins· AccountOptIns · required This supplier’s own switches, whatever the summary above reports.per_broker_policies· array of string · required Policy keys where your negotiated terms are in force with this supplier today.webhooks· object · required Event delivery as this supplier has it configured for you.enabled· boolean · required Whether delivery is armed for this supplier.first_enabled_at· string or null · required When delivery was first enabled for this supplier (ISO-8601, UTC), or null.
currency· string · required · pattern^[A-Z]{3}$The ISO 4217 currency this supplier prices in — every Money it sends you is in it.api_version· object · required The surface version this supplier serves you at, beside the deployment’s newest.pinned· string · required · pattern^[0-9]+\.[0-9]+\.[0-9]+$The surface version your agreement with this supplier is served at.latest· string · required · pattern^[0-9]+\.[0-9]+\.[0-9]+$The newest version this deployment serves.upgrade_available· boolean · required Whether this supplier’s pin is belowlatest.
CredentialRotateRequest
overlap_seconds· integer · optional · ≥ 300; ≤ 604800 How long the key being replaced keeps working, in seconds. Default 86400 (24h) when omitted. Minimum 300 — below that an "overlap" cannot survive a rolling restart, so it would be a hard cut-over wearing a window's name. Maximum 604800 (7 days) — a retired credential should not outlive a support cycle. Rotating because a key may be compromised? Pass a short window and revoke the old key as soon as the new one is live.
CredentialRevokeRequest
reason· string · optional · max length 500 Optional free text recorded against the retired key and in the operator's audit trail — why you revoked it. Write something an operator can act on ("suspected leak in CI logs", "routine post-rotation cleanup"). It is stored in an immutable audit record and is visible to the operator, so it must carry no secret and no personal data: never a key, a token, a password, a customer name or an email address.
ApiCredential
key_id· string · required Opaque identifier of this key — the value POST /v1/credentials/{key_id}/revoke takes. Treat it as a string and never parse it. It is not the credential and cannot authenticate anything.prefix· string · required The non-secret display handle (e.g.chk_A1b2C3d4) — the first characters of the key. Safe to put in logs, tickets and dashboards: it identifies which key without being enough to use one.scopes· array of string · required What this key may do. A rotation copies this set exactly; changing it is an operator action.status· string · required The stored lifecycle:activeorrevoked. This does not tell you whether the key works — a key retired by a rotation staysactivepast its deadline. Readauthenticablefor that.authenticable· boolean · required Whether this key authenticates right now: active and not pastexpires_at. Computed by the same clock the API refuses on, so this field and the live behaviour can never disagree.created_at· string · required When the key was issued (RFC 3339).expires_at· string or null · optional The instant this key stops authenticating, or null for a key with no deadline. Non-null only for a key that a rotation retired — a key never expires for any other reason.rotated_at· string or null · optional When this key was superseded by a successor, or null if it was not.replaced_key_id· string or null · optional Thekey_idthis key replaced, or null if it was minted rather than rotated into being — the rotation chain, for your own records.revoked_at· string or null · optional When this key was revoked, or null if it never was. Read it after an idempotent revoke:POST /v1/credentials/{key_id}/revokeanswers 200 whether this call revoked the key or it was already revoked — including by the operator, out of band. Comparing this instant against when you made the request is how you tell those apart; the status code deliberately does not, because revocation asks for an end state and that end state holds either way.is_current· boolean · required True for the key that signed this request. During a two-key rollout this is how a deployment reports which credential it is actually using.kind· string —account· (extensible: new members may appear) · optional Present when an account key made the request (1.50.0): these are your account's keys, each reaching every supplier your account holds, and a rotation or revocation applies to all of them at once. Absent for a key that reaches one supplier, whose list is that supplier's keys.
RotatedCredential
key· string · required The credential. Present in this one response and never again — it is stored only as a hash, so no route can return it a second time. Put it straight into your secret store.reveal· string · required A human-readable restatement thatkeyis one-time. Informational; do not parse it.issued· ApiCredential · required The new key's non-secret record — its prefix, scopes and id.replaces· object · required The key that was just retired, and the deadline on it.key_id· string · required The retired key's opaque id — pass it to the revoke route to end the overlap early.prefix· string · required The retired key's display handle.expires_at· string · required Until this instant both keys authenticate; after it, only the issued one does. This is your cut-over deadline.
QuoteRequest
plan· string —basic·medium·premium·no_deposit_with_excess· optional The price-book plan to price from — the protection tier the operator includes in the rate on that plan (since 1.30.0). Optional: omit it and you are priced from the plan the operator serves your account by default, exactly as before 1.30.0. Send theplancarried by the availability row you are pricing (rows carry it unlessavailability_plansis switched off for your account — GET /v1/whoami). A plan this account holds no book for at the pickup location is 422unknown_plan. The vocabulary is the protection tier set and is published x-extensible-enum, liketier.currency· string · optional · pattern^[A-Z]{3}$The ISO 4217 alpha-3 currency to price in, uppercase. Optional: omit it and the offer's supplier prices in its own currency. One that supplier does not price in is refused 422 — readcurrencyper supplier on GET /v1/suppliers and pin that.pickup· StopInput · required One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. This one is the collection.dropoff· StopInput · required One end of the rental — where, and when. Send exactly one of the two time forms: either the preferred offset-awareat, or thedate+timepair. Sending both, or neither, is 422 — the route enforces it, because this schema cannot. This one is the return.vehicle_id· string · required · min length 1 The vehicle group to price, from GET /v1/reference/vehicles — or from an availability row, whosevehicle_idis the same value. Required. You are pricing a class, not a unit.protection_id· string · required · min length 1 The protection product to price, from GET /v1/reference/protections — or from an availability row'sprotection_options, every entry of which is selectable here as-is. Required: there is no default tier. It must belong to the selectedvehicle_id, or the request is rejected 422.extras· array of object · optional · max items 20 The extras to add. Optional — omit it entirely to take none; an empty array means the same thing. Each entry is a separate product, so send one entry perextra_idrather than repeating one. At most twenty entries.extra_id· string · required · min length 1 The opaque identifier of the extra, from GET /v1/reference/extras. An id that is no longer offered is rejected 422 naming the field — that is the authoritative withdrawal signal, not the catalogue'sactiveflag.quantity· integer · required · ≥ 1; ≤ 9 How many units of this extra, 1–9. To decline an extra, omit it from the array — do not send a quantity of 0. The catalogue's ownquantity.maxcan be lower than 9, and the effective ceiling is the lower of the two; a request above it is rejected 422. A non-countable extra (requires_quantity: false) takes 1.
cross_border· boolean · optional The customer's declared intent to take the vehicle out of the country of hire; sendtrueonly when they have said so — absent is not a declaration. Where the operator configures a cross-border rule it changes the price, and it is part of a booking's identity forexternal_ref(§1.8): a retry with a different value answers409booking_conflict. It is not a one-way, which aone_wayrule prices, but a border crossed between collecting and returning at one desk, which the locations cannot show. A missing cross-border fee is not permission to cross: the API does not publish whether the operator allows it (§2.6).drivers· array of object · required · min items 1; max items 9 Everyone who will drive, at least one and at most nine. Send every driver you intend to list or the quote will be short: age fees are resolved per qualifying driver across this array, and the quote's driver set is binding — the booking that consumes it must restate the same{age, role}multiset, so a driver added afterwards means a new quote. Names are not sent here; they are supplied on the create.age· integer · required · ≥ 16; ≤ 99 The driver's age in whole years at pickup. Required, and price-bearing: age fees are applied per qualifying driver across this whole array, main and additional alike, so two drivers in one band produce two fee lines (§6.3). It is also gated — a driver outside the selected group'smin_driver_age…max_driver_agefails 422 driver_age_out_of_range.role· unspecified —main·additional· requiredmain— the driver the rental is contracted to; send exactly one.additional— a further named driver. Required. Note that an additional driver is charged as an extra while an age surcharge is charged as a fee: different mechanisms, and both can apply to the same person.country· string · optional · pattern^[A-Z]{2}$The driver's country of licence or residence as an ISO 3166-1 alpha-2 code, uppercase — a lowercase value is rejected 422. Optional.
distribution_channel· object · optional · no other members The downstream channel this quote prices for (since 1.54.0), judged as a booking naming it is: by your agreement’s policy on GET /v1/whoami, anddistribution_channel_not_approvedfor a channel the operator has not approved (or accepted and flagged where your agreement says so). Priced from that channel’s prices where the operator keeps them, else from your account’s own, and echoed asdistribution_channel. A booking made from the quote must be priced the same way (issuequote_channel_mismatch).code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your own identifier for the downstream channel, exactly as registered with the operator and listed by GET /v1/distribution-channels: letters, digits, dot, underscore and hyphen, 1 to 64 characters, matched exactly including case.
Settlement
supplier_id· string · optional The supplier this settlement statement belongs to — the opaquesupplier_idfrom GET /v1/suppliers (§2.0), present on every row. Store it and key your own table on (supplier_id, the record's own id): ids are unique across suppliers, but your catalogue, mapping and settlement are organised per supplier. Never parse it.settlement_id· string · required Opaque identifier of this settlement period — the value GET /v1/settlements/{settlement_id} takes. Treat it as a string and never parse it. A settlement that is not yours answers 404, identically to one that does not exist.period_start· string · required Inclusive start of the settled period (YYYY-MM-DD).period_end· string · required Exclusive end of the settled period (YYYY-MM-DD) — the window is half-open [start, end).status· string · required open | partial | settled | disputed today; treat an unrecognised value as unknown (this list is not closed).amount_expected· Money · required Commission the operator owes you for the period, before adjustments.amount_received· Money · required How much of it the operator has remitted so far.amount_adjusted· Money · required Net signed corrections in the settlement currency (negative = clawback).amount_outstanding· Money · required expected + adjusted − received: what is still owed to you.bookings_allocated· integer · required How many bookings contributed commission to this period.
SettlementDetail
supplier_id· string · optional The supplier this settlement statement belongs to — the opaquesupplier_idfrom GET /v1/suppliers (§2.0), present on every row. Store it and key your own table on (supplier_id, the record's own id): ids are unique across suppliers, but your catalogue, mapping and settlement are organised per supplier. Never parse it.settlement_id· string · required Opaque identifier of this settlement period — the value GET /v1/settlements/{settlement_id} takes. Treat it as a string and never parse it. A settlement that is not yours answers 404, identically to one that does not exist.period_start· string · required Inclusive start of the settled period (YYYY-MM-DD).period_end· string · required Exclusive end of the settled period (YYYY-MM-DD) — the window is half-open [start, end).status· string · required open | partial | settled | disputed today; treat an unrecognised value as unknown (this list is not closed).amount_expected· Money · required Commission the operator owes you for the period, before adjustments.amount_received· Money · required How much of it the operator has remitted so far.amount_adjusted· Money · required Net signed corrections in the settlement currency (negative = clawback).amount_outstanding· Money · required expected + adjusted − received: what is still owed to you.bookings_allocated· integer · required How many bookings contributed commission to this period.allocations_truncated· boolean · optional True when this response's allocation array hit the 1000-line page size and more lines follow — which, on a request that sends no allocations_page_token, is exactly what it has always meant: this period has more than 1000 allocation lines. Always equal to (allocations_next_page !== null). The header's bookings_allocated is the authoritative full count in every case.adjustments_truncated· boolean · optional True when this response's adjustment array hit the 1000-line page size and more lines follow. Always equal to (adjustments_next_page !== null).allocations_next_page· string or null · optional Opaque cursor continuing the allocation lines; null when this is the last page. Pass it back as allocations_page_token. Opaque means opaque: it encodes no ordering key, no id and no row count, and a token minted for another settlement or another partner is rejected 422.adjustments_next_page· string or null · optional Opaque cursor continuing the adjustment lines; null when this is the last page. Pass it back as adjustments_page_token.allocations· array of object · required One line per booking that earned commission in this period — the explanation of the header'samount_expected. The emitter caps how many lines it will serve; when a period exceeds that cap the siblingallocations_truncatedreads true and the header'sbookings_allocatedremains the authoritative full count, so the array is never silently short. That cap is emitter behaviour and deliberately not a schema bound — branch on the flag, never on the array's length. Join the lines to your own book onexternal_ref.booking_ref· string or null · optional The operator's own booking reference, drawn from the same reference space their desk uses — quote it when you raise a query with us, and never join on it. It is A third identifier: not yourexternal_refand not thebooking_idUUID, and it is not a member of the Booking schema, so a booking response gives you no way to map it.nullwhere the underlying booking has since been anonymised under data-retention rules — the money line is still shown, because omitting it would misstate the period total.external_ref· string or null · optional The reference you supplied when creating the booking — the join key for this period against your own book, and nothing else.nullonly where the booking has since been anonymised: such a line no longer joins, but its money still belongs in the period's totals.amount· Money · optional The commission this booking earned you in this period, in integer minor units (§1.6). Positive; a correction against it arrives as a signed entry inadjustmentsrather than by changing this line.allocated_at· string · optional When this booking was allocated to the period, ISO-8601 with an explicit offset. It is an accounting instant, not the booking's owncreated_at, so a booking made in one period can be allocated in another.distribution_channel· object or null · optional The downstream channel recorded on this line’s booking now, in the Booking member’s shape; null when none is recorded. A modify may have corrected it since the sale.distribution_channel_id· string or null · required The operator's register entry the code matched, as listed by GET /v1/distribution-channels; null when the code matched no entry.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, exactly as you sent it.approval· string —approved·unregistered·proposed·suspended·deactivated·not_yet_effective·expired· (extensible: new members may appear) · required What the register said about the channel when it was recorded. Alwaysapprovedunless your agreement accepts and flags bookings through channels the operator has not approved; then it names why the channel was not approved at that moment.
adjustments· array of object · required Signed corrections against this period's allocation lines — the explanation of the header'samount_adjusted. Capped by the emitter on the same terms asallocations, with the siblingadjustments_truncatedreporting when the cap bit — branch on that flag, never on the array's length. An empty array is the normal state and means nothing has been corrected.booking_ref· string or null · optional The operator's own booking reference, drawn from the same reference space their desk uses — quote it when you raise a query with us, and never join on it. It is A third identifier: not yourexternal_refand not thebooking_idUUID, and it is not a member of the Booking schema, so a booking response gives you no way to map it.nullwhere the underlying booking has since been anonymised under data-retention rules — the money line is still shown, because omitting it would misstate the period total.external_ref· string or null · optional The reference you supplied when creating the booking this correction is against — the join key, on the same terms as the allocation line's.nullwhere the booking has since been anonymised.reason· string · optional System-generated explanation where one exists (e.g. a booking cancelled after settlement). A correction entered by the operator is reported as a neutral label, not the operator's internal note.amount· Money · optional Signed. Carries its own currency: an adjustment in a different currency from the period is excluded from amount_adjusted, and this is where you can see that.adjusted_at· string · optional When this correction was recorded, ISO-8601 with an explicit offset. A correction can be recorded after the period closed — that is what it is for — so it is never bounded by the period's own dates.distribution_channel· object or null · optional The downstream channel recorded on this line’s booking now, in the Booking member’s shape; null when none is recorded. A modify may have corrected it since the sale.distribution_channel_id· string or null · required The operator's register entry the code matched, as listed by GET /v1/distribution-channels; null when the code matched no entry.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, exactly as you sent it.approval· string —approved·unregistered·proposed·suspended·deactivated·not_yet_effective·expired· (extensible: new members may appear) · required What the register said about the channel when it was recorded. Alwaysapprovedunless your agreement accepts and flags bookings through channels the operator has not approved; then it names why the channel was not approved at that moment.
PriceLineItem
kind· string —base_rate·protection·extra·fee·discount· (extensible: new members may appear) · required What kind of charge this line is, so lines can be grouped and labelled generically without parsinglabel.base_rate— the rental itself, time and mileage.protection— the selected cover.extra— an ancillary product.fee— a conditional surcharge (§2.5).discount— a reduction, carried as a negativetotal. There is deliberately notaxkind: tax lives only intax_breakdown, and a tax line here would double-count against it.code· string · required · min length 1 The stable machine identifier for this line (TIME_MILEAGE, the protection/extra/fee/discount code). Match, branch and reconcile on this.label· string · required · min length 1 Human-readable caption for display only. It is operator configuration — its wording, language and currency formatting follow the operator's own settings and may change without an api_version bump. Never parse it, never key off it:codeis the identifier.quantity· integer · required · ≥ 1 The number of units this line was priced over — days for a per-day charge, units for a countable extra. Display it, do not multiply by it:quantity × unit_priceneed not equaltotal, because a cap can truncate the charge and tier-card pricing derivesunit_pricefor display only.totalis the number that counts.unit_price· Money · required The per-unit amount for this line, in integer minor units (§1.6). For display: on a tier-card base rate it is derived from the line total for presentation, so it is not a factor you can re-multiply. Readtotalfor what is charged.total· Money · required The authoritative amount for this line, in integer minor units (§1.6) — tax included wherever this deployment reportstax_inclusive: true. Display it; never derive it fromquantityandunit_price, which need not multiply out to it. Adiscountline carries a negative value here.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this line is due:prepaid— it falls in the prepaid part of the total and is summed intototals.pay_now;at_pickup— it is payable on arrival and is summed intototals.pay_at_pickup. No money moves through this API in either case: this states when an amount is due, not that anything has been taken.taxable· boolean · required Whether this line is subject to tax. It tells you which lines the tax computation covers, not what the tax comes to — that istax_breakdown, extracted per line by the engine. Never derive a tax amount from this flag.type· string · optional Fee lines only (1.6.0). The fee type this charge is —young_driver,one_way,out_of_hours,delivery, … — so you can classify a charge without parsinglabelor hard-codingcode. Its value set is the full vocabulary atconstants.fee_types(§3.1), which is one entry wider than the eight §8.5 lists for a GET /v1/reference/fees record:deliveryis published on the location itself, so it can reach you here but never as a fee record. Absent on non-fee lines (kindalready classifies those). Deliberately a plain string, not an enum: a future fee type must not break a strict validator (§1.2).fee_id· string · optional Fee lines only (1.6.0). The fee this charge came from, joinable to GET /v1/reference/fees for itsapplies_when,pricing_type,amountandcap. Optional even on A fee line, and its absence is meaningful rather than an error: adeliveryline has none because the delivery charge is published per-location on GET /v1/reference/locations rather than as a fee record (§3.6), and a fee awaiting catalogue reconciliation has none because we will not hand you an id that resolves to nothing.typeis always there — classify ontype, join onfee_idwhen present.
TaxLine
code· string · required · min length 1 The identifier of the tax this entry is for, as the operator names it. Display and reconciliation text; the applicable rate is alwaysratebelow, never something to infer from this string.rate· number · required · ≥ 0; ≤ 1 The rate this entry was computed at, as a decimal fraction between 0 and 1 (0.24 means 24%) — deliberately not a percentage. Never assume or hardcode A rate: the applicable one is always carried here, per response, which is what makes a multi-jurisdiction integration safe.taxable_base· Money · required The base this entry's tax was computed over, in integer minor units — the sum of (line total − line tax) for the lines at this rate. It is nottotals.subtotal_ex_taxwhenever any line is non-taxable: the two are different numbers and neither derives the other.amount· Money · required The tax at this rate, in integer minor units — the sum of the per-line extractions, which is why it cannot be reproduced by applyingrateto a total. Display it.
PriceTotals
The rolled-up totals for this price. Read these rather than summing the lines yourself: the engine's arithmetic is what is billed.
subtotal_ex_tax· Money · required The total of the priced lines excluding tax, in integer minor units — derived by subtraction (grand_total − tax_total) and never independently rounded, which is what makes the totals sum exactly. It is not the base of every tax entry: seetax_breakdown[].taxable_base.tax_total· Money · required The tax insidegrand_total, in integer minor units — the sum of the per-line extractions (§6.2). Read it; never derive it.grand_total· Money · required What the rental costs in full, in integer minor units — tax included wherever this deployment reportstax_inclusive: true. It equalspay_now + pay_at_pickupexactly. It does not includedeposit, which is a hold rather than a charge (§6.4).pay_now· Money · required The prepaid part ofgrand_total, in integer minor units — the sum of the lines whosepayableisprepaid, tax included. Nothing is collected through this API: there is no payment instrument here, so this states which part of the total is prepaid, not that any money has moved.pay_at_pickup· Money · required The part ofgrand_totalpayable on arrival, in integer minor units — the sum of the lines whosepayableisat_pickup, tax included. Show it as an amount still to pay at collection, so a prepaid figure is never mistaken for the whole cost.
AvailabilityItem
supplier_id· string · optional The supplier this offer belongs to — the opaquesupplier_idfrom GET /v1/suppliers (§2.0), present on every row. Store it and key your own table on (supplier_id, the record's own id): ids are unique across suppliers, but your catalogue, mapping and settlement are organised per supplier. Never parse it.pickup_location_id· string · optional The desk this offer is collected from. Present only on a search that names a place (pickup_iataorpickup_city), which can price several desks (1.47.0); send it to POST /v1/quote aspickup.location_id.dropoff_location_id· string · optional The desk this offer is returned to. Present only on a search that names a place (1.47.0); send it to POST /v1/quote asdropoff.location_id.vehicle_id· string · required The vehicle group this row prices — the same opaque, stable id GET /v1/reference/vehicles publishes, and the value to send to POST /v1/quote as-is. You are selling a class, not a unit.display_name· string · required The customer-facing name of the group, safe to show as-is — conventionally in 'a model or similar' form, because the group is what is sold. Display only; never match on it.acriss· string · required The group's four-character industry classification code, for like-for-like comparison across suppliers. A group with no code assigned is not advertised here, which is why this member is a plain string on a row while the catalogue publishes it as nullable — do not infer from that difference that the catalogue value can be trusted to be non-null.available_units· integer · required · ≥ 1 How many units of this group are free for the whole window, at the moment of this search — a scarcity signal, never a reservation. It is a snapshot, so it can be gone by the time you quote; the only thing that holds inventory is a booking.price· Money · required Indicative grand_total only — non-firm; the firm price is POST /v1/quote.price_type· string —indicative· (extensible: new members may appear) · required What kind of pricepriceis.indicative— a browse-surface figure, not a firm one, and the only value today. The set may grow additively, so tolerate one you do not recognise rather than failing the parse.requires_quote· boolean · required Whether this row must be turned into a quote before it can be booked — alwaystrueon this endpoint today, which is the same statementprice_type: indicativemakes from the other side. It governs every number on the row, including each protection rung's window totals. Treat it as the row's own instruction: call POST /v1/quote before you commit a customer to a price.plan· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · optional The price-book plan this row is priced from — which protection tier the rate includes. The samevehicle_idappears once per plan you hold, so key rows on (vehicle_id,plan) and send this value asplanonPOST /v1/quote. Absent on every row when the supplier has switchedavailability_plansoff for your account (seeGET /v1/whoami): the row is then priced from your default plan. An open enum, liketier.protection· Protection · required The protection tier the indicative price was computed with — since 1.32.0, the one package this row's plan includes — as the §2.4 objectGET /v1/reference/protectionsserves, embedded so a results page shows excess and deposit without a join. Itsprotection_idis accepted byPOST /v1/quoteas-is. It is also the first entry ofprotection_options, which carries the same values plustotals_for_windowandtax_breakdown_for_window, equal to this row'stotalsandtax_breakdown.protection_options· array of Protection + object · required · min items 1 The protection tiers you can book for this group — the §2.4 objectsGET /v1/reference/protectionsserves, embedded so an upsell renders without a join. Always present and never empty. The first entry isprotectionabove, the tierpriceincludes; since 1.32.0 it is the only entry, because a price plan sells one package, and another package for the group is another plan's row (plan). Itsprotection_idis accepted byPOST /v1/quoteas-is. An entry'spriceis the catalogue unit price; its total for your window istotals_for_window.supplier_id· string · optional · min length 1 The supplier this protection belongs to — the opaquesupplier_idyou read from GET /v1/suppliers (§2.0), present on every row. Store it beside the record and key your own table on (supplier_id, the record's own id): every id this API issues is unique across suppliers, but your catalogue, mapping, availability cache and settlement are organised per supplier. Never parse it; it is permanent, the same on every object from that supplier.protection_id· string · required · min length 1 Opaque, stable identifier of this protection product — the value you send asprotection_idon a quote and on a direct create, where it is required: there is no default tier, so a customer's choice must be presented and sent. Treat it as a string and never construct one. It is scoped tovehicle_id: an id that does not belong to the selected group is rejected 422 at quote and at booking.vehicle_id· string or null · required The vehicle group this product is scoped to — a protection tier belongs to one group, so there is no single global ladder to map once. Read the tiers for the group you are selling.nullis reserved for an operator-wide included cover and is published as nullable for that reason; no /v1 catalogue serves such a row today, because partner catalogues are always group-scoped.code· string · required · min length 1 The operator's short machine code for this product, and the value that appears ascodeon the matchingprotectionline of a price breakdown — so it is what joins a charged line back to this record. Stable per record but not guaranteed unique across the catalogue;protection_idremains the identity.name· string · required · min length 1 Customer-facing name of the product, written to be shown as-is. Display only — never match or branch on it, and never rank a ladder by it.tier· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · required The slot this product fills in a group's cover ladder: one product per tier per group. Read the numbers, not the word.basic,mediumandpremiumare nominal: nothing makespremiumcost or cover more, nothing makesbasicfree, and each operator maps its own products onto them, so the words do not compare across operators.no_deposit_with_excessis the one name with enforced terms —depositzero andexcessabove zero — and it is a slot, not a rung abovepremium. Rank options byprice; describe them fromexcessanddeposit.partner_code· string or null · optional · min length 1; max length 128 Your own code for this protection product, as the supplier recorded it for you. Present only once the supplier has recorded codes for you, andnullwhere it has none for this product. It never changes whatprotection_ididentifies.description· string or null · required Free text describing what this product covers.nulltoday on every operator and every tier, without exception — the projection emits the literal null and consults no column, so it is not a per-row emptiness you can wait out. Do not build a coverage panel that depends on this member arriving; when it is populated it will arrive as a string, which a client reading this schema already handles.excess· Money · required The customer's maximum liability for damage under this product, in integer minor units (§1.6) — a term of the rental agreement the desk applies, not a rule this API enforces: nothing here charges, holds or releases it. Show it before the sale; it is frozen onto a booking at creation. It is the damage excess only. A separate theft figure appears astheft_excess; when that is absent the supplier has not said whether theft differs, so do not present this number as covering theft or copy it into a theft-excess field.theft_excess· Money · optional The customer's liability when the vehicle is stolen, where the supplier states it separately from the damageexcess— often a higher figure. Absent means unstated, not equal toexcess: show the damage excess and say nothing about theft, or ask the supplier, but never copyexcessinto it. The supplier states it once per vehicle group and tier, so it is the same for every distributor, unlikeexcess; a supplier whose figure differs by contract leaves it absent. It is the supplier's statement, not checked here. Same shape and currency asexcess.deposit· Money · required The security deposit the desk blocks at pickup under this product, in integer minor units (§1.6) — a term, not a rule this API enforces. It is a hold, not a charge (§6.4), and no deposit is taken, held or released through this API. Readdeposit_payment_methodson GET /v1/reference/policies for what the desk accepts to block it, and show both before checkout.price· Money · required Readprice_basisbefore you render this number. When it isminimum, this is the least this product can cost per billed day, not what a quote charges: the supplier prices it as a share of the car rate, so show it as a "from" price or show the quote's figure instead. When it is absent the price is exact and is what a quote charges. It is the catalogue unit price in minor units (§1.6) on the basispricing_typenames, never a window total: a quote, a booking create ortotals_for_windowcarries the real figure. Unlikeexcessanddepositthe price is enforced, so rank tiers by it, with care where a ladder mixes exact and minimum prices.pricing_type· string —included·per_day· (extensible: new members may appear) · required Howpriceis charged:included— already inside the rate, so nothing is added at checkout;per_day— charged once per billed rental day (§6.2 defines the day count).includedtracks a zero price and not a tier: do not read it as naming the lowest rung. There is no per-rental protection product today. A product whoseprice_basisis"minimum"is alwaysper_day, and the day count is the multiplier for its exact price — which you obtain from a quote, not fromprice(read that member's description before multiplying anything here).price_basis· string —minimum· (extensible: new members may appear) · optional How to readprice.minimum:priceis a lower bound — the least the product can cost per billed day — and not what a quote charges, so render it as a "from" price or show the quote's figure instead. Absent:priceis exact, the normal case. The supplier prices such a product as a percentage of the car rate, andprice_percentstates the terms. Any surface that carries a rental — a quote, a booking, an availability row — has the exact figure, and the quote is the authority. An open enum: ignore a value you do not recognise (§1.2).price_percent· object · optional The terms behind aminimumprice; present exactly whenprice_basisis. The charge for a stay isshare_bpsbasis points of its base-rate total, rounded half up once, then held to at leastmin_per_dayand at mostmax_per_daytimes the billed days where present. These are the default price list's terms; the rate row you price from carries the ones the quote applies at its stations. Contract §3.5 has the steps and test vectors. The quote is the authority.share_bps· integer · required · ≥ 1; ≤ 10000 The share of the rental's base-rate total, in basis points: 1500 is 15%. A whole number from 1 to 10000.min_per_day· Money · optional The least the supplier charges for each billed day of the rental, in minor units, so the charge is never belowmin_per_day× the billed days. Absent means the supplier states no floor.max_per_day· Money · optional The most the supplier charges for each billed day of the rental, in minor units, so the charge is never abovemax_per_day× the billed days. Absent means the supplier states no cap.
product_type· string —waiver·insurance· (extensible: new members may appear) · optional Whether this product is a damage waiver or insurance, which carry different disclosure duties.waiver: the operator limits the renter's liability by contract, with no insurer and no policy.insurance: an insurer takes the risk under a policy, and in most EU jurisdictions selling it is regulated insurance distribution, with information duties on you as the distributor. Branch on this, never onname. Absent means unstated, notwaiver: present the product as neither. It is the operator's statement, not a legal conclusion of this API; the binding words are in its rental terms.covers· array of string —administration_fee·animal_collision·attempted_theft·bodywork·cross_border_use·documents·driver_injury·driving_under_influence·ferry_or_sea_transport·fire·glass·immobilisation·interior·keys·lights·mirrors·natural_hazard·off_road_use·passenger_injury·personal_effects·replacement_vehicle·roadside_assistance·roof·theft·third_party_liability·towing_and_recovery·tyres_and_wheels·unauthorised_driver·undercarriage·vandalism·wrong_fuel· (extensible: new members may appear) · optional What this tier covers, as codes to branch on and to render in your customer's language: parts of the vehicle (glass,undercarriage,tyres_and_wheels) and events (theft,fire,wrong_fuel). The legal extent of the cover is in the operator's rental terms, which bind. The list grows: ignore a code you do not recognise. A code never appears in bothcoversandexcludes. The supplier states it once per tier, for every vehicle category and every distributor, and this platform does not verify it. Absent — a common state — when the supplier has not stated cover or its cover differs within the tier; never an empty array.excludes· array of string —administration_fee·animal_collision·attempted_theft·bodywork·cross_border_use·documents·driver_injury·driving_under_influence·ferry_or_sea_transport·fire·glass·immobilisation·interior·keys·lights·mirrors·natural_hazard·off_road_use·passenger_injury·personal_effects·replacement_vehicle·roadside_assistance·roof·theft·third_party_liability·towing_and_recovery·tyres_and_wheels·unauthorised_driver·undercarriage·vandalism·wrong_fuel· (extensible: new members may appear) · optional What this product does not cover — the vocabulary ofcovers, rendered the same way, and shown to the customer before booking. Empty and absent differ:[]is the operator stating that nothing is excluded (a genuine zero-excess waiver); absent means nothing is stated either way, so never tell a customer "no exclusions" on an absent member.coversis never empty. A code never appears in both lists.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this product'spriceis due (§8.3).prepaid— it falls in the prepaid part of the total.at_pickup— it is payable on arrival. It is what routes this charge into thepay_now/pay_at_pickupsplit of a breakdown'stotals(§6), and a tier whosepayablediffers from another's moves money between those two buckets without changing the grand total. No money moves through this API in either case.active· boolean · required Alwaystrue, on every record — a withdrawn product is not served at all rather than served withfalse, so a client that branches on this member branches on a constant. It is declared and required so you can read it; it is never the thing to act on. Withdrawal reaches you as an absence from this full-refresh collection, and an absence is not a deletion: upsert by id and keep the record (§1.10). The authoritative signal is the failing call — 422 validation_error naming the protection, or 409 availability_changed on a quoted create (§3).updated_at· Timestamp · required When this record was last considered changed. On this collection it is the response instant, not A per-row edit stamp: the endpoint returns the full current catalogue on every call rather than narrowing, so every row's value moves together on every read. Store it and order by it; do not build change detection on it — the siblingrow_updated_atis the real per-row edit stamp, and?changed_since=narrows on it (§1.10).row_updated_at· Timestamp · optional When this record last changed — a real per-row stamp, and the valuechanged_sincefilters on. Unlikeupdated_at, a re-read does not move it, so diff, order and store by it. To sync, send your stored watermark as?changed_since=, upsert what returns by id, and store that response'smeta.changed_as_ofas the next watermark — never your own clock. Underchanged_sincean absent row is unchanged, not removed; removals show on a full refresh (§1.10). Coarse on this catalogue: an operator's protection rows share one stamp, because each is derived from the insurance configuration and the price book, so a hit returns the whole catalogue — re-upsert the set.totals_for_window· PriceTotals · optional The §6 totals for the whole rental with this tier, from the enginePOST /v1/quoteruns, with only the cover changed; indicative, like the row.grand_totalincludes tax; readtax_totalrather than deriving it (§6.2).pay_nowandpay_at_pickupare this tier's split, not the row's.protection_options[0]is the row's own tier and equals itstotals; since 1.32.0 it is the only entry, so compare plans by comparing rows. Absent when the tier could not be priced for this window, or when vehicle groups sharing the row'sacrisscode disagree on its price — then on every row under that code. The tier is still bookable: quote it.tax_breakdown_for_window· array of TaxLine · optional The §6 tax lines behindtotals_for_window(1.9.0), per rate — the same shape the row's owntax_breakdownhas. Present and absent together withtotals_for_window. Use it when you must show a tax-exclusive net:taxable_baseis the base of the taxed lines only, which is nottotals_for_window.subtotal_ex_taxwhenever any line is non-taxable, so the two are different numbers and neither derives the other.
line_items· array of PriceLineItem · required · min items 1 The §6.1 lines behind the indicative price — base rate, the protection above, and every route/time fee this window triggers — in the shape a quote returns, plus (1.6.0)typeandfee_idon fee lines so a charge can be classified and joined to GET /v1/reference/fees. Their grand total isprice. Still indicative: extras appear once you quote, and age-dependent fees unless you sentdriver_age.tax_breakdown· array of TaxLine · required Tax applied to this price, one entry per rate (§6.2) —code,rate, thetaxable_baseit was applied to, and theamount. Display this rather than deriving it:line_items[].taxabletells you which lines are taxable, not what the tax came to.totals· PriceTotals · required The §6 totals for this price:subtotal_ex_tax,tax_total,grand_total, and thepay_now/pay_at_pickupsplit. That split records which priced lines the operator has marked prepaid (pay_now) and which pay on arrival (pay_at_pickup) in its own product, the same for every partner. It is not a payment schedule for your booking and names no collecting party: under a pay-on-arrival agreement nothing falls due before pickup, however largepay_nowis. When amounts fall due, and who collects them, are terms of your agreement; no customer money is collected through this API. A cancellation charge has its own basis and timing.grand_totalequalsprice;pricepredates this object and is kept because partners already read it.min_driver_age· integer · optional · ≥ 16; ≤ 99 Youngest driver this group may be rented to. Emitted on every row — it is not listed inrequiredbecause it was published optional from birth and we do not change a published guarantee, not because a row can omit it. You will not meet a row without it. Identical to GET /v1/reference/vehicles for the samevehicle_id; enforced by the pricing engine, so a group your driver cannot rent is not in this response at all.max_driver_age· integer · optional · ≥ 16; ≤ 99 Oldest driver this group may be rented to. Emitted on every row, on the same footing asmin_driver_ageabove, and enforced the same way.seats· integer · optional · ≥ 1 Seats. Optional and omitted when the operator has not verified it — never defaulted; absent means "not stated", not "5".doors· integer · optional · ≥ 1 Doors. Optional and omitted when not verified (seeseats).large_bags· integer · optional · ≥ 0 Large bags (suitcases) the group holds. Optional and omitted when not verified.small_bags· integer · optional · ≥ 0 Small bags (cabin) the group holds. Optional and omitted when not verified.transmission· string —manual·automatic· (extensible: new members may appear) · optional Transmission. Optional and omitted when not verified. Noteacrissalso encodes this; they come from different stores and this one is the operator-verified fact.fuel· string —petrol·diesel·hybrid·electric·plugin_hybrid· (extensible: new members may appear) · optional Fuel type. Optional and omitted when not verified.air_conditioning· boolean · optional Whether the group has air conditioning. Optional and omitted when not verified.gps_included· boolean · optional Whether a navigation device is included in the rate for this group, at no extra charge. Optional and omitted when the operator has not stated it — absent means "not stated", never "no".falseis a stated fact and is emitted asfalse. Says nothing about buying navigation as an add-on; that is a priced entry on GET /v1/reference/extras.body_style· string —hatchback·sedan·estate·coupe·convertible·roadster·suv·crossover·mpv·van·minibus·pickup·motorhome·other· (extensible: new members may appear) · optional The group's body style as a stable code; absent when not stated. OnGET /v1/reference/vehiclesthe same fact can also appear asacriss_decoded.type. Both are the supplier's statements, held in different stores, and their agreement is not guaranteed: the supplier's screens refuse a contradictory pair, but a supplier already connected can still serve one until it is corrected. Most groups have no ACRISS code, which is why this member exists.electric_range_km· integer · optional · ≥ 1; ≤ 2000 Battery range in kilometres, as stated by the operator. Optional; emitted only for a group whosefueliselectricorplugin_hybrid. Always read it withelectric_range_basis— they are present and absent together, and a range without its test cycle is not comparable between suppliers.electric_range_basis· string —wltp·epa·manufacturer· (extensible: new members may appear) · optional Which test cycleelectric_range_kmwas measured on. Present and absent together with it. The same car yields materially different numbers on each cycle, so do not show the range without this qualifier.example_models· array of string · optional Representative models for the group ("or similar"). Optional and omitted when not verified. Never a booking guarantee — you are renting the group, not a listed model.images· array of object · optional Images of the group, as the operator supplied them. Optional and omitted when not verified. The URLs are the operator's own and the image host is not part of this contract: fetch eachurlexactly as given, never construct, rewrite or sign one, and do not assume any host, path shape or lifetime.roleis an optional free-text hint (e.g. "hero"); treat an unknown role as untagged.url· string · required · min length 1 Absolute URL of the image, served exactly as the operator stored it. Take it as given: never construct, rewrite or sign one, and never assume the host (see the array's own note).role· string · optional Optional free-text hint about what the image is for (a hero shot, an interior, an angle). An open vocabulary, not an enum: treat a role you do not recognise as untagged rather than dropping the image. Omitted where the operator gave none.
QuoteResponseData
currency· string · required · pattern^[A-Z]{3}$ISO 4217 alpha-3 code, uppercase, of the currency every amount in this breakdown is expressed in — and therefore how many minor units make a major one, which is what makes those integers renderable.rental_days· integer · required · ≥ 1 The billed day count this breakdown was priced over (§6.2), not a date subtraction: the number of 24-hour periods between pickup and dropoff on wall-clock time in the station's timezone — so daylight saving never changes it — with an operator grace allowance before a partial period rounds up, and at least one. The grace differs per operator and is not published; it is already inside this figure. Read it rather than computing your own:min_rental_daysandmax_rental_daysare enforced against it.line_items· array of object · required · min items 1 The itemised lines behind this price — everything that makes up the total, so an itemised receipt can be rendered without guessing or deriving anything. At least one line. A line'scodeis not unique within the array: an out-of-hours window emits two lines under one code (one per end) and an age fee emits one line per qualifying driver, so treat the array as a list of charges and never as a map keyed by code.kind· string —base_rate·protection·extra·fee·discount· (extensible: new members may appear) · required What kind of charge this line is, so lines can be grouped and labelled generically without parsinglabel.base_rate— the rental itself, time and mileage.protection— the selected cover.extra— an ancillary product.fee— a conditional surcharge (§2.5).discount— a reduction, carried as a negativetotal. There is deliberately notaxkind: tax lives only intax_breakdown, and a tax line here would double-count against it.code· string · required · min length 1 The stable machine identifier for this line (TIME_MILEAGE, the protection/extra/fee/discount code). Match, branch and reconcile on this.label· string · required · min length 1 Human-readable caption for display only. It is operator configuration — its wording, language and currency formatting follow the operator's own settings and may change without an api_version bump. Never parse it, never key off it:codeis the identifier.quantity· integer · required · ≥ 1 The number of units this line was priced over — days for a per-day charge, units for a countable extra. Display it, do not multiply by it:quantity × unit_priceneed not equaltotal, because a cap can truncate the charge and tier-card pricing derivesunit_pricefor display only.totalis the number that counts.unit_price· Money · required The per-unit amount for this line, in integer minor units (§1.6). For display: on a tier-card base rate it is derived from the line total for presentation, so it is not a factor you can re-multiply. Readtotalfor what is charged.total· Money · required The authoritative amount for this line, in integer minor units (§1.6) — tax included wherever this deployment reportstax_inclusive: true. Display it; never derive it fromquantityandunit_price, which need not multiply out to it. Adiscountline carries a negative value here.payable· string —prepaid·at_pickup· (extensible: new members may appear) · required When this line is due:prepaid— it falls in the prepaid part of the total and is summed intototals.pay_now;at_pickup— it is payable on arrival and is summed intototals.pay_at_pickup. No money moves through this API in either case: this states when an amount is due, not that anything has been taken.taxable· boolean · required Whether this line is subject to tax. It tells you which lines the tax computation covers, not what the tax comes to — that istax_breakdown, extracted per line by the engine. Never derive a tax amount from this flag.
tax_breakdown· array of object · required The tax inside this price, decomposed one entry per rate (§6.2). It can legitimately be empty where no line is taxable. Display it; never recompute it — the algorithm extracts per line and sums, so an extraction done once over a total disagrees by cents, and a dispute is resolved against the stored snapshot.code· string · required · min length 1 The identifier of the tax this entry is for, as the operator names it. Display and reconciliation text; the applicable rate is alwaysratebelow, never something to infer from this string.rate· number · required · ≥ 0; ≤ 1 The rate this entry was computed at, as a decimal fraction between 0 and 1 (0.24 means 24%) — deliberately not a percentage. Never assume or hardcode A rate: the applicable one is always carried here, per response, which is what makes a multi-jurisdiction integration safe.taxable_base· Money · required The base this entry's tax was computed over, in integer minor units — the sum of (line total − line tax) for the lines at this rate. It is nottotals.subtotal_ex_taxwhenever any line is non-taxable: the two are different numbers and neither derives the other.amount· Money · required The tax at this rate, in integer minor units — the sum of the per-line extractions, which is why it cannot be reproduced by applyingrateto a total. Display it.
totals· object · required The rolled-up totals for this price. Read these rather than summing the lines yourself: the engine's arithmetic is what is billed.subtotal_ex_tax· Money · required The total of the priced lines excluding tax, in integer minor units — derived by subtraction (grand_total − tax_total) and never independently rounded, which is what makes the totals sum exactly. It is not the base of every tax entry: seetax_breakdown[].taxable_base.tax_total· Money · required The tax insidegrand_total, in integer minor units — the sum of the per-line extractions (§6.2). Read it; never derive it.grand_total· Money · required What the rental costs in full, in integer minor units — tax included wherever this deployment reportstax_inclusive: true. It equalspay_now + pay_at_pickupexactly. It does not includedeposit, which is a hold rather than a charge (§6.4).pay_now· Money · required The prepaid part ofgrand_total, in integer minor units — the sum of the lines whosepayableisprepaid, tax included. Nothing is collected through this API: there is no payment instrument here, so this states which part of the total is prepaid, not that any money has moved.pay_at_pickup· Money · required The part ofgrand_totalpayable on arrival, in integer minor units — the sum of the lines whosepayableisat_pickup, tax included. Show it as an amount still to pay at collection, so a prepaid figure is never mistaken for the whole cost.
deposit· object · required The security deposit for this rental — surfaced separately from the price so a customer can be warned about the card hold before they book. It is not part ofgrand_totaland never appears inpay_now/pay_at_pickup(§6.4).amount· Money · required How much is held, in integer minor units (§1.6). It comes from the selected protection product'sdeposit, so a zero-excess or premium cover can legitimately set it to zero — and zero is a stated amount, not an absence.type· string —pre_authorization· (extensible: new members may appear) · required How the deposit is taken:pre_authorization— a hold placed on a card, not a charge. It is the only value today; the set may grow additively, so tolerate one you do not recognise.refundable· boolean · required Whether the held amount is released back to the customer. It describes the hold, not a refund of the rental price — and nothing is held or released through this API in any case (§6.4).collected_at· string —pickup· (extensible: new members may appear) · required When the hold is placed:pickup— at the handover, by the desk. The only value today; the set may grow additively, so tolerate one you do not recognise.
supplier_id· string · optional The supplier this quote belongs to — the opaquesupplier_idfrom GET /v1/suppliers (§2.0), present on every row. Store it and key your own table on (supplier_id, the record's own id): ids are unique across suppliers, but your catalogue, mapping and settlement are organised per supplier. Never parse it.quote_id· string · required The opaque UUID of this quote — the value you send back on the quoted create, where it is pattern-checked, so a re-shaped or truncated id fails 422 rather than 404. It is single-use: once a booking has consumed it, booking it again is 409 quote_consumed. Never parse it and never construct one.plan· string —basic·medium·premium·no_deposit_with_excess· (extensible: new members may appear) · optional Present only when the request named aplan(since 1.30.0): the price-book plan this quote was priced from, echoed so the figure and the plan travel together. Absent when the quote was priced from the plan served by default — byte-identical to 1.29.0.distribution_channel· object · optional Present only when the request named adistribution_channel(since 1.54.0): the channel this quote was priced for, as recorded when it was minted — a replay echoes the same. A booking made from this quote must name the same channel, or it is refused 422validation_errorwith issuequote_channel_mismatch.distribution_channel_id· string or null · required The operator's register entry the code matched, as listed by GET /v1/distribution-channels; null when the code matched no entry.code· string · required · pattern^[A-Za-z0-9._-]{1,64}$Your identifier for the channel, exactly as you sent it.approval· string —approved·unregistered·proposed·suspended·deactivated·not_yet_effective·expired· (extensible: new members may appear) · required What the register said about the channel when it was recorded. Alwaysapprovedunless your agreement accepts and flags bookings through channels the operator has not approved; then it names why the channel was not approved at that moment.
expires_at· string · required ISO-8601 instant after which this quote can no longer be booked. Booking against a lapsed quote_id fails distinctly — 409 quote_expired from POST /v1/bookings — so an expiry is always separable from any other create failure. Re-quote and re-confirm the price with the customer.status· string · required At least "active" | "expired" (server-derived — a lapsed row reports expired even before any write touches it); other values may be added additively.
Timestamp
No members.
NullableMoney
A money amount that may be absent as a stated value: either a {amount, currency} object or the literal null. null means 'no such amount is set' — never zero, and never free. Branch on the value rather than on the key. Whether the member carrying this shape is itself always present is a fact about that member, stated at that member.
amount· integer · required The amount in integer minor units ofcurrency— 1234 in a two-decimal currency is 12.34. Never a decimal, never a formatted string (§1.6).currency· string · required · pattern^[A-Z]{3}$ISO 4217 alpha-3 code, uppercase, naming the currencyamountis expressed in — and therefore the number of minor units in a major one, which is what makesamountrenderable.
MovementPair
One directed movement: collect at pickup_location_id, return at dropoff_location_id. The order is the order the rental is performed and is never symmetric — the reverse movement is a different pair and may have the opposite answer. The two ids are always different: a same-location rental is not a movement and never appears in either list.
pickup_location_id· string · required · min length 1 Where the rental is collected, as alocation_idfrom GET /v1/reference/locations.dropoff_location_id· string · required · min length 1 Where the rental is returned, as alocation_idfrom GET /v1/reference/locations.
TermsClause
One clause of the contract. May nest.
number· string · required · min length 1 The supplier's own clause number as it must appear on the contract — frozen text, not a render-time counter. A clause cited as "7.2" in a dispute is still 7.2 when this tree is rendered years later after an edit added a clause above it, so render this string rather than numbering the list yourself.heading· string · optional Optional clause heading. Absent where the supplier wrote none — do not synthesise one.paragraphs· array of array of TermsTextRun · required The clause's paragraphs, each a list of text runs. An empty list is a clause that heads its sub-clauses and carries no text of its own.clauses· array of TermsClause · optional Sub-clauses, in order. Absent where there are none.
TermsTextRun
A run of plain text with an optional emphasis mark. Never markup: text is literal characters and must be escaped by you before it reaches a template, exactly as this platform escapes it before it reaches a PDF.
text· string · required The literal text. It is never HTML, never a template, and never a URL to fetch.mark· string —strong·em· (extensible: new members may appear) · optional Optional emphasis.strongandemare the only two marks this format has, and an unknown one is refused at the writer rather than passed through — a format that tolerates unknown marks is one that will eventually be handed 'html'. Render them as emphasis, never as instructions.
EnumList
No members.
BookingStop
location_id· string · required · min length 1 The station, as alocation_idfrom GET /v1/reference/locations — the only form this API accepts. Opaque: never parse it and never construct one.name· string · required · min length 1 The station's display name as it stood when the booking was made, safe to print on a voucher without a second lookup. Display only — never match on it;location_idis the identity.at· string · required · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$The handover instant, ISO-8601 with an explicit offset (or Z) — never a date alone and never an offsetless local time. This is the instant the out-of-hours rules and the billed day count were judged against, and 'local' for those rules is resolved in the station's own publishedtimezone, not in the offset carried here (§2.1).
StopInput
location_id· string · required · min length 1 The station, as alocation_idfrom GET /v1/reference/locations — the only form this API accepts for a pickup or dropoff. Opaque: never construct one, and never send a name or a code. An id that has left the operator's current selling estate is refused exactly like one that never existed.at· string · optional · pattern^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$The handover instant, ISO-8601 with an explicit offset (or Z) — the preferred form, because it is unambiguous. The offset you send does not decide the local rules: out-of-hours and the billed day count resolve 'local' in the station's own publishedtimezone(§2.1), so an instant carrying a different offset is still judged at the desk's clock. Mutually exclusive withdate+time.date· string · optional · pattern^\d{4}-\d{2}-\d{2}$The handover date as YYYY-MM-DD, the older form, interpreted in the station's timezone. Must be sent together withtime, and never together withat. Preferat: this pair cannot express an instant unambiguously across a daylight-saving boundary.time· string · optional · pattern^\d{2}:\d{2}$The handover time of day as local HH:MM in the station's timezone, 24-hour clock. Must be sent together withdate, and never together withat.
MoneyInput
amount· integer · required · ≥ 0 The amount in integer minor units ofcurrency— 1234 in a two-decimal currency is 12.34. Never a decimal and never a formatted string (§1.6). Must not be negative.currency· string · required · pattern^[A-Z]{3}$ISO 4217 alpha-3 code, uppercase, naming the currencyamountis expressed in — and therefore how many minor units make a major one.
SignatureScheme
tag· string · required · pattern^v[0-9]+$The scheme marker that appears as a parameter name in the signature header —vfollowed by a number. Constrained to that grammar so you can map a header parameter to the scheme that produced it. Unique withinschemes.header· string · required · min length 1 The name of the HTTP header the signature is delivered in, so a generated receiver can wire it without a literal from prose.algorithm· string · required · min length 1 The signing algorithm, as a plain string — currently an HMAC over SHA-256. Not a closed enum, so a future scheme can announce a new value without breaking a strict validator.signed_payload· string · required · min length 1 A human-readable template of what is signed — the concatenation of the timestamp, the request method, the request target and the exact delivered bytes. It is A hint, not A grammar to machine-parse: the authoritative byte construction for each scheme is defined pertagin the Webhook Receiver Guide, because a component list cannot convey separators or formats, and an unknown future scheme could not be auto-implemented from this string in any case. Implement the announced scheme version, and use this to recognise which one you are looking at.
PartnerCodeEntryInput
kind· unspecified —vehicle·protection·plan·location·accessory· required What kind of objectidnames.id· string · required · min length 1; max length 128 The object's id as the reference reads publish it: thevehicle_idof a vehicle group, theprotection_idof a protection, the plan name, thelocation_idof a station, theextra_idof an accessory.code· string or null · required · max length 512 Your own code for the object, stored exactly as sent (1 to 128 characters, no control character, no leading or trailing space).nulltakes your code off the object.
Enumerations
A closed enumeration never gains a value within /v1. An extensible one may — treat a
member you do not recognise as "other", never as an error (contract §1.2).
| Object · member | Values | Closed or extensible |
|---|---|---|
Location.type |
airport · port · office · city · hotel · address · other |
extensible — a new value may appear; treat it as "other" |
Vehicle.transmission |
manual · automatic |
extensible — a new value may appear; treat it as "other" |
Vehicle.fuel |
petrol · diesel · hybrid · electric · plugin_hybrid |
extensible — a new value may appear; treat it as "other" |
Vehicle.body_style |
hatchback · sedan · estate · coupe · convertible · roadster · suv · crossover · mpv · van · minibus · pickup · motorhome · other |
extensible — a new value may appear; treat it as "other" |
Vehicle.electric_range_basis |
wltp · epa · manufacturer |
extensible — a new value may appear; treat it as "other" |
Extra.category |
child_seat · additional_driver · navigation · connectivity · winter · protection_upsell · other |
extensible — a new value may appear; treat it as "other" |
Extra.pricing_type |
per_day · per_rental |
extensible — a new value may appear; treat it as "other" |
Extra.payable |
prepaid · at_pickup |
extensible — a new value may appear; treat it as "other" |
Protection.tier |
basic · medium · premium · no_deposit_with_excess |
extensible — a new value may appear; treat it as "other" |
Protection.pricing_type |
included · per_day |
extensible — a new value may appear; treat it as "other" |
Protection.price_basis |
minimum |
extensible — a new value may appear; treat it as "other" |
Protection.product_type |
waiver · insurance |
extensible — a new value may appear; treat it as "other" |
Protection.covers |
administration_fee · animal_collision · attempted_theft · bodywork · cross_border_use · documents · driver_injury · driving_under_influence · ferry_or_sea_transport · fire · glass · immobilisation · interior · keys · lights · mirrors · natural_hazard · off_road_use · passenger_injury · personal_effects · replacement_vehicle · roadside_assistance · roof · theft · third_party_liability · towing_and_recovery · tyres_and_wheels · unauthorised_driver · undercarriage · vandalism · wrong_fuel |
extensible — a new value may appear; treat it as "other" |
Protection.excludes |
administration_fee · animal_collision · attempted_theft · bodywork · cross_border_use · documents · driver_injury · driving_under_influence · ferry_or_sea_transport · fire · glass · immobilisation · interior · keys · lights · mirrors · natural_hazard · off_road_use · passenger_injury · personal_effects · replacement_vehicle · roadside_assistance · roof · theft · third_party_liability · towing_and_recovery · tyres_and_wheels · unauthorised_driver · undercarriage · vandalism · wrong_fuel |
extensible — a new value may appear; treat it as "other" |
Protection.payable |
prepaid · at_pickup |
extensible — a new value may appear; treat it as "other" |
Fee.type |
young_driver · senior_driver · one_way · out_of_hours · premium_location · additional_location · cross_border · winter_equipment |
extensible — a new value may appear; treat it as "other" |
Fee.pricing_type |
per_day · per_rental |
extensible — a new value may appear; treat it as "other" |
Fee.payable |
prepaid · at_pickup |
extensible — a new value may appear; treat it as "other" |
Rate.plan |
basic · medium · premium · no_deposit_with_excess |
extensible — a new value may appear; treat it as "other" |
StopSale.source |
manual · sold_out |
extensible — a new value may appear; treat it as "other" |
Policy.deposit_payment_methods |
credit_card · debit_card · cash |
extensible — a new value may appear; treat it as "other" |
Policy.restrictions |
commercial_passenger_transport · cross_border_use · driving_instruction · driving_under_influence · ferry_or_sea_transport · motorsport_or_track_use · off_road_use · overloading · pushing_or_towing · smoking · subletting · transport_of_animals · transport_of_hazardous_goods · unauthorised_driver · unpaved_roads |
extensible — a new value may appear; treat it as "other" |
TermsDocument.format |
clauses/v1 |
extensible — a new value may appear; treat it as "other" |
TermsDocumentDetail.format |
clauses/v1 |
extensible — a new value may appear; treat it as "other" |
Booking.plan |
basic · medium · premium · no_deposit_with_excess |
extensible — a new value may appear; treat it as "other" |
Booking.status |
pending · confirmed · cancelled · no_show · completed |
extensible — a new value may appear; treat it as "other" |
CreateBookingRequest.plan |
basic · medium · premium · no_deposit_with_excess |
closed |
CreateBookingRequest.mode |
direct |
closed |
CreateBookingRequest.payment_model |
pay_on_arrival · prepaid · partial |
closed |
CreateQuotedBookingRequest.mode |
quoted |
closed |
CreateQuotedBookingRequest.payment_model |
pay_on_arrival · prepaid · partial |
closed |
Capabilities.environment |
production · sandbox |
extensible — a new value may appear; treat it as "other" |
WebhookEvent.type |
booking.confirmed · booking.modified · booking.cancelled · booking.no_show · booking.completed · webhook.test · supplier.added · supplier.paused |
extensible — a new value may appear; treat it as "other" |
BookingListItem.status |
pending · confirmed · cancelled · no_show · completed |
extensible — a new value may appear; treat it as "other" |
PartnerCodeRow.kind |
vehicle · protection · plan · location · accessory |
extensible — a new value may appear; treat it as "other" |
WebhookWriteResult.status |
updated · rotated · not_found · no_webhook_secret · unavailable |
extensible — a new value may appear; treat it as "other" |
WebhookReplayResult.status |
queued · webhooks_disabled |
extensible — a new value may appear; treat it as "other" |
WebhookDelivery.status |
delivered · retrying · exhausted |
extensible — a new value may appear; treat it as "other" |
WebhookDelivery.last_failure_class |
endpoint_error · endpoint_unreachable · not_deliverable · null |
extensible — a new value may appear; treat it as "other" |
Supplier.status |
active · paused |
extensible — a new value may appear; treat it as "other" |
Supplier.payment_models |
pay_on_arrival · partial · prepaid |
extensible — a new value may appear; treat it as "other" |
DistributionChannel.status |
proposed · approved · suspended · deactivated |
extensible — a new value may appear; treat it as "other" |
ApiCredential.kind |
account |
extensible — a new value may appear; treat it as "other" |
QuoteRequest.plan |
basic · medium · premium · no_deposit_with_excess |
closed |
PriceLineItem.kind |
base_rate · protection · extra · fee · discount |
extensible — a new value may appear; treat it as "other" |
PriceLineItem.payable |
prepaid · at_pickup |
extensible — a new value may appear; treat it as "other" |
AvailabilityItem.price_type |
indicative |
extensible — a new value may appear; treat it as "other" |
AvailabilityItem.plan |
basic · medium · premium · no_deposit_with_excess |
extensible — a new value may appear; treat it as "other" |
AvailabilityItem.transmission |
manual · automatic |
extensible — a new value may appear; treat it as "other" |
AvailabilityItem.fuel |
petrol · diesel · hybrid · electric · plugin_hybrid |
extensible — a new value may appear; treat it as "other" |
AvailabilityItem.body_style |
hatchback · sedan · estate · coupe · convertible · roadster · suv · crossover · mpv · van · minibus · pickup · motorhome · other |
extensible — a new value may appear; treat it as "other" |
AvailabilityItem.electric_range_basis |
wltp · epa · manufacturer |
extensible — a new value may appear; treat it as "other" |
QuoteResponseData.plan |
basic · medium · premium · no_deposit_with_excess |
extensible — a new value may appear; treat it as "other" |
TermsTextRun.mark |
strong · em |
extensible — a new value may appear; treat it as "other" |
PartnerCodeEntryInput.kind |
vehicle · protection · plan · location · accessory |
closed |
Certification
Status: Procedural. This document describes how certification is run and what a pass means; it
is not normative for the API surface — CHANNEL-API.md is.
Audience: Channel partners preparing a /v1 integration for go-live.
Certification is the last step before you go live. It is not a code review: it is a recorded run in which your client meets the API in shapes it has not seen before, and we observe what it actually does. It exists so that a later additive release — a new optional field, a new member of an open enum, a re-minted identifier — cannot break you.
Passing means those changes were served to your real client, it kept working, and there is recorded evidence of it. A claim is not enough; the verdict comes from observed traffic.
What you point at, and what you need first
For the run you point your client at your sandbox base URL — unchanged — and authenticate with your own sandbox key, exactly as you do today: the run is hosted on your own sandbox account, whether your key came from the portal or from the operator. (A staffed session, on request, is served from a separate address we send you for that sitting.) We never hold your key — anywhere it could surface in the run record it appears only as a short masked prefix. Everything you send is passed through unchanged, and outside the one probe active in each phase what comes back is byte-for-byte what a direct call returns: this is the same API, not a mock. Runs go against the sandbox only — a production target is refused outright with no override, so nothing you do during a run can reach live inventory or a live booking.
Before you start, you need:
- A working sandbox integration that completes the whole flow: capabilities → reference sync → availability → quote → book → cancel.
- Your sandbox key on every request your client sends while the run is active,
GET /v1/capabilitiesincluded — the paragraph after this list says why. - A base URL that is configuration, not a constant. A hosted run needs no change to it; a staffed session (on request) is served from a separate address you point at for that sitting and back again.
- The ability to trigger a reference sync on demand, and to quote a specific vehicle from your own synced catalogue rather than only what your UI happens to surface.
- Someone available to run the steps in order and confirm each one. A run is one session at your pace inside a 24-hour window; a restarted run starts over from the beginning.
Why the key goes on capabilities too. Outside a run, GET /v1/capabilities answers with or
without a key, and the contract lists it among the reads that need none
(CHANNEL-API.md §1.3). A hosted run, though, is attached to your key: a request
that carries it is served through the run, and one that carries none is answered by the plain API,
unaltered, with nothing recorded for the run. Two phases, unknown-feature-code and
version-format, change the capabilities response and nothing else, so a client that reads it
without the key never receives either change. Both then score inconclusive, and because
unknown-feature-code gates the result, the run cannot pass. When that happens the report names the
request that never arrived with your key. Sending the key to capabilities outside a run changes
nothing, so the simplest client sends it on every call.
What happens during the run
Ten phases, in order. Each asks you to run a short flow and then move to the next one yourself
(POST /_certification/advance). The first is a
plain baseline — your normal flow, nothing altered — which also tells us which of your catalogue
items the later phases will use. After that, each phase changes exactly one thing in what you
receive.
The identifier in the first column is that phase's name in your run report, on the evidence lines
it produced. (baseline is the exception and produces none: evidence is attributed per SCENARIO,
and no scenario owns the baseline phase — so nothing from it reaches the report's evidence.) A verdict therefore always points back at the row that explains it, and at the
rehearsal for it below.
| Phase | What you will see | What your client has to do |
|---|---|---|
baseline |
Nothing altered | Complete your normal full flow, end to end |
unknown-enum-value |
One location carries a type value you have never seen |
Keep the record, finish the sync, quote as usual |
unknown-error-code |
The first read of the locations list fails once with 422 and an unrecognised error.code. The injection is one-shot, so the next read of that list succeeds — you are not being asked to retry a 422 |
Degrade generically on a code you do not know, then carry on |
unknown-response-field |
Unknown fields appear across capabilities, reference records, quotes and bookings, including nested inside totals | Ignore them — and never copy one back into a request, in the body, the query string or a header |
unknown-feature-code |
Capabilities lists a feature code you do not recognise | Ignore it |
opaque-id-tolerance |
One vehicle id and the protection ids tied to it come back as different opaque tokens; a second vehicle keeps its id but changes display name | Re-sync, then quote both from your freshly synced catalogue, on your served plan: a quote naming no plan (or the served one), with a protection_id your fresh GET /v1/reference/protections lists. That read is your served plan's packages, and the phase re-mints the ids it lists, so a quote on another plan's row echoes none of them and leaves the scenario inconclusive. Send ids exactly as served — never parsed, derived, or swapped back for a cached one. A changed display name is the same record, not a new one |
shrunk-reference-refresh:1 |
A full reference refresh comes back with one vehicle, and its protections, missing — and availability stops listing that vehicle while the phase is active | Keep operating, and keep that vehicle quotable from your existing records. A smaller refresh is a merge, never a wholesale replacement — one refresh omitting an item is not an instruction to delete it |
shrunk-reference-refresh:2 |
The vehicle is back in the next refresh | Quote it under its original id; it must be the same record you already had |
deprecation-notice |
Capabilities carries a deprecation entry and one reference route answers with Deprecation and Sunset headers |
Keep using the route. Nothing is actually being retired during a run |
version-format |
Capabilities reports an api_version with two components instead of the usual three |
Do not assume major.minor.patch. If you send X-API-Version, send the major version only |
In a hosted run, every change in the table reaches you only on a request that carries your key. For
the two rows whose change is in capabilities alone, that request is GET /v1/capabilities itself.
Two phases — baseline and unknown-response-field — ask you to create a booking and cancel it.
Those are sandbox bookings, so make them with invented test data (INTEGRATION-GUIDE.md §1.1a). Nothing a phase shows you is a real change to the catalogue: the altered
shapes exist only in your own run's responses and leave nothing behind.
How long it takes: a hosted run must be completed within 24 hours of starting — it is one session at your pace, not a project — and an unfinished one simply expires. Most of that time is yours: ten phases, each a short flow you run and then move past. (A staffed session, on request, is one continuous sitting of up to 120 minutes.)
You drive your own run — nobody sits in the session with you. If your sandbox key was issued by
the operator, email [email protected] when you are ready: we start a hosted run on your
sandbox account and tell you it is running — your base URL does not change. If you got your key from
the portal, start it yourself (next section). From then on the pace is yours: when you have finished a
phase, move to the next one yourself.
GET https://sandbox-api.caravelway.com/_certification/status # which phase you are on, and how many remain
POST https://sandbox-api.caravelway.com/_certification/advance # move to the next phase
Both take your own sandbox X-API-Key, the same one your client uses. advance takes no
body — it moves you to the next phase and nothing else, so there is no way to skip ahead and
nothing to get wrong in a request.
Two things worth being straight about. Moving past a phase without doing its flow does not get
you through it: the result is computed from our record of the requests you actually made, and a
phase with no evidence scores inconclusive — which never counts as a pass. And the run is still one
continuous session, so "you set the pace" means you are not waiting on us between phases, not
that you can stop for the day and come back.
Prefer to have us on the call? Say so in that email and we will run it with you instead. Nothing about the phases, the scoring or the result differs — only whether we are in the room.
Self-serve sandbox accounts run it without asking
If you got your sandbox key from the portal ("Get a sandbox key"), there is nothing to email. Start the run from your account page, or with your key:
POST https://sandbox-api.caravelway.com/_certification/start # begin a run (a run already in progress starts over)
GET https://sandbox-api.caravelway.com/_certification/status # which phase you are on, and how many remain
POST https://sandbox-api.caravelway.com/_certification/advance # move to the next phase
Your base URL does not change. While a run is active, every request you make to
https://sandbox-api.caravelway.com with that key is served through the run — and only those: a request sent
without the key, GET /v1/capabilities included, reaches the plain API and plays no part in the run.
The phases alter what you receive exactly as the table above describes; the moment the run ends, the same requests are served plainly
again. A run must be completed within 24 hours of starting — it is still one session at your pace,
not a project — and an unfinished one simply expires. When you advance out of the last phase the run
ends and its report is on your account page: the verdict, the reasons, and the evidence lines behind
every scenario. Run it as many times as you need; every run stays on the account, and a reviewer sees
all of them.
With an operator-issued key everything in this section holds too — the same window, the same two calls, the same report — except who starts it and who reads the result: the operator starts your run and tells you, and the report reaches the operator, who reviews and signs off exactly as before. There is no account page for such a key, so ask them for the report if you want to read it.
Before you ask us to start, your sandbox integration should already complete the full flow: the prerequisites above are what the run assumes, and Rehearse the run before you start it below is how to satisfy yourself that you meet them.
What decides the result
Ten phases, eight scored scenarios. The baseline is not scored — it establishes the items later
phases use. Of the eight, the first six gate the result: unknown enum value, unknown error code,
unknown response fields, unknown feature code, opaque ids, and the shrunk refresh — which is one
scenario run in two phases, shrunk-reference-refresh:1 and shrunk-reference-refresh:2, and both
stages must pass for it to pass. The last two, deprecation notice and version format, are recorded
but do not gate. An overall pass requires every gating scenario to pass, and verdicts fail closed:
- A scenario fails if your client breaks, or the short probe flow you run after the change fails, if you send back the original id
for something served re-minted, if one of the injected unknown fields reappears in a request of
yours, or if the server rejects your
X-API-Versionheader duringversion-format. - A scenario is inconclusive if the changed response was served but no follow-up call arrived —
a skipped step, or a sync that answered from cache instead of re-fetching — or if the change was
never served at all, because the request it rides never reached the run during that phase:
skipped, answered from your own cache, or, in a hosted run, sent without your key —
GET /v1/capabilitiesincluded. The reason in the report names the request it was waiting for. Inconclusive never counts toward a pass, so run each phase fully.
Rehearse the run before you start it
A run is an expensive place to discover that your client has never met one of these shapes: it is continuous, and an interrupted one starts again. You set the pace within a run, but you cannot stop one and resume it later. Everything the ten phases ask of your client can be exercised beforehand instead — across as many separate sittings as you like, with nothing at stake.
None of it is scored, none of it is observed by us, and it certifies nothing. It is how you arrive at the run already knowing what your client does.
Two ways to rehearse
Live — an ordinary call to https://sandbox-api.caravelway.com with your own sandbox key. A few phases have a
real analogue already on the wire.
Replayed — save one real sandbox response, edit the saved copy exactly as the phase describes, and feed it to your client through whatever seam your own suite already uses: a stub server, a fixture file, a recorded-response rig. This is what a run does — it edits real responses in flight, which is why everything outside the one active probe stays byte-for-byte what a direct call returns — so a replayed rehearsal drives the same code path in your client that the run will.
Replay is the load-bearing one: most phases can only be rehearsed that way, because they serve shapes the sandbox does not produce on request.
Invent your own tokens. Wherever a phase introduces something unfamiliar — a field name, an enum member, a feature code, an error code, a re-minted id — use a value you make up, and change it between rehearsals. The run uses values of its own. A client that copes with the exact token it rehearsed against has proved nothing about the one it will meet.
Phase by phase
| Phase | Rehearse it | You are ready when |
|---|---|---|
baseline |
Live. Run the whole flow end to end: capabilities → reference sync → availability → quote → book → cancel. Then quote the first few vehicles your catalogue serves, each by its vehicle_id rather than through your UI |
The flow completes, and any catalogue item is quotable by id — the later phases pick their targets from the order your catalogue is served in, not from what your UI happens to surface |
unknown-enum-value |
Replayed. In a saved GET /v1/reference/locations, change one record's type to a value you invent. Sync from the copy, then quote at that location |
The record is stored rather than dropped or rejected, the sync finishes, and the quote still prices |
unknown-error-code |
Both. Live: send a query parameter that does not exist to a reference route, read the 422, then repeat the call cleanly. Replayed: answer the first locations read with a 422 whose error.code is a string you invent, and the next one normally |
Your client degrades on the code instead of branching on it, does not retry the 422, and goes on to the rest of its work rather than stopping |
unknown-response-field |
Both. Replayed: add a field you invent to a saved capabilities response, to each saved reference record, and to a saved quote and booking — including one nested inside totals — then drive quote → book → cancel against the copies, and one further read after the cancel. Live: put that same invented field into a real quote request |
Nothing you invented ever reaches a request of yours — not in a body, not in a query string, not in a header name or value — and the flow completes, the post-cancel read included. The live half is the confirmation: requests are validated closed, so an echoed field is refused 422 validation_error rather than quietly accepted |
unknown-feature-code |
Replayed. Append one more entry to capabilities.features[], with a code you invent and enabled: true |
Your client ignores it and finishes its flow. A client that treats the list as closed — an exhaustive switch, or an assertion about exactly which codes may appear — breaks on the extra entry |
opaque-id-tolerance |
Replayed. In a saved catalogue, replace one vehicle's vehicle_id — and every protection_id scoped to that vehicle — with fresh random tokens; separately, change a second vehicle's display_name and leave its id alone. Re-sync from the copy, then quote both |
The first quote carries the new tokens exactly as served, the second still carries the original id under its new name, and nothing anywhere rebuilds an id from its parts or falls back to a cached one |
shrunk-reference-refresh:1 |
Both. Replayed: serve a full refresh with one vehicle and its protections removed — and drop the same vehicle from your saved availability response for that window, because the run withholds it from both — then quote that vehicle and one still listed. Live: compare GET /v1/reference/vehicles against GET /v1/availability for the same window — a vehicle the catalogue publishes can legitimately be withheld from a search |
Your catalogue still holds the missing vehicle and still lets you attempt a quote for it. A refresh that omits an item is a merge, never an instruction to delete it. Shrinking only the catalogue and leaving availability intact would let a client that quotes from search results pass the rehearsal and fail the run |
shrunk-reference-refresh:2 |
Replayed. Serve the next refresh with the item back | It lands as the same record under the same id — not a duplicate, not a new one — and nothing downstream re-keys on it |
deprecation-notice |
Replayed. Add an entry to capabilities.deprecations[], and stamp Deprecation and Sunset headers on one saved reference response |
Your client keeps calling the route and surfaces the notice to your own people instead of acting on it. Nothing is retired during a run, and the notice period you get in real life is the one capabilities publishes |
version-format |
Both. Replayed: report an api_version with two components. Live: if your client sends X-API-Version at all, find out what value it puts there and send that exact value once by hand — this is a deliberate check on your own client, not the way to send the header |
The replayed half parses without assuming major.minor.patch. The live half comes back 200. A 409 version_mismatch means your client echoes the whole version where it owes the major alone, which is a scored failure in the run — and since the header is optional, sending nothing at all is equally correct. Do the live half while the replayed two-component version is the one your client last read, not against a fresh three-component one: a client that derives the header with a rule like "take the first component when there are three, otherwise send the string" passes both halves checked separately and still fails the run. What the phase scores is that the value you send does not depend on how many components you were given |
Three sandbox behaviours worth meeting first
These are not phases. They are one pricing rule and two refusals that the sandbox will put in front of a certifying client, and each is cheaper to meet now than in a run.
A rental that spans two rate cards. Base rates are tier cards with validity periods, and a
rental whose nights fall in more than one period is prorated per night — the base-rate line comes
back as the blend, already computed (CHANNEL-API.md §6.2). Find a boundary by
quoting the same vehicle for the same short window in successive months and watching that line:
where it steps between two adjacent months, a card period ends. Then quote a window straddling the
step. You are ready when your reconciliation accepts a total that matches neither month's flat
rate, and when you display the line's own total instead of deriving it from a unit price.
A vehicle that is published but cannot be sold. GET /v1/reference/vehicles publishes what the
operator sells; GET /v1/availability answers for one window. A group with no free unit is absent
from the search and accounted for in meta.withheld as no_units_for_window (§4.1), and a quote
for it is refused 422 no_availability. You are ready when an absent vehicle does not vanish
from your catalogue, the refusal reads as not for these dates rather than as a broken record, and
you can explain a short page instead of treating it as a broken one.
What you can and cannot verify here. meta.withheld carries a reason and a count and no group
identity of any kind — no vehicle_id, no name, no ACRISS code — deliberately, because naming them
would disclose inventory the catalogue withholds. So you cannot check which groups sit behind a
count, and the accounting closes over the partner-scoped sellable ruleset (the groups in your
price book that are active, fleeted and distributed), a number no /v1 surface publishes. The totals
are not externally verifiable; do not build a check that needs them.
You are ready when an empty or short page routes to an explanation rather than an error: you read
the reasons, you show the ones a user can act on (age, dates), and you treat data: [] with
withheld: [] as "nothing sellable for this search" rather than a failure. The one inconsistency you
CAN detect is a group you hold going missing from data while withheld is empty — treat that as a
sync problem worth reporting.
🚩 The precondition is load-bearing: on no_partner_catalogue the entry carries no
vehicle_groups member at all — omitted, never zero, never null — so there is nothing to add up.
Reachable both when you have no sellable book and when the operator's selling estate is empty.
A handover outside a desk's published hours. Every location publishes hours and an
out_of_hours object (§3.2). Outside those hours, a desk that offers out-of-hours handovers accepts
the rental and charges the operator's out-of-hours fee if a fee applies — as one line per end,
so a rental that is out of hours at both pickup and dropoff carries two; a desk that has answered
"available": false refuses it instead, as out_of_hours_not_offered. You are ready when you use
out_of_hours.available — not hours — to tell those two apart, and any fee that IS charged reaches
your customer's price rather than your margin.
🚨 available: true does NOT guarantee a fee line, and must not be read as one. The fee member
is optional by schema and is emitted only where the operator has set an out-of-hours cost — an
operator who never set one has a desk that accepts out-of-hours handovers and charges nothing for
them. Accepting and charging are two separate facts; available answers only the first.
⚠️ hours alone cannot predict this, and building a predictor on it is the mistake this bar exists
to prevent. hours tells you WHEN a desk is open; out_of_hours.available tells you what happens
when you arrive outside that. They are different questions and the second is the one that decides.
out_of_hours: null means the operator has not stated a position: the handover is accepted and
charged if a fee applies — not unconditionally. Where hours is null or empty you cannot derive
that desk's window from the catalogue at all. And pre-filtering only REDUCES refusals, it does not
eliminate them: keep handling the 422 and the 409, which remain the authoritative answer (§3.2).
What a rehearsal cannot tell you
A rehearsal exercises your client. It does not predict a verdict, and four things about a real run have no rehearsal:
- Evidence, not claims. Every verdict comes from observed traffic. A step you skip is not a
neutral omission — it is
INCONCLUSIVE, and that never counts toward a pass. - Order counts. Your behaviour is credited only once the phase's change has actually been served to you. Quoting before you re-sync leaves the phase nothing to judge.
- One thing at a time, on a live system. Each phase alters exactly one thing on top of a sandbox that otherwise keeps behaving normally. A saved copy holds everything else still; a run does not.
- The key decides which requests are in the run. A replayed copy reaches your client whatever headers it sent, so a rehearsal cannot show you a request that goes out without your key — and a hosted run serves its changes to none of those. Confirm before you start that every call your client makes carries the key, capabilities included.
If something does come back FAIL or INCONCLUSIVE, the report names the scenario, and the
evidence lines beneath it carry the phase identifiers from the table above — so a verdict leads
straight back to the rehearsal to redo before you ask us to start the next run. The shrunk refresh is one
scenario spanning two of those rows, and both have to hold for it to pass.
What you get, and what comes next
The run produces a report in two forms, machine-readable and readable, and you get a copy — a self-serve account finds it on the account page the moment the run ends, as a table and as JSON. For every scenario it records the verdict, the reasons for it, and the timestamped evidence lines behind it — no verdict exists without evidence. Your key is not in it beyond a masked prefix.
Self-serve sandbox accounts: production is a request, and a human approves it. When your report and your webhook self-test are in place, ask from your account page ("Request production") with the legal or trading name your production partner should carry. Nobody hands you a key: a named manager creates the live partner, sets the commercial terms, brings your certification evidence across and signs it off, approves — at which point the system mints your production key and seals it — and, once your billing profile is on file, releases it. You are emailed, and you reveal the key once, in the portal, within 14 days; it is never in an email. A declined request tells you why, and your sandbox account is unaffected.
The report also states what the run does not cover, so a pass is never read as more than it is.
Settlement reconciliation, where you earn a commission
If your agreement pays you a commission, the sign-off also records that the two of us reconciled a
monthly commission statement before go-live. The operator issues a statement covering your test
bookings — booking-level, one line per ledger entry, with the voucher value, the VAT rate and amount,
the commission base and the commission — and sends it to you as CSV or JSON. You return your own
statement for the same bookings, in your own format. The operator compares the two booking by booking
on your reference (external_ref), and every difference — an amount, a status, a booking on one
side only — is explained and resolved before sign-off. An issued statement never changes: a correction
made afterwards appears as a credit or debit line on the following month's statement. This step is an
attestation the operator records, not a verdict drawn from your traffic; if you earn no commission it
does not apply.
Two suppliers — the part of certification most integrations get wrong
Your sandbox account holds one key that reaches both of the sandbox's suppliers
(https://sandbox-api.caravelway.com issues it; see QUICKSTART.md §1b). Two bars in the table
below are decided from whether you actually used both. (An account approved before one-key
provisioning holds one key per supplier instead; the bars read the same way across its keys.)
Why this exists. Almost every client that breaks on a second supplier looks perfect against a
one-supplier sandbox — because with one supplier, a client that reads GET /v1/suppliers and a client
that hardcodes its single answer send byte-identical requests. There was nothing to observe. With
two suppliers there is, and this is the one place your integration can find out cheaply instead of in
production.
The two suppliers are deliberately unalike — different currency, different tax rate, different payment
terms, different desks — and they share a station code and a vehicle category code under
different ids. If you map stations on code alone you will pair one supplier's location_id with the other's
car and be refused 422 supplier_mismatch. That refusal is the feature. On payment terms, read your
own GET /v1/suppliers. Your sandbox agreement with each supplier permits every payment_model, so
the booking you make at each may name the model you will sell in production; what differs is who
collects the prepaid part, and on a partial booking how large it is, which a booking that prepays
any part states in its payment object. An account opened before the sandbox gained these terms may
still list fewer at a supplier until [email protected] brings it up to date
(CHANGELOG.md, Sandbox fixture data — 2026-10-16); until then, book a model your
entry lists.
| Bar | What we look for, exactly |
|---|---|
suppliers.list_read |
A 2xx GET /v1/suppliers in this run's request log. |
suppliers.every_supplier_exercised |
For each supplier your account holds a book with: a 2xx GET /v1/availability, a 2xx POST /v1/quote and a 2xx POST /v1/bookings for that supplier, inside this run's window. Under one key, a request counts for the supplier it was bound to — the one its ids name (a location_id, a quote_id) or X-Supplier-Id names. A search across every supplier (by place) is bound to none of them, and counts toward a supplier's search only once the same key has made a request bound to that supplier, which the quote and booking at that supplier are. |
So: run your catalogue sync, a search, a quote and one booking at each supplier. Nothing else is required, and no statement from you is involved — both bars are read from your own traffic.
The rule: you certify on an account that holds two or more suppliers
Every partner certifies on a sandbox account that holds a book with two or more suppliers. This is a rule for every partner, not a recommendation, and it is the reason the bar above exists.
- A self-serve sandbox account meets it as issued. When your access is approved, the account is given a book at every sandbox supplier — today both of them — and one key that reaches each. Nothing needs to be asked for.
- An operator-issued sandbox starts with a book at the first supplier only. The operator adds the
second supplier's book before your certification run; ask if you see a single entry on
GET /v1/suppliers.
How suppliers.every_supplier_exercised reads your account. It counts the suppliers your account
holds a book with, and it binds only at two or more:
| Your account holds | The bar comes back |
|---|---|
| books with two or more suppliers, each walked (search, quote, booking) | pass |
| books with two or more suppliers, at least one not walked | fail, naming each supplier you left out |
| a book with one supplier | inconclusive — a provisioning state, not a defect in your client, so it is never failed |
| no per-supplier evidence for the run | inconclusive |
Inconclusive is not a pass. A bar that is not passed (or waived by us) holds the whole certification open: it cannot be signed off while any bar is still pending. So a one-supplier account can never complete certification, and the answer to that is the second supplier's book, not a waiver. Run the walk at each supplier the account holds and the bar passes.
What we deliberately do NOT check, and why
Read this as a promise about the limits of the badge. Four things a certification scheme could plausibly claim are not checked here, and will not be, because we cannot observe them from your traffic. We would rather tell you that than hand you a box to tick about yourself — a bar you assert rather than one we measure tells neither of us anything, and it would let a client that does none of these pass by typing "yes".
This section exists because the honest boundary of a machine check is worth publishing. Each of these was considered and refused rather than turned into a box you tick about yourself:
- Whether you STORE
supplier_idin your own database. Not observable. Nothing that reaches our wire distinguishes a client that persists the id from one that reads it and throws it away. We check that you read the list — the narrower claim, and the true one.suppliers.list_read's own published text says exactly this, so the bar cannot be quoted as proving more than it does. - Whether you took
currency,tax_inclusiveandpayment_modelsfromGET /v1/suppliersrather than fromGET /v1/whoami. Not observable: a correct client may legitimately call both, and for a key that reaches one supplier the two agree. We can see that you read the supplier list; we cannot see which value your code used. The contract says which one to build on (INTEGRATION-GUIDE.md§1.6) — this is guidance we can give you, not a bar we can grade. - Whether you send
X-Supplier-Idcorrectly. The header is optional and always valid to omit, so requiring it would fail correct clients. A wrong value is visible (it is refused422 unknown_supplier), but a client that never makes the mistake produces no evidence either way — and a bar you can only fail by misbehaving, never pass by behaving, is not a bar. - Whether your code branches on the supplier at all. That is a property of your code, not of your traffic. It is the thing the two-supplier walk makes likely, and nothing on the wire makes it provable.
- Channel-aware pricing (since 1.54.0). It is not part of the badge: only partners whose agreement names downstream channels use it, and a bar only some partners could reach cannot be part of a badge every partner earns. You can still test it end to end in the sandbox. On your account page (Test distribution channels), register your own test channel codes — each approved at once, in the sandbox only — and set your policy. Then name a code on the search, the quote, the booking and the cancel, and you get exactly the verdicts, echoes, issues and errors production applies. Contract §2.9 states every rule, and how your channels are registered in production.
Every bar, in one table
The run above is one part of your certification. The whole of it is the table below — generated
into this pack from the registry we actually judge against, so it cannot describe fewer bars than
exist. Each row says who decides it: most are measured from your own sandbox traffic and need nothing
from you beyond exercising the API; three come from the run's report; the rest are things only you
can show us (your code branches on error.code; your verifier reproduces the
HMAC fixture shipped with WEBHOOKS.md),
and for those we record what you hand over, with the evidence attached.
| Check | Section | Who decides | What a pass means |
|---|---|---|---|
idempotency.replay_observed |
§1 | Measured from your sandbox traffic, automatically | A repeat call with the same Idempotency-Key replayed rather than re-executed |
idempotency.key_reuse_rejected |
§1 | Measured from your sandbox traffic, automatically | Reusing a key with a different body was rejected (422 idempotency_key_reused) |
idempotency.key_per_request |
§1 | Measured from your sandbox traffic, automatically | A distinct Idempotency-Key per logically distinct request (no static key) |
idempotency.external_ref_distinct |
§1 | You attest it; we record it with your evidence | external_ref (business identity) is separate from the 24h retry key |
errors.status_families_exercised |
§2 | Measured from your sandbox traffic, automatically | At least one representative failure per status family (401/403/404/409/422/429) |
errors.branches_on_code |
§2 | You attest it; we record it with your evidence | The client branches on error.code, never on HTTP status alone, and never parses message |
errors.unknown_code_degrades |
§2 | Your certification run's report | An unrecognised error code fails soft, never throws |
errors.transient_retry_policy |
§2 | You attest it; we record it with your evidence | The client retries only transient answers, after Retry-After or with capped full-jitter backoff |
webhook.hmac_self_test |
§3 | You submit the evidence; we verify it | The broker's verifier reproduces the HMAC fixture (and rejects a tampered body) |
webhook.live_delivery_certified |
§3 | You attest it; we record it with your evidence | Full live-delivery certification (ordering, retries, backoff, dead-letter) |
pagination.unknown_param_rejected |
§4 | Measured from your sandbox traffic, automatically | An unknown query parameter 422d loudly (the deliberate typo case) |
pagination.token_endpoint_bound |
§4 | Measured from your sandbox traffic, automatically | A page_token from one endpoint was rejected on another (422 malformed_page_token) |
pagination.follows_to_exhaustion |
§4 | You attest it; we record it with your evidence | The client follows meta.next_page to exhaustion and UPSERTs by id (never a destructive replace) |
pagination.deactivation_from_flag |
§4 | You attest it; we record it with your evidence | Sellability is read from a FAILED QUOTE, never from active — and absence is not deletion |
quote.book_happy_path |
§5 | Measured from your sandbox traffic, automatically | A quote was created and booked against |
quote.consumed_rejected |
§5 | Measured from your sandbox traffic, automatically | A consumed quote could not be re-booked |
quote.expired_rejected |
§5 | Measured from your sandbox traffic, automatically | An expired quote was rejected distinctly (quote_expired) |
quote.price_changed_guard |
§5 | Measured from your sandbox traffic, automatically | The direct path's price-consent guard fired (409 price_changed) |
ratelimit.429_exercised |
§6 | Measured from your sandbox traffic, automatically | The partner's rate limit was deliberately exceeded (429 rate_limited) |
ratelimit.retry_after_honoured |
§6 | Measured from your sandbox traffic, automatically | The client waited at least Retry-After before retrying (no hot loop) |
ratelimit.retry_keeps_key |
§6 | You attest it; we record it with your evidence | An auto-retry carries the SAME Idempotency-Key as the original attempt |
capabilities.fetched |
§7 | Measured from your sandbox traffic, automatically | GET /v1/capabilities was fetched during onboarding |
capabilities.tolerates_unknown_feature |
§7 | Your certification run's report | An unrecognised features[].code is ignored, not an error |
capabilities.version_shape_tolerant |
§7 | Your certification run's report | No hardcoded major.minor.patch assumption; deprecations[] read without crashing |
suppliers.list_read |
§9 | Measured from your sandbox traffic, automatically | The client read GET /v1/suppliers |
suppliers.every_supplier_exercised |
§9 | Measured from your sandbox traffic, automatically | The client searched, quoted and booked under EVERY supplier it holds a key for |
sandbox.scoped_keys_only |
§8 | Measured from your sandbox traffic, automatically | Every certified request used a SANDBOX key, never a live one |
sandbox.run_complete |
§8 | Measured from your sandbox traffic, automatically | The run produced a real, inspectable body of sandbox traffic |
settlement.reconciliation_tested |
§10 | You attest it; we record it with your evidence | Settlement reconciliation was tested with the partner before go-live |
The HMAC self-test is the one you hand over through the API: submit the hex your verifier computed
over fixtures/webhook-hmac-self-test.json,
POST https://sandbox-api.caravelway.com/_certification/hmac-self-test with your sandbox key and
{"signature_hex": "…", "tampered_rejected": true}, and we recompute it from our own signer and
record the result against your account. With an operator-issued key, submit it after the operator
has opened your certification — we tell you when that is, at the latest when we tell you your run is
running: a submission from before that instant counts for nothing on this certification, however
recently it passed, and the latest submission is the one that counts. From the portal, submit it
whenever your verifier is ready; it is recorded against your account.
A pass settles the forward-compatibility part of your certification record, with its evidence attached. The separately-certified items listed above are completed alongside it; a production key follows all of them together, not this run on its own. If something fails or comes back inconclusive, you get the report with the exact evidence, fix it, and re-run — a verdict only comes from a complete run, so a re-run starts again from the baseline.
Security and Data Handling
Revision 1.2 — effective 2026-10-02.
Changes in 1.2: one paragraph added, under "Data we hold, and for how long" — the sandbox is for invented test data only, a real customer's data must never be sent to it, and the bookings and quotes created there may be reset at any time. No other statement on this page changed.
Changes in 1.1, all corrections of statements that overclaimed against the running code: the key length and prefix length; the wrong-environment refusal; what "a third active key" means during a rotation; which boundary the database itself enforces and which one application code enforces; and what the webhook encryption key is actually derived from and which processes hold it. Nothing here describes a change to the system — only this page catching up with it.
Cite this revision and date when you record what you assessed. The revision belongs to this document: it moves when a statement here changes meaning, and it stays still when the API's own version moves without changing what this page promises. The two are deliberately separate — the API version tells you what the wire is today, and you can read it from the running system at any time; this revision tells you which text you reviewed.
This is the page to read before your engineers read the API documentation. It states how the API authenticates you, how your data is isolated from every other partner's, what we keep and for how long, how a change reaches production, and — in its own section — what we do not have.
Every statement here is a rule, not a snapshot. Where a value can be read from the running system we say so and name the field, so you can assert on the system rather than on this page.
Transport and authentication
One credential, per environment. You authenticate with an API key in the X-API-Key header.
Sandbox and production keys are separate and are not interchangeable: a key presented to the
environment it was not issued for is refused 401 unauthenticated, the same status and the same
unauthenticated error code as an unknown key — but with a different message, which says exactly
that: that the key was issued for the other environment, and points at GET /v1/capabilities
environment so you can check the base URL. That is deliberate, and it is the opposite of the
reasoning you might expect. The environment is written into the key's own prefix, which the caller
is already holding, so naming the mix-up tells a stranger nothing a stolen key would not already
tell them — while an opaque refusal costs every honest integrator an afternoon on the wrong base
URL. Every other refusal — unknown key, revoked key, expired key — is the one opaque message.
A key minted before the environment markers existed carries no environment claim, and is refused opaquely like any other key. See the key format below for which is which.
We cannot recover your key, and neither can anyone here. A key carries 288 bits of entropy
in 48 random base64url characters, behind a marker that makes a leaked one recognisable to a secret
scanner on sight. The marker names the environment — chk_test_ for sandbox, chk_live_ for
production — so a key minted today is 57 characters, and we store its SHA-256 hash and a
17-character non-secret prefix (the marker plus the first 8 random characters). Keys minted
before the environment markers existed carry the bare chk_ marker and are 52 characters with a
12-character prefix; they are still valid, and the entropy is identical. Assert on the prefix your
own key actually has rather than on a length from this page. The plaintext exists once, at the
moment it is minted, and nowhere afterwards. Support conversations quote the prefix; the prefix
cannot authenticate. If a key is lost, it is re-issued, never retrieved.
Rotation costs no downtime. Two keys can be live on an account at once, so you rotate by
taking delivery of the second, moving your traffic, and having the first revoked. Minting a third
live key is refused rather than silently allowed — 409, naming the cap.
Be precise about what the cap counts, because it is not the number of keys that work. A rotation
retires the key it succeeds over an overlap window you choose, and a retiring key still
authenticates until its deadline — that is the whole point of the window. It is no longer live, so
it does not occupy a slot. The arithmetic that follows is the real bound: at most 2 live + at most
1 retiring = at most 3 keys can authenticate for one holder at one instant, and only during an
overlap. The holder is whatever the key belongs to. A key for ONE supplier relationship counts
toward that relationship's cap — so an account holding such keys has the cap for each supplier,
which is what the changelog's "per supplier" means. A key that reaches EVERY supplier of an account
(the sandbox's) belongs to the account, and the cap is the account's: at most 2 live, however many
suppliers it reaches. One overlap at a time is permitted per holder; a rotation is refused
409 rotation_in_progress while another is open. If you are counting credentials for an assessment, count what
GET /v1/credentials reports — each key's authenticable field is the answer, and it is computed
at the moment you ask.
Revocation is a status change that takes effect on the next request: every request re-reads the credential, and nothing caches an authorization decision.
You can restrict your account to your own egress addresses — and you do not have to. On request,
we will attach a list of IPv4/IPv6 CIDR ranges to your account; once attached, a request that
authenticates correctly but arrives from an address outside the list is refused 403 forbidden,
naming the reason. It is off for every account until an operator turns it on, and turning it on is
a deliberate act per partner — the absence of a list is not an empty list, and there is no state in
which an account is configured to allow nothing.
Take it if your integration egresses from addresses you control and can tell us about in advance. Do
not take it if your traffic leaves from a cloud NAT pool whose ranges change without warning: the
failure mode is your own integration going dark at 403, and we would rather you not have that than
have it for the appearance of a control. Tell us when your egress changes before it changes.
⚠️ The honest limit, and it is the whole of the control's value. We identify your source address from the
CF-Connecting-IPheader our edge sets, falling back toX-Real-IPand then to the socket address. Those headers are trustworthy only while the origin is unreachable except through that edge. We do not currently publish evidence that our origin refuses direct connections. Treat this as defence in depth behind your own controls, not as a boundary you can rely on alone, and ask us in writing for the current origin-exposure position before you do. If you need a hard network boundary, the answer is a private link, which is not something we offer today.
An address we cannot resolve is treated as outside the list, never inside it: if the header is absent or unparseable and you have a list configured, the request is refused. The control fails closed.
Authorization is published per operation. Every operation in the OpenAPI document that requires
a credential also publishes the scope it requires, as x-required-scope, and the operations that
require no credential declare that too — so you can audit what a key is able to do by reading the
contract rather than by probing. That value is always a single string, and always a scope that really
does open the operation. Where other scopes independently open the same operation, they are listed
alongside it as x-accepted-scopes, an array of which any ONE member is enough — never all of them,
and never a set you must hold together. Read x-required-scope and you are never wrong; read
x-accepted-scopes as well and you can pick the narrowest scope that covers what your system does.
Scopes are not hierarchical: holding one never implies another, so a key's
reach is exactly the list it was issued with and nothing derived from it. Scopes are fixed at the
moment a key is minted — a key does not acquire a new capability later, it is replaced by one that
has it. Read a 403 forbidden as a scope your key was not issued, not as an outage.
Least privilege is available, and worth taking. Where reading and changing the same resource are
separable, they are separate scopes: the booking reads are opened by read:bookings as well as by
write:bookings, so a system that only reconciles bookings can be issued a key that cannot cancel
one — it is refused 403 forbidden on create, modify and cancel. Ask for the narrowest scope set
each of your systems actually needs rather than one key that does everything; it bounds the damage a
bug or a leaked credential can do, and it costs you nothing, because a narrower key reads exactly what
a broader one reads.
Key-guessing is throttled and watched. A coarse per-IP throttle sheds an unauthenticated flood before it reaches the credential lookup, and every failed authentication is counted; a sustained burst raises an operational alert rather than running unseen.
Your key does not appear in our logs. The request log records the key's own non-secret prefix (17 characters for a key carrying an environment marker, 12 for a pre-marker key) and only once the key has successfully authenticated — a rejected request records no key material at all, because at that point the header could contain anything. Certification run records are built from those same logs and inherit the same property.
Use the published https:// base URLs. Both environments are served over TLS, and every base
URL, example, fixture and generated client we ship is https://. A cleartext http:// request is
answered with a redirect to the https:// URL, but a redirect cannot protect what has already been
sent — see the transport line in What we do not have below.
Isolation and tenancy
Your operator is resolved from your credential, on every request — never from a process-wide default. Resolution runs in three steps, and the order is the design: the key identifies which operator it belongs to (a pointer that grants nothing); the process confirms it is permitted to serve that operator at all; then the credential is re-read scoped to that operator, and the database decides. The first step grants nothing on purpose — if that directory were stale, wrong or tampered with, the scoped read returns no rows and the request is refused. It can never widen access.
Isolation between OPERATORS is enforced by the database, not by application code remembering to
filter. The application connects as a least-privilege role that cannot bypass row-level security
and cannot grant itself the bypass. Row-level security is enabled and forced on every
tenant-scoped table, and every policy tests the operator carried in a transaction-local setting, so
a query that forgets its tenant returns nothing rather than another operator's rows. That is the
enforcement. On top of it, the /v1 queries also carry the operator condition explicitly — belt to
the braces, so that two independent mechanisms have to fail together for that boundary to be
crossed.
🔸 The boundary between PARTNERS of the same operator is enforced by application code, and we say
so rather than let the sentence above be read as covering it. The row-level policies are keyed on
the operator; one operator has many partners, so the policies alone would not stop one partner's
credential from reaching a sibling partner's row. What stops it is an explicit partner_id
predicate on every such query — one mechanism, not two. It is treated accordingly: it is
load-bearing rather than decorative, it is what the credential and settlement surfaces are built
around, and the isolation suites assert it as an invariant with positive controls (below) rather
than trusting that it was remembered. If your assessment distinguishes database-enforced boundaries
from application-enforced ones — and a serious one does — then operator isolation is the former
and partner isolation is the latter.
The boundary is tested as an invariant, against data that genuinely exists. The isolation suites run as the real least-privilege role, insert a foreign row first, and pair every "sees zero rows" assertion with a positive control that the same role sees its own row in the same table — so a pass can never mean "the table was empty" or "the test plumbing was broken". A red assertion there is treated as a finding, never as a test to soften.
Sandbox is a separate deployment with its own store, and its data is fixture data. Certification is built to run there. Two refusals stand between it and production, and neither has an override: the live origin is refused outright, and so is the live service's port on the machine certification runs from. A third control is an acknowledgement rather than a refusal, and is worth naming as what it is — an attended certification run will not start until it names its target explicitly, and that acknowledgement is recorded in the run's report. It makes the choice of target deliberate and auditable; it does not itself verify that the named host is a sandbox.
Data we hold, and for how long
What we hold. The booking record and the customer identity attached to it, the priced breakdown behind it, your API request log, and — where the rental was signed with us — the rental agreement.
The sandbox is for invented test data only. A real customer's personal data must never be sent to
it: it is the environment in which an integration is built and certified, not a place for customer
data. Data that looks real may be flagged for review, and a flag never blocks a request. Bookings and
quotes created there may be reset at any time, without notice. The rule for partners is in
CHANNEL-API.md §10.
Retention is operator configuration, not a global constant. Three retention periods are configured per operator and applied by scheduled sweeps: how long customer identity is kept on a completed booking, how long the priced detail behind it is kept, and how long a signed rental agreement is kept as a transaction record. Each is a stored setting with its own enable switch, so the figures — and whether a given sweep runs at all — are a property of the operator you are booking against. Ask for the schedule that applies to your account; we will not quote you a number here that is true of one deployment and false of the next.
Erasure requests are handled by the source of the booking. This is a description of what the system does, not a determination of anyone's role under data-protection law — that is fixed by the agreement between us, and the agreement governs where the two disagree.
- For a booking made with the operator directly, an erasure request runs the erasure: the customer's identity is removed across the booking and the surfaces that carry a copy of it, except where a retention period still covers part of that record — see below.
- For a booking that reached the operator through a partner's API, the booking is retained under the partner agreement and the erasure request is referred to that partner rather than actioned unilaterally. Referral is the answer on this path; no partial erasure is performed on it.
An erasure we action can come back partial, and it says so. A retention period covering part of a record — a signed agreement kept as a transaction record is the ordinary case — outlives the erasure, so the request is answered on a stated partial-erasure basis rather than reported as a complete deletion it was not. You are told what was removed and what was kept. Retention and erasure are two rules over the same record, and where they meet, retention is what makes the answer partial.
This is a split by booking source. It is neither a blanket refusal to delete nor an undertaking to delete everything on request, and the specific outcome for your bookings should be settled in the data processing agreement rather than inferred from this page.
Webhooks, if you take them
Deliveries are signed. Each delivery carries an HMAC-SHA256 signature over a canonical string
that binds the timestamp, method, path and the exact body bytes. Verify before you parse, compare in
constant time, and reject a delivery whose timestamp falls outside your replay window. The scheme,
its tag, the header name and the signed-payload form are published on GET /v1/capabilities rather
than only in prose, and a static self-test fixture ships with the integration pack so you can prove
your verifier before you take live traffic. During a secret rotation more than one signature is
sent; accept the delivery if any one of them verifies.
Your signing secret is encrypted at rest with AES-256-GCM and is never hashed — the signer needs the secret itself. The encryption key lives only in the deployment's environment, never in the database and never in a log, and delivery fails closed rather than ever falling back to an unkeyed signature.
🔸 Two properties of that key we would rather you learned here than found for yourself. First, the key is derived from a dedicated variable or, when that is unset, from the application's session-signing secret — so one secret can protect both session integrity and every stored webhook secret, and "fails closed if the key is absent" is true only when both are absent. Second, the sandbox and production processes run from the same shared environment file and therefore derive the same key: it is a deployment-wide key, not a per-environment one, so read "the deployment's environment" literally rather than as isolation between sandbox and production. Neither is a defect being disclosed — no secret is stored in clear, and no ciphertext crosses a tenant boundary — but both are exactly the kind of thing an assessment is entitled to be told rather than to assume. A separate key per environment, with a key identifier carried in each ciphertext so existing secrets stay readable through the change, is a named piece of planned work.
We will not let a callback URL become an internal request. Before every delivery the target is validated: HTTPS only, no embedded credentials, and every resolved address checked against the private and reserved ranges — checked as addresses, so a public hostname pointing at a private record, and numeric or encoded host forms, are all caught. The validated address is then pinned into the socket so no second, unchecked resolution can happen between the check and the connection, while certificate validation still runs against your hostname. Redirects are never followed: a 3xx is a failed delivery, so a redirect cannot bounce a delivery to an unvalidated target.
Change management
/v1 is additive only. Fields and endpoints are added; the set of fields a response guarantees
may grow but never shrink. Your client must ignore fields it does not recognise and tolerate enum
members it has not seen — that requirement is stated in the contract, and it is what makes additive
change safe for you. This one is a policy we hold ourselves to rather than something a machine can
prove in advance, so we publish the contract document itself at a stable address on each
environment: hold a copy and diff it, and you can audit the promise rather than take it.
Deprecation carries a minimum 180-day notice, published as a number. Read
deprecation_notice_days from GET /v1/capabilities and assert on it; announced changes appear in
the same document with their dates, and on the affected responses as standard Deprecation and
Sunset headers that agree with it. A sunset date may only ever move further out, never nearer.
Every merge is gated, and the gates are mechanical. The published contract is checked against the routes the code actually mounts, so a route cannot drift out of the document. The integration pack is re-rendered from source and byte-compared against the committed copy, in both directions, so neither a stale artifact nor an unreviewed edit can ship. Secrets are scanned across the full history. The tenancy, pricing and booking suites run against a real database rather than mocks. Merges to the shipping branch are restricted to the operator's owner.
Contract-affecting and money-affecting changes get an independent external review before merge — several frontier models from different vendors read the diff, and each finding is verified against the code before it is accepted or rejected.
Deploys ship code only, behind two blocking gates: the schema-versus-code gate refuses a deploy whose database schema is behind the code being shipped, and a live booking canary must complete after it. The first gate runs in one direction, which is the direction that hurts: a deploy whose database schema is behind the code being shipped fails, rather than serving routes that read columns which are not there yet.
What we do not have
We would rather you read this here than discover it in a questionnaire.
- No SOC 2 and no ISO 27001 certification.
- No third-party penetration test report on file.
- No committed service-level agreement or published uptime figure. Service commitments are a separate document and are not made on this page.
- No published recovery-time or recovery-point objective, backup topology or restore-drill evidence. We will not state a recovery position we have not measured, and we have not measured one. Ask us for the current position, in writing, before you rely on it.
- No HSTS preload, and no
includeSubDomains. Both environments do answer a cleartexthttp://request with a redirect to thehttps://URL, and both do send aStrict-Transport-Securityheader on their responses — read its currentmax-agefrom any response rather than from this page. Neither control protects a request that is already in flight: the redirect is issued only after the request, and any header it carried, has reached us in the clear, and themax-agebinds a client only once it has already seen one HTTPS response from that host. Configure your client with the publishedhttps://base URLs and do not rely on us to correct a downgrade — and treat a key that has been sent even once overhttp://as disclosed, and ask us to rotate it. - No network-level rate limiting at the edge. Per-partner rate limits, cost quotas and the pre-authentication throttle are enforced in the application.
What to ask us for
These are answered per deployment rather than on a published page, because a published answer would rot: the hosting region and data residency statement; the backup schedule, retention and restore testing; penetration-test status; the retention period configured for your account; and the data processing agreement and sub-processor list.
Changelog
Status: Informational. The wire is authoritative for the current version — api_version on
GET /v1/capabilities — and CHANNEL-API.md is authoritative for what every
member means. This file exists so an integration built against an earlier pack can find, in one
place, what changed and what to re-check. Newest first.
Every entry is labelled with the kind of change it is, and there are exactly three. Read the label first; it tells you whether an entry can reach you at all.
- Additive — new members, new endpoints, new enum members, and a widening of what a request may contain. Nothing an earlier integration sent or parsed is removed or reshaped. These ship to everyone, including you, whatever version you are pinned to; a client that tolerates unknown members (§1.2 of the contract) is unaffected by all of them.
- Behaviour change — every shape stays exactly as it was and what a value MEANS changes: a total
that can go up, a list that now holds one entry, a rule applied at both ends instead of one.
These reach you only when you move your pinned version, and never before. Your pinned version
is
api_version.pinnedonGET /v1/whoami. - Removal — announced through the deprecation registry with its
DeprecationandSunsetheaders and a notice window of at leastcapabilities.deprecation_notice_days. Nothing is removed within/v1at all; the/docs/versioningpage on the host you call carries every active notice.
🚨 Two entries below are labelled Behaviour change and were published as additive when they shipped: 1.32.0 and 1.34.0. Both kept every shape and changed a meaning, which the labels above now separate. They were relabelled on 2026-09-12, when per-account version pinning shipped — the text was corrected, the behaviour of neither release was altered. No integration was affected: no partner was live on production when either shipped.
This pack was built at surface version 1.66.0.
Sandbox webhooks — 2026-10-18 — REAL DELIVERY TO YOUR OWN ENDPOINT, switched on by the operator after an ownership challenge (no /v1 change)
Nothing in this section changes /v1 or production. It is a sandbox capability, so it carries none of the
three labels above, and no member, code, shape or version moves.
- What is new. A self-serve sandbox account's booking events are captured in its inspector. The operator can
now switch on real delivery to your registered URL for one sandbox supplier, for a limited time and number of
requests, so you can see real requests reach your own receiver before production. You cannot switch it on
yourself.
WEBHOOKS.md§7, Real delivery to your endpoint from a self-serve sandbox account, has the detail. - The ownership challenge. Before switching it on, the operator sends your registered URL one signed
webhook.endpoint_challengerequest. Your receiver answers200with{"challenge_response": "<hex HMAC-SHA256(secret, \"webhook.endpoint-challenge.v1:\" + challenge)>"}. It is not a booking event: it is not inwebhooks.event_types, never retried and never in your delivery log. - What to do. Nothing, unless you want real delivery in the sandbox: then add the challenge to your receiver and ask the operator.
- Unchanged.
GET /v1/webhooks/endpointkeeps reportingenabled: falsefor a self-serve account; test events are always captured in the inspector; production arming is unchanged.
2026-10-18 — TWO RULES FOR EVERY PARTNER: the hotel on a delivery booking goes in notes, and certification needs two suppliers — no wire change
No /v1 status, member or behaviour changed. Two rules are now written down, and one sentence about
how a search counts toward certification is corrected.
- The hotel on a delivery booking goes in
notes. A location that prices a delivery is a delivery area, not the customer's hotel. When either end is at one, put the hotel's name and address in the booking's free-textnotes; there is no structured field for it.notesis a string ornullwith no length limit of its own, bounded only by the 64 kB request body.INTEGRATION-GUIDE.md§5.2a andCHANNEL-API.md§2.1 state it, and the OpenAPIdescriptionofnotesonBooking,CreateBookingRequest,CreateQuotedBookingRequestandModifyBookingRequestnow says the same: a person at the desk reads it, nothing parses it, and for a delivery-priced location it carries the hotel. Only description text changed: the type, the members and the validation are as they were. - Every partner certifies on an account holding two or more suppliers. A self-serve sandbox account
holds both sandbox suppliers when it is approved; an operator-issued one is given the second before the
run.
suppliers.every_supplier_exercisedbinds only at two or more and is inconclusive on one, and an inconclusive bar keeps a certification from being signed off.CERTIFICATION.mdstates the rule and how the bar reads each case. - Corrected: a search by place does count toward a supplier's search.
CERTIFICATION.mdsaid a search across every supplier counted for none of them. It counts for a supplier once the same key has made a request bound to that supplier, which the quote and booking there are. The bar itself did not change.
2026-10-18 — DOC CORRECTIONS: your key on capabilities during a certification run, a row quoted with its own plan, and what the sandbox's suppliers state about payment — no endpoint behaves differently
No /v1 endpoint behaves differently. The guides, the walkthrough script, the wording of a
certification report, and the way the contract document states capabilities' authentication did.
- During a certification run, send your key on every call —
GET /v1/capabilitiesincluded. Capabilities answers without a key, and outside a run nothing about that changes. A hosted run, though, serves its changes only to requests that carry your key, andunknown-feature-codeandversion-formatchange capabilities alone — so a client that read it keyless scored bothinconclusiveand could not pass.CERTIFICATION.mdnow says so in its prerequisites, its run and self-serve sections, its scoring and its rehearsal limits, and the contract (§1.3) and the guide (sections 1.2 and 1.4) no longer read as an instruction to leave the key off. A run report now names the request a phase was waiting for — here, that noGET /v1/capabilitiescarrying the run's key arrived — where it said only that the client "did not fetch the mutated surface". Verdicts are unchanged: such a scenario is stillinconclusive, and that never counts toward a pass. - The contract document says it too.
GET /v1/openapi.jsondeclaresGET /v1/capabilities'ssecurityas[{}, {"ApiKeyAuth": []}]— no key, or yours — where it said[], no authentication. The route answers exactly as before; a client generated from the document, and the shippedgetCapabilitiessamples, now send your key there. The generated reference's scope line reads the samesecurity: it no longer says "no key needed" ofGET /v1/whoami,GET /v1/suppliersandGET /v1/distribution-channels, which carry no scope and answer401without a key, as they always have. - Quote an availability row with its own
plan.walkthrough.mjsand the Quickstart quoted the first row without itsplan. Rows come ordered by vehicle and then plan, so that row is usually on a plan you are not served by default, and its package was refused422 validation_error, issueunknown_protection— the walkthrough's first quote failed on every run against the sandbox. Both now send the row'splan; the walkthrough also tries one row per vehicle, and prints thedetailsissue of a refusal. GET /v1/reference/protectionslists your served plan's packages only, as it always has. The contract (§2.4, §3.5, §4.1) and the guide now say so where they describe the read, and say to take theprotection_idfrom the row you price. The guide's quote section told you to take it from that read, which is right only for a row of your served plan.- The sandbox's two suppliers do not differ as "one prepaid, one paid at the desk". Since the sandbox
fixture change recorded below (Sandbox fixture data — 2026-10-16), your sandbox agreement with each permits
every payment model: both
GET /v1/suppliersentries statepayment_models: ["pay_on_arrival", "partial", "prepaid"], so a booking at either may name any of them aspayment_model. What differs is who collects the prepaid part, and on apartialbooking how large it is; a booking that prepays any part states both in itspaymentobject (contract §2.8). An account opened before that change may still list fewer until it is brought up to date (that entry says how), and your own entries are what applies to you. The Quickstart's walk throughGET /v1/supplierssaid one supplier prepays and the other is paid at the desk, and the 2026-09-12 sandbox entry below said the same; both are corrected, the entry in place.CERTIFICATION.mdnow says what the two suppliers' payment terms let you book.
2026-10-18 — THE SANDBOX INSPECTOR SHOWS YOUR BOOKING EVENTS: a self-serve account's stream is captured, signed, and listed in its delivery log
Additive. No shape, member, enum value or version changed, and nothing changed outside the sandbox. A
self-serve sandbox account is now shown events it was not shown before. WEBHOOKS.md §7, A
self-serve sandbox account: your booking events land in the inspector.
- Every booking event the sandbox emits for a self-serve account is captured in the inspector on its account
page, as exactly the request a receiver would have seen: the same envelope with its
sequence, the sameX-Caravel-Signaturesigned with the account's live secret, the same headers, and the registered URL's request-target. Until now the inspector held onlywebhook.test, andGET /v1/webhooks/deliverieslisted nothing for a self-serve account. Nothing is sent to any URL: a self-serve account is still never armed. - The delivery log lists each capture as
status: "delivered", withlast_status_code: nullandlast_failure_class: null. - Redelivery works for a self-serve account, and each replayed event is captured again. Until the account's
stream has a signing secret there is nothing to replay, and the answer is
409 webhooks_disabled, saying so. - The stream starts when it first has a signing secret. The first test event creates one, and the booking
events since the last sandbox reset are then captured too, numbered in event order from
sequence1 (they can land out of that order across bookings, as deliveries can). This is the one sandbox-only difference from an armed stream, which is never back-filled. - A sandbox reset now also empties the inspector, test events included.
1.66.0 — 2026-10-17 — DESK CORRECTIONS REACH YOU: a price or date the operator corrects is pushed as booking.modified carrying change
Additive. No shape changes and no new member, field or enum value: an existing event, carrying the existing change block with an existing reason, is now sent on one more occasion. A receiver that already applies booking.modified by revision (§7) has nothing to build.
- A desk correction of the price or the dates is pushed. When the operator's desk corrects a booking's total or its pickup or dropoff, the booking moves to a new
revision, its snapshot states the corrected total, instants and day count, and you receivebooking.modifiedwithchange: {actor: "operator", reason: "operator_adjustment", changed_at}(Channel API §5.1, §7). Before this release §7 listed such an edit as not pushed — poll for it; it is now pushed, and the list poll (§5.5) shows it as a movedrevision. changetells you it was the operator, not you. Its presence is what separates this from your own/v1modify (§7.1). A desk save that also states a reason, or moves the booking to another class (overbooking), sends ONEbooking.modifiedwhosereasonis that more specific one.- One save, at most one content event, and the terminal last. A desk save that corrects the content and also cancels the booking, or marks it
no_showorcompleted, sendsbooking.modifiedand then the terminal event at the nextrevision; the terminal event carries the corrected content too, and a cancellation'sfeeis resolved on the corrected price, and its notice is counted to the corrected pickup.booking.modifiedstates the status the booking held before the save; the terminal event states the new status together with itscancellationorno_showobject, so no revision statescancelledorno_showwithout it. - A fee you were sent is never contradicted. The operator's desk cannot correct the price or dates of a booking that is already cancelled or a no-show: it reinstates the booking first, and a new cancellation or no-show then states a new fee on the corrected booking. So no
booking.modifiedmoves the total under acancellationorno_showfee computed on the old one. - Nothing that changes nothing you were sold is sent. A note edit, or a car swapped within the class you were sold, mints no revision.
vehicle_id,acrissanddisplay_namestay as sold. - The point read's price. §5.2 used to say a desk adjustment could not rewrite the figure you sold at. That figure now moves only at a revision you are sent: your own
/v1write or an operator correction carryingchange. A settlement line names the revision whose snapshot states the total it bills on.
Sandbox fixture data — 2026-10-16 — EVERY PAYMENT MODEL AT BOTH SUPPLIERS, a late-cancellation fee, a delivered desk and a fourth plan (no surface change; sandbox only)
Nothing in this section changes /v1 or anything in production. It records a change to the
seeded fixture data in the sandbox environment: the two sandbox suppliers now hold the terms a
production agreement can switch on, so a certification exercises the ones you will sell. Sandbox
fixture data is illustrative and may change; read every value from the API at run time. (Recorded
2026-10-18: this entry was missing when the change shipped.)
- Payment terms, at both suppliers. Your sandbox agreement with each permits
pay_on_arrival,partialandprepaidas a booking'spayment_model, withpay_on_arrivalthe default — so a booking that names nopayment_modelis paid at the desk exactly as before. The two differ in who collects the prepaid part — the operator at the EUR supplier, you at the GBP one — and in how large the prepaid part of apartialbooking is. A booking that prepays any part states both in itspaymentobject (contract §2.8). The EUR supplier stated no payment terms before: itsGET /v1/suppliersentry now carriespayment_models, and its bookings apaymentobject — members appearing, as/v1allows. - The refusal of a model your agreement does not permit (
422, issuenot_permitted) cannot be produced on an account holding these terms: you meet it under production terms. No certification bar asks for it. - A late-cancellation fee at the GBP supplier. Free until 48 hours before pickup, then a share of
the grand total, as its policies read (
GET /v1/reference/policies) states. The EUR supplier stays free to cancel at any notice. - A delivered desk at the GBP supplier: a new station of
typehotel,sbx-hotels, with a delivery charge per end, which itsdeliverymember states. New rather than a charge added to an existing desk, so no total already priced at an existing desk moved. - A fourth plan at the GBP supplier,
no_deposit_with_excess: no deposit and a real excess on each group, priced above Premium, on its own availability and rate rows (contract §4.1, Plans). - What did not change: no category, desk, extra or fee was renamed or removed, and no existing price, rate card, id or plan of either supplier moved; the EUR supplier's published reference pages are byte-identical.
- An account opened before this change may still hold the earlier terms —
pay_on_arrivalalone at the GBP supplier, and nopayment_modelsmember at the EUR one — until it is brought up to date. Your ownGET /v1/suppliersentries are what applies to you; if yours list fewer than the three models, ask[email protected]to bring your sandbox agreement up to date, and until then book a model your entry lists.
1.65.0 — 2026-10-16 — THE PAYMENT AMOUNTS AS MONEY: amount_prepaid and amount_on_arrival carry their currency, from your pin
Behaviour change. It reaches a response only when the supplier book answering it is pinned at 1.65.0 or later. The pin is held per supplier you book, not once for your account. Below 1.65.0, nothing you receive from that supplier changes.
- What changes. In the booking's
paymentblock (1.27.0),amount_prepaidandamount_on_arrivalare sent as Money objects,{ "amount": <integer minor units>, "currency": <the currency of price.totals.grand_total> }. That is the shape every other money member of the booking has, and the shape the published schema has declared for these two since 1.27.0. The amounts themselves do not change, and they still sum togrand_totalexactly. - Who receives it. Each supplier book has its own pin. A request answered by a book pinned at or
above 1.65.0 receives Money. A book pinned below 1.65.0 keeps sending each amount as a bare integer
in minor units, as it has since 1.27.0. A key that reaches several suppliers can therefore receive
both shapes, one per supplier, until every book's pin has moved. The pin is
api_version.pinnedonGET /v1/whoami. For a key that reaches several suppliers, each supplier's pin isbooks[].api_version.pinned. Each supplier moves its own book's pin when you ask it to. - Where it applies. Every response that carries the block:
GET /v1/bookings/{ref}, aPATCHand its alias when nothing changed, a cancel of a booking that is already cancelled, and the booking inside a409 revision_conflict. The booking list, the create responses and webhook payloads carry nopaymentblock, for any pin. - What to check before a pin moves. Code that reads either amount as a number must read
.amountinstead. A client generated from the published schema already expects the object.
1.64.0 — 2026-10-16 — THE BOOKING STATUS IS AN OPEN LIST: accept a status you do not know, before any new one exists
Behaviour change. No booking carries a new status, and nothing changes for an account until it moves its
pinned version to 1.64.0. What changes is the promise. The booking status was the one closed enum in /v1, and
you were told you could branch on it exhaustively. From 1.64.0 it is open, like every other enum, so a later release
can add a lifecycle state without a new major version. It is opened now, while it costs nothing, rather than when the
first new state would have forced a re-contract.
- Pinned below 1.64.0? You keep the closed list. An account whose
api_version.pinned(GET /v1/whoami) is 1.63.0 or earlier is only ever servedpending,confirmed,cancelled,no_showorcompleted— on every booking response, theGET /v1/bookingslist,details[].current_statusof aninvalid_transitionrefusal,booking_statusesonGET /v1/reference/constants, and in a webhook delivery'sdata. A state added later is served to that account as its declared pre-1.64.0 equivalent, announced with the state: never a terminal value for a state that can still move, never a live one for a state that cannot, and never a step the closed list did not allow (noconfirmed → pending). The?status=filter matches by the value you are served. - Pinned at 1.64.0 or later: a later state reaches you as itself, and the rule below applies.
- Today's values and transitions are unchanged. A status is still one of the five. A booking created through
/v1is stillconfirmedat once. Nothing you parse today changes. - The published document lists them as
x-extensible-enumonBooking.statusand on theGET /v1/bookingssummary row, as every other response enum is listed. A generated client now types the member as an open string.capabilities.forward_compatibility.extensible_enumsnow namesbooking.status,bookings[].statusandreference.constants.booking_statuses. The document is the same for every caller and describes 1.64.0. - What to do with a status you do not recognise (
CHANNEL-API.md§8.1): treat the booking as neither confirmed nor finished. Do not show it to your customer as confirmed, and do not treat it as cancelled or release what it holds. Keep the booking and its last known value. Read it again withGET /v1/bookings/{ref}, the list, or the next webhook, until it carries a value you know. Never reject the payload or the webhook delivery for it. Write this as the default branch of anyswitchonstatus. - A new state is announced here first, with its meaning, its transitions and its pre-1.64.0 equivalent, before any booking carries it.
- What to check before you move your pin to 1.64.0: a hand-written exhaustive
switchonstatuswith no default branch, or a validator that closes the five values. Either one would fail on the first new state.
1.63.0 — 2026-10-16 — THE NO-SHOW FEE: what a no-show costs, quoted, frozen on the booking, and charged
Additive. A supplier can now charge a fee when a renter does not turn up and the desk records the booking as a no-show. The fee is a whole-number percentage of the same amount the supplier's cancellation fees are measured against (fee_basis), and a supplier may agree a different one with you. If the supplier states no fee — which is where every supplier starts — nothing you read changes.
- Quoted.
GET /v1/reference/policiesandmeta.policiesonGET /v1/availabilitycarryno_show: { type: "percent", fee_percent, fee_basis, source }when a no-show is charged for you.sourceisbroker_termswhen it is the fee agreed with you,operator_defaultotherwise. Absent means a no-show costs nothing. - Frozen on the booking. A booking made while a fee is in force states it as
cancellation_policy.no_show_fee_percent, and keeps it whatever the supplier changes later. - Charged. When the desk records a no-show on such a booking, the
booking.no_showrevision carriesno_show: { fee, recorded_at }, and so doesGET /v1/bookings/{ref}while the booking is a no-show (a desk reinstatement removes it from the read).feeis the amount owed to the supplier; nothing is collected through this API. - Nothing you already parse changes. Each member is present only when a fee is charged. No capability code: the members are self-detecting.
1.62.0 — 2026-10-07 — RECORD YOUR OWN CODES: GET and PUT /v1/partner-codes
Additive. A supplier can now let you keep the map of §1.60.0 yourself. Two new endpoints, two new scopes and one feature code; nothing you already read changes, and until the supplier switches self-service on for your account there is nothing for a client that never calls them to notice.
GET /v1/partner-codes(read:partner-codes) lists every object you can read — vehicle groups, protections, plans, stations, accessories — as{ kind, id, name, code }, with your code where you have one andnullwhere you do not.Accept: text/csvreturns the same rows as a file to fill in.PUT /v1/partner-codes(write:partner-codes) sets, changes or withdraws codes —{ entries: [{ kind, id, code | null }] }as JSON, ortext/csvwith the columnskind,id,code. It is all or nothing: the shape is checked first, then each entry reports all of its own problems in one422, each with its entry or file line, and?dry_run=truechecks the request and writes nothing. Restating what you already hold is200and writes nothing. A code still recorded against an object you can no longer read is released when you give it to one you can (applied.released).- Off by default, switched on by the supplier, per partner. Until it is,
PUTis403 forbiddenwith the issuepartner_codes_not_enabled.meta.self_service.enabledon theGETsays where you are. - Thirty saves an hour per supplier, then
429 rate_limitedwithRetry-After. Every change is recorded against the key that made it. - Feature code
partner_codesonGET /v1/capabilitiessays the deployment serves the endpoints (not that your account may write).
Contract: §3.15. Guide: §2.1f.
1.61.0 — 2026-10-07 — THE PERCENTAGE BEHIND A "FROM" PROTECTION PRICE: the share and the per-day bounds, so you can show a total without a quote
Additive. Where a supplier prices a protection as a share of the car rate, the product has carried price_basis: "minimum" since 1.24.0, and its price has been only the least it can cost per day. The share itself was withheld because you held no rate card to apply it to. GET /v1/reference/rates (1.56.0) gave you the card, so the share is now published.
- One new optional member,
price_percent, on exactly the objects that carryprice_basis:GET /v1/reference/protections, theprotectionof aGET /v1/reference/ratesrow, and the protections embedded in aGET /v1/availabilityrow. It reads{ "share_bps": 1500, "min_per_day": Money, "max_per_day": Money }:share_bpsis a whole number of basis points (1500 is 15%);min_per_dayandmax_per_dayare present only where the supplier set that bound; an absent member is no bound at all, and a floor of0, which can be stored, is present and never binds. - The rule, and test vectors to check your implementation against, are in the contract (§3.5). For a stay of N billed days with base total B, the charge is
round_half_up(B × share_bps ÷ 10000)— one rounding, on the whole stay — held to at leastmin_per_day × Nand at mostmax_per_day × Nwhere those are present. The rate rows of the stay give you B:totals[N]for N up to 7,N × extra_daybeyond, blended ascross_periodsays where a stay spans more than one rate period. - Take the terms from the rate row you are pricing from. A rate row states the terms of the price list that row prices, which the quote at its stations applies.
GET /v1/reference/protectionsand the protections in an availability row state the default price list's, so wherever a station keeps its own price list the sameprotection_idcan carry different terms there (the same difference §3.12 states for base prices). - The quote is still the authority.
POST /v1/quote, a booking andtotals_for_windowcarry the exact figure, and a stay the rate row does not price (anullcell, a length outsiderental_days) is refused at the quote whatever the arithmetic gives.price_percentlets you show a total without a call; it is not a second price. - Nothing you already parse changes. The member is present exactly when
price_basisis, so every flat and included product of every supplier is byte-identical.pricekeeps its meaning and stays the floor. There is no capability code: the member is self-detecting on the row.
1.60.0 — 2026-10-06 — YOUR OWN CODES: the code you keep for a supplier's vehicles, products, stations and accessories, on the rows that carry them
Additive. If you key your own table on codes — a "Vehicle Code", a "Product Code", a station code, an accessory code — the supplier can now record your code for each of its objects, and every reference row that carries the object returns it. Nothing you already read changes, and if the supplier has recorded no codes for you, nothing at all is added to what you read.
- A map the supplier keeps for you, per supplier. It is the supplier's own record of what you call its objects — not a column of its catalogue, and never visible to another partner. A partner with no map reads today's wire, member for member.
- The members.
partner_codeonreference.vehicles,reference.protections,reference.extrasandreference.locations;vehicle_partner_codeandplan_partner_codeon areference.ratesrow, withstations.location_partner_codesaligned withstations.location_ids;vehicle_partner_codeon areference.stop-salesrow;pickup_location_partner_codeanddropoff_location_partner_codeon areference.feesrow, besideapplies_whenand never inside it. - Present means mapped;
nullmeans no code for that object. Once the supplier has recorded any code for you, the members above appear on every row they apply to,nullwhere it has none for that object. A code is exactly what the supplier recorded: stored as sent, 1–128 characters, never trimmed or case-folded, and it never changes whatvehicle_id,protection_id,extra_idorlocation_ididentify — those remain the only ids you send back. - Not the supplier's own label.
partner_codeis nevercode, the supplier's own label, which joins a charged line to its record. - Where it is, and is not. The members are on the reference reads above. The accessories and protections embedded in
GET /v1/availabilityand theprotectioninside a rate row do not carrypartner_code; read the reference pages for it.
What to do: nothing. If you hold your own table keyed on your codes, read the members when they appear and replace per supplier as before.
1.59.0 — 2026-10-06 — ACCESSORY CODES: read the supplier's own code for an accessory, and the OTA code it maps it to
Additive. Two optional members on each accessory of GET /v1/reference/extras — and on each extra of GET /v1/availability's meta.extras, which is the same record. Nothing you already read changes, and a partner that ignores them is unaffected.
supplier_code— the supplier's own short code for the accessory (such asCHILD; upper-case letters, digits,.,_and-, at most 32 characters), for keying your table on something that is not our identifier. Set once by the supplier and never changed, unique within one supplier (not across suppliers), and never reused for a different accessory — even after this one is withdrawn.nulluntil stated.ota_code— the OTA equipment code the supplier states for the accessory (such as7, or9.EQP).nulluntil stated. It is the supplier's statement and is not checked against any code list, so use it as a hint for your own mapping; the supplier can correct it, and two accessories may share one.- Neither is
code, which still joins a charged line to this record.
What to do: nothing, unless you map accessories by code. If you do, key your table on supplier_code and read ota_code as a hint (contract §2.3).
1.58.0 — 2026-10-06 — SOLD-OUT CLOSURES: the days a group has no unit left, on the stop-sales read
Additive. GET /v1/reference/stop-sales now also lists the days a vehicle group you can sell has no unit left, so you can stop offering it on those days without a search per date. Nothing you already read changes.
- A new
source: rows withsource: sold_outjoin themanualones, in the same shape (vehicle_id,stations,start,end). Consecutive sold-out days are one row, and a day already listed as a stop is not listed twice.sourcewas always documented as growing: an unknown value is a closure of unknown cause. - The measure is the engine's own, per day: every active model of the group is booked out, stopped, or withheld from partners. It is computed for
meta.rules_by_supplier[*].sold_out_horizon_days(a new member; 400) days from today, on the operator's clock, throughsold_out_through(another new member): a run ending there may continue beyond it. - As of
meta.as_of, a new rental that covers a sold-out day is refused by the quote and the booking, so a row never closes a rental the quote would accept at that moment. A modification of a booking you already hold, and a cancellation since your read, are the exceptions. A rental can still be refused where every day has a unit but no single car is free for the whole span, and bookings the supplier has not assigned to a car are not counted. The quote decides. - For partners only: the supplier's own website keeps selling its fleet.
What to do: nothing. If you hold the stop-sales table, replace it per supplier as before (contract §3.13; guide §2.1d).
1.57.0 — 2026-10-06 — STOP SALES: read the days a vehicle group is closed, as a table you replace
Additive. A new read, GET /v1/reference/stop-sales, returns the stop sales that close a vehicle group you can sell, so you can stop offering it on those days without a search per date. Nothing you already read changes.
- A row is one closure of one group:
vehicle_id,stations({ "all": true }— a stop closes the whole group at every station),start,end(inclusive dates) andsource(manualtoday). A rental is refused because of a row when its pickup date is on or beforeendand its dropoff date is afterstart. - It is a list of closures, not of availability. A day that is not listed can still be refused (fleet occupancy, lead time, rental length, out-of-hours, delivery, movement rules); the quote decides. A group is published as closed over the days on which every active model in it is stopped, which never closes a rental the quote would accept (as of
meta.as_of) and can miss a closure it enforces. - A table you replace, not a feed you upsert, exactly as the rate card (1.56.0): a lifted stop leaves by disappearing. The suppliers read are the keys of
meta.rules_by_supplier; a supplier named inmeta.suppliers_unavailablewas not read, so keep what you hold for it. It takes no parameter (all refused422) and is conditional like the other catalogues. - Discovery: the capabilities feature
stop_sales_read.
What to do: nothing, unless you want the table. If you do, replace your stop sales per supplier from each response (contract §3.13; guide §2.1d).
1.56.0 — 2026-10-06 — THE RATE CARD: read a supplier's prices as a table, held to what a quote charges
Additive. A new read, GET /v1/reference/rates, returns the rate card a supplier sells from, so you can show and compare prices without a search for every date. Nothing you already read changes.
- A row is one vehicle group, rate period, plan and set of stations: the total for 1 to 7 rental days (
totals["1"]…totals["7"]), the price of each day after the seventh (extra_day; a stay of N ≥ 7 days isN × extra_day), the stations it is the price at, and the plan's protection (protection, with its excess, deposit and theft excess). A length the supplier has no price for isnull— never €0 — and cannot be quoted. - It is a table you replace, not a feed you upsert. An absence is a deletion on this read, per
supplier_idand per the slice you asked for (meta.collection, with theplananddistribution_channelechoed asscope), except for a supplier named inmeta.suppliers_unavailable— which includes one that does not price the channel you named. It takesplananddistribution_channel; the paging and*_sinceparameters are refused422. - How to turn a row into a total is stated per supplier in
meta.pricing_by_supplier(billing day and grace, the supplier's rental-length limits, how a stay across two rate periods is priced, tax, and what a row leaves out: extras and fees). - It is a price, not availability. A rate says what a rental costs if the vehicle is free;
POST /v1/quoteis the figure you book on. - Discovery: the capabilities feature
rates_read. The read is conditional like the other catalogues (1.55.0).
What to do: nothing, unless you want the table. If you do, replace your rates per supplier from each response (contract §3.12; guide §2.1c).
1.55.0 — 2026-10-06 — CONDITIONAL REFRESH: an unchanged catalogue answers 304 Not Modified, with no body
Additive. If you re-read a catalogue on a timer you no longer have to take the whole page each time: the full-refresh reads now carry an ETag, and a request that sends it back as If-None-Match is answered 304 Not Modified while the page is unchanged. A request that sends no If-None-Match is answered exactly as before, member for member; the only differences are in response headers: ETag and Cache-Control are added and Vary gains two names.
- Which reads:
GET /v1/reference/locations,/vehicles,/extras,/protections,/fees,/termsand/constants. Not/policies(keyset-paged) and not the live pricing reads. - The headers:
ETag: W/"…"andCache-Control: private, no-cacheon every200and304;Varynow also namesX-API-KeyandX-Supplier-Id. - The tag follows the content, not the clock.
request_id,meta.as_of,meta.changed_as_ofand the per-rowupdated_atare the instant of the read and are not part of it;row_updated_atand everything else are. A supplier withheld from an account-key read (meta.suppliers_unavailable), or one that is read but contributes no rows, changes the tag, so a partial page is never confirmed as the complete one. - A
304keeps your copy and your watermarks, and is charged like a200. It saves the body, not the call. Itsrequest_idandas_ofare those of the read you stored; quote theX-Request-Idheader of the304to support. - Discovery: the capabilities feature
conditional_get. - The contract's sentence "all are cacheable" is corrected: the catalogue reads vary by credential and are
private, no-cache.
What to do: nothing, unless you poll. If you do, store the ETag beside each page and send it back as If-None-Match (contract §1.10a; guide §2.1b).
Sandbox test channels — 2026-10-02 — TEST YOUR DOWNSTREAM CHANNELS IN THE SANDBOX: register your own test channel codes on your account page (no /v1 change)
Nothing under /v1 changes. Until now a sandbox account's distribution-channel policy was off, and nothing
could change it, so naming a channel in the sandbox was always refused not_permitted. Your sandbox account page
now has a Test distribution channels section, where you can:
- register your own test channel codes — each approved at once, in the sandbox only, at every sandbox supplier;
- deactivate a code, and register it again to approve it again — or to rename it;
- set your policy (
off·optional·required) and its enforcement (refuse·flag).
Your first code at a supplier turns channel codes on there (optional, refuse). Every rule of §2.9 then applies
exactly as in production — the same verdicts, echoes, issues and errors — so what you test is what you get. Per
supplier you can hold at most 20 of your test channels that are not deactivated, and register at most 100 codes
over your account's life: a code is never deleted, so to reuse one, register it again. Nothing changes in
production: there the operator registers your channels and approves each one in writing.
What to do: nothing, unless you sell through downstream channels. If you do, register your codes on your account page, then send them on the search, the quote, the booking and the cancel.
Sandbox use — 2026-10-02 — INVENTED TEST DATA ONLY: a real customer's data never goes into the sandbox (no /v1 change)
Nothing in this section changes /v1 or production. It states a rule for how the sandbox is used. This is
not a release of the surface, so it carries none of the three labels above, and no member, code, shape or
version moves.
- The rule. Every name, e-mail address, phone number and address you send to the sandbox must be made up.
A real customer's data must never be sent there — a real booking you would like to "try first" and traffic
replayed from production included. The contract states it in §10 of
CHANNEL-API.md, andINTEGRATION-GUIDE.md§1.1a shows how to follow it. - Data that looks real may be flagged for review. A flag is a note for our review; it never blocks a request or a test run.
- Nothing you create in the sandbox is kept for you. The bookings and quotes you make there may be reset at any time, without notice.
- What to do. Nothing, if your sandbox tests already use invented customers: the examples in this pack use
invented customers with addresses on
example.com. If any of yours use a real person's details, replace them. - Production is unchanged. This is about the sandbox only.
2026-10-02 — DOC CORRECTIONS: statements in the guides that disagreed with the wire, and the channel rules the guide did not carry — no wire change
No code, status or member changed; the guides did.
- "Any parameter or field not listed is
422" is now true of every list that says it. The search table omittedcross_border,distribution_channeland the place parameters (pickup_iata,pickup_citywithpickup_country_iso2, and the dropoff twins); the quote list omittedcross_borderanddistribution_channeland namedpayment_model, which a quote does not take; the booking lists omitteddistribution_channelandmode, and the direct listcross_border; the modify list omitteddistribution_channel; the settlements parameters omittedpage_tokenandupdated_since. totals.grand_totalandexpected_priceare the supplier's price, not what you show or charge your own customer. Whatever you add for your own customers or channels is yours, and is not on this API. The guide's section 3.3 now says whose price it is, and wherepayment.settlement_basisstates how the total is settled; the descriptions ofexpected_priceand of the quote operation in the contract's schemas say the same, and so does the 1.54.0 entry below, which said the figure was "the one to charge". Descriptions only: no member, status or version moved.- The guide carries the downstream-channel rules that 1.51.0 and 1.54.0 published only here and in the
contract: where the channel goes on each call, what each policy and enforcement does, how a quote and its
booking stay together, that a
planis looked up in the set the channel selects, a search by place, and that a sandbox account's policy isoff(INTEGRATION-GUIDE.mdsection 1.7). - Rate limits are stated as set in your agreement and onboarding, with no figure;
X-RateLimit-Limitreports the burst capacity. - The Quickstart's dates move with you (pickup 30 days out) instead of naming a date that would pass, and it
states the sandbox key's 90-day life, the 14-day window to reveal an approved key, and that a portal key carries
no
read:settlements. - The error catalogue's entry for
distribution_channel_not_approvedsays it is raised on a search and a quote as well as on a booking, as it has been since 1.54.0.
GET /health — 2026-10-01 — A HOST LEAVING SERVICE SAYS SO: ok: false also means "do not send this host work" (no /v1 change)
Nothing under /v1 changes. GET /health keeps its three members, its two statuses and its body shapes. What
ok: false can mean is wider, and the contract now says so in its three places (the operation, its 503, and
HealthStatus.ok) and in INTEGRATION-GUIDE.md §13. /health is not versioned, so this carries none of the three
labels above and does not wait for your pinned version.
- Before:
ok: false, served503, meant one thing: the host that answered cannot reach its own database. - Now it can also mean that the host is being taken out of service. That is how a host tells the load balancer in front of it to stop sending it work, and the balancer then routes requests to a host that is in service.
- What to do is unchanged: retry, and if it persists, §13. It still says nothing about your account, your key or any route.
1.54.0 — 2026-10-01 — CHANNEL-AWARE PRICING: one key, the channel on every core request, a price per channel
Additive. A partner that sells through several downstream channels no longer needs a key per channel: name
the channel on the search, the quote, the booking and the cancel, and each is priced for that channel. Nothing
changes for a request that names no channel, and nothing changes at all for a partner whose policy is off.
distribution_channel=<code>onGET /v1/availabilityanddistribution_channel: { "code": … }onPOST /v1/quote— judged exactly as on a booking: refused under policyoff(422 validation_error, issuenot_permitted), required underrequired(issuerequired), and a channel the register does not approve for today is422 distribution_channel_not_approved(or, under enforcementflag, served and echoed with its verdict).- The price is the channel's. Rows and quotes are priced from the operator's prices for that channel where it keeps separate ones, else from your account's own; the figure you receive is the supplier's price for that request.
- The echo:
meta.distribution_channelon the search page (and on each supplier's leg of a search by place) anddistribution_channelon the quote — the Booking member's shape, present only when you named one; a quote replay echoes what the mint recorded. - A quote is bound to its price. A booking made from a quote that named a channel must name the same channel.
A quote that named none was priced from your account's own prices, so a booking made from it may still name a
channel priced from them too (one the operator keeps no separate prices for), exactly as in 1.51.0, but not one it
prices separately. Anything else is
422 validation_error, issuequote_channel_mismatch(withquote_distribution_channel), and nothing is written. - The cancel takes
distribution_channelas an assertion: the booking's own channel is accepted, another is422 validation_error, issuebooking_channel_mismatch; omitting it is always fine. - A search by place judges the channel per supplier: a supplier that refuses it is listed in
meta.supplierswith no searches anddistribution_channel_refusal; when every supplier with a desk at the place refuses it, the search is refused (a supplier with no desk there answers nothing and does not count).
What to do: nothing, unless you sell through several channels under one agreement. If you do, send your channel code on each search, quote, booking and cancel, and book a quote with the channel you quoted it for.
Moving a supplier into your account — 2026-10-01 — ONE KEY FOR EVERY SUPPLIER: a held write answers 409 idempotency_in_progress (no member, code or shape changes)
No member, code or shape changes. A documented, retryable answer gains a second cause, and the error catalogue's retry line for it now says what the retry policy always said. This is not a release of the surface, so it carries none of the three labels above, and it does not wait for your pinned version: a move can happen at any version.
- A supplier can move your relationship with it into your account: the move that lets one key reach all your
suppliers. While it runs, every write to that relationship is held:
POST /v1/quote,POST /v1/bookings,PATCH /v1/bookings/{ref},POST /v1/bookings/{ref}/modify,POST /v1/bookings/{ref}/cancelandPOST /v1/webhooks/redeliver. A held write answers409 idempotency_in_progresswithRetry-After, under anyIdempotency-Key, including one you never sent. Reads are not held, and nothing changes at your other suppliers. - Retry it as the retry policy says: the same key and body, after
Retry-After, with backoff. It runs once the move completes. The catalogue told you to retry this code once; it now says to keep retrying with backoff while it persists, asERRORS.md's retry policy andCHANNEL-API.md§1.8 always did. - A held write's
messagestill names the first cause ("still executing"). Branch oncode, as everywhere.
1.53.0 — 2026-10-01 — OVERLOAD ANSWERS 503 WITH RETRY-AFTER: never 401 for a valid key, never 500 for a busy moment
Additive. No member, code or status is added or removed. 503 service_unavailable and its
Retry-After have been in the contract since it was published, as "maintenance or overload". What
changed is that an overload now receives them.
- A valid key is never answered
401because the service is busy. Under load, one of the checks this API makes while it authenticates a key could time out, and the request was answered401 unauthenticated— "Invalid API key." — which §1.8 tells you never to retry. It is now503 service_unavailablewithRetry-After. A401is always about the key. - Overload is
503, not500. A request the service could not serve right now — every database connection busy, a database failover in progress, a statement it could not answer in time — was answered500 internal_error, with nothing to pace a retry by. It is now503 service_unavailablewithRetry-After, a few whole seconds.500stays what it says: a fault on our side. - A
503is never stored as a write's outcome. After a503, a retry under the sameIdempotency-Keyis a real attempt — it books, or it replays an attempt that did land — never a replayed503. - Every authenticated operation in the OpenAPI document now declares
503, withRetry-After; it was declared on the writes only. Regenerate your client to pick the response up. - The retry policy is stated exactly (contract §1.8,
ERRORS.md): retry only429,5xx, network errors and409 idempotency_in_progress, with the sameIdempotency-Keyon a write; wait at leastRetry-Afterwhen the response carries it, otherwise back off exponentially with full jitter — a random wait up to 0.5 s × 2ⁿ before retry n, capped at 8 s.walkthrough.mjsfollows it now, and treats409 idempotency_in_progressas "not answered yet" rather than as a refusal. - Certification records the policy as one more attested box,
errors.transient_retry_policy(CERTIFICATION.md).
What to do: if your client already retries 5xx as §1.8 says, nothing — it now gets
Retry-After to pace by. If it treats a 401 as a key that no longer works, that stays right: a
401 now only ever means the key.
1.51.0 — 2026-09-30 — DOWNSTREAM DISTRIBUTION CHANNELS: name the channel a booking was sold through, and read which ones your agreement approves
Additive. A partner that resells through downstream channels of its own — a comparison site, an agency,
another reseller — can name the channel on each booking and read the operator's register of the channels its
agreement approves. Nothing changes for a partner whose policy is off, which is every partner's default.
GET /v1/distribution-channels— the downstream channels the operator has registered for your account:code,name,status(proposed·approved·suspended·deactivated),markets,effective_from,effective_until, andapproved_atwhile approved. Paged and in-loop like the reference lists (page_token,updated_since,meta.as_of); an entry is never removed, so a channel you may no longer sell through is one whosestatussays so. A key and no scope, likeGET /v1/whoami.distribution_channelsonGET /v1/whoami, in the summary and on each book: yourpolicy(off·optional·required) andenforcement(refuse·flag).distribution_channel: { "code": … }onPOST /v1/bookings(both shapes) and on a modify, wherenullremoves it. Under policyoffit is refused422 validation_error(issuenot_permitted); underrequireda booking without one is refused (issuerequired). A code the register does not approve for today is refused422 distribution_channel_not_approved(issueunregistered,not_approvedornot_in_effect) — or, under enforcementflag, accepted and recorded with the register's verdict. Nothing is written when it is refused.distribution_channelon the booking — the create response, the read, the list and every webhook payload:distribution_channel_id,codeandapproval(the register's verdict when it was recorded). Present only when one is recorded; a booking without one is byte-identical to before.- Settlement lines carry
distribution_channel(nullable): the channel recorded on the line's booking now. - A retry with the same
Idempotency-Keyreplays the original booking, even if its channel was suspended since. A modify judges the channel only when you change it: sending back the recorded code, or omitting the member, never re-judges a booking already made.
What to do: nothing, unless the operator tells you your agreement needs channel attribution. If it does,
read your policy on GET /v1/whoami, the codes on GET /v1/distribution-channels, and send
distribution_channel.code on each booking.
Sandbox key issuance — 2026-09-29 — ONE KEY FOR EVERY SUPPLIER: a sandbox approval issues one key that reaches both suppliers (no /v1 change)
Nothing in this section changes /v1 or production. It records how the sandbox issues keys from now on; every
call it relies on shipped in 1.45.0–1.50.0.
- An approved sandbox account gets ONE key, and it reaches both sandbox suppliers.
GET /v1/supplierswith it lists both, each with itssupplier_id; every list answers for both; a call that names an id goes to that id's supplier by itself;X-Supplier-Idnarrows a call to one. It is revealed once in the portal, as before. - Once your account holds such a key, the portal issues that kind only — at most 2 live for the account, however many suppliers it reaches — and lists and revokes them on your account page.
- An account approved before this change keeps its keys, one per supplier, and the portal keeps issuing that kind for it; everything in this pack works with each of them.
- Production is unchanged: a production key reaches the supplier relationship it was issued for.
1.50.0 — 2026-09-29 — ONE KEY, ITS OWN CREDENTIALS: an account key lists, rotates and retires its account's keys
Additive. /v1/credentials serves an ACCOUNT key — one key that reaches every supplier your account holds. A key
that reaches one supplier receives exactly what it received before, byte for byte.
GET /v1/credentialslists your account's keys, each markedkind: "account". They are account keys only; a legacy key's list is still that supplier's keys.POST /v1/credentials/rotateissues a successor to the account key that signed the request, for every supplier at once, with the same overlap window and the same one-time reveal as before. One rotation may be open per account.POST /v1/credentials/{key_id}/revokeretires that key's own predecessor, for every supplier at once. A key still cannot revoke itself, and every refusal is the code you already handle.
What to do: nothing, unless you hold an account key. If you do, rotate and retire it exactly as you would a legacy key; the rotation applies to all of your suppliers in one step.
1.49.0 — 2026-09-29 — ONE KEY, ONE REDELIVER: replay your deliveries from every supplier you reach in one request
Additive. POST /v1/webhooks/redeliver answers for every supplier an API key reaches when the request does not
send X-Supplier-Id. A key that reaches one supplier receives exactly what it received before, byte for byte.
- A window or an id list replays every supplier's deliveries in one request, under one
Idempotency-Keyand one answer. The newresultsarray names each supplier's outcome:queuedwith its own counts, orwebhooks_disabledwhen its delivery is off and it replayed nothing. The top-level counts are their sums, exceptnot_found, which counts an id once when no supplier holds it. - A sequence range needs
X-Supplier-Id. A sequence counts one supplier's stream, as in the delivery log since 1.46.0; without the header the answer is409 supplier_required. - The limits hold for the request as a whole. The per-request event cap applies across all suppliers. If any
supplier is inside its cooldown, busy with another replay, or over its in-flight bound, nothing is queued anywhere,
and the
429'sRetry-Afteris the longest wait. When every supplier's delivery is off, the answer is409 webhooks_disabled, as for one. - One key, one request. A key used for such a replay, sent again to one supplier with
X-Supplier-Id, is422 idempotency_key_reused— and the other way round. - Corrected: the document listed
idempotency_key_reusedon this endpoint as a409. It has always been a422on the wire; the409s arewebhooks_disabled,idempotency_in_progressand nowsupplier_required.
What to do: nothing, unless you want to be ready for a key that reaches several suppliers. If you do: read
results after a replay, and send X-Supplier-Id with a sequence range.
1.48.0 — 2026-09-29 — ONE KEY, ONE WEBHOOK REGISTRATION: register your endpoint, rotate your secret and test once for every supplier you reach
Additive. The webhook self-service routes answer for every supplier an API key reaches when the request
does not send X-Supplier-Id, as WEBHOOKS.md §7 has promised since 1.42.0. A key that reaches one supplier
receives exactly what it received before, byte for byte.
GET /v1/webhooks/endpointanswers for all of your suppliers. Each member is the value they share: a flag istrueonly when it is true for every supplier, and a value they do not all hold isnull. The newsuppliersarray carries each supplier's own configuration, and it is the record to trust. This read is never refused for a key that reaches several suppliers. When a supplier could not be read,meta.suppliers_unavailablenames it, and every shared flag is thenfalseand every shared valuenull.PUT /v1/webhooks/endpointsets one URL for every supplier.resultsnames each supplier's outcome:updated,not_found(no webhook registration yet) orunavailable(not reached, not written). The write is idempotent: repeat it until every supplier readsupdated. When no supplier could be written because one could not be reached, the answer is503 service_unavailablewith each supplier's outcome inerror.details; ERRORS.md indexes eachissue.outcome_unknownmeans the write may have taken effect: read before you act on it.- Two such writes for one account never interleave. Each is applied to all of your suppliers in one step, and a second waits for the first.
POST /v1/webhooks/secret/rotateissues ONE new secret for every supplier. Each supplier keeps signing with its own previous secret during the overlap, so your receiver installs one new secret and nothing it already accepts stops working.resultsnames each supplier's outcome:rotated,no_webhook_secretornot_found, and a supplier that was not rotated keeps the secret it had. A rotation is one secret for every supplier or for none: if a supplier cannot be reached, none is rotated, and the answer is503 service_unavailablenaming each one (not_rotatedfor a supplier the undo left as it was). If its outcome could not be confirmed, each readsoutcome_unknown: readGET /v1/webhooks/endpointbefore you rotate again.POST /v1/webhooks/testsends one test event per supplier, each signed with that supplier's secret, andresultslists them. On such an answer the top-levelevent_idanddelivered_toarenull: the schema now acceptsnullfor both, and no key that exists today can receive it. When every supplier is refused for the same reason, that refusal is the answer, exactly as it is for one supplier.POST /v1/webhooks/redeliverfollows in the next release. Until then such a key names the supplier to replay withX-Supplier-Id.
What to do: nothing, unless you want to be ready for a key that reaches several suppliers. If you do: read
suppliers rather than assuming one configuration, and check results after a write.
1.47.0 — 2026-09-28 — ONE KEY, SEARCH BY PLACE: search an airport or a city across every supplier you reach
Additive. GET /v1/availability can name a PLACE instead of a desk, and searches every desk there of every
supplier your API key reaches. GET /v1/reference/locations narrows to the same desks. A search by
pickup_location_id is unchanged, byte for byte.
pickup_iata(an airport), orpickup_citywithpickup_country_iso2, instead ofpickup_location_id/dropoff_location_id. The two forms cannot be combined, and a city always needs its country.pickup_location_idanddropoff_location_idare now required only when no place is named.- Each supplier's desks at the place are searched, each returning to itself. Add
dropoff_iata, ordropoff_citywithdropoff_country_iso2, to return somewhere else: each pickup desk is then paired with every desk of the same supplier at the dropoff place. A pair never spans two suppliers. - Every row names the desks it is for —
pickup_location_idanddropoff_location_idon the row, the values to send toPOST /v1/quote. Rows stay grouped by supplier, insupplier_idorder, and are never re-sorted by price across suppliers, whose currencies differ. meta.supplierslists every supplier searched, each withsearches[]: one entry per desk pair, carrying that pair's ownmetaexactly as a search of those two desks bylocation_idreturns it. A supplier with no desk at the place is listed with no searches.meta.as_ofis the earliest of them.meta.suppliers_unavailablenames a supplier that could not be searched; search again to include it.- At most 12 desk pairs are searched per request. A place that names more is refused
422with issueselector_too_broadand how to narrow it (an airport code rather than a city, or one desk bypickup_location_id) — never served in part. GET /v1/reference/locationstakesiata,cityandcountry_iso2, each narrowing the list, by the same rule a search picks its desks with: the filtered list is the set of desks a search at that place prices. A filtered page is narrowed — a desk absent from it is not withdrawn.- Sandbox: supplier A's desks now state
iata,geoandaddress.country(its airport desk isZZS), so both sandbox suppliers can be searched by place. Additive under the sandbox catalogue's rule: members appeared, and nothing was renamed, removed or re-priced.
What to do: nothing, unless you want to search by place. If you do: send pickup_iata (or city + country),
read each row's pickup_location_id / dropoff_location_id, and quote with those.
1.46.0 — 2026-09-28 — ONE KEY, MERGED PAGES: the paged lists answer for every supplier you reach, with a page token that keeps each supplier's place
Additive. Four paged lists answer for every supplier an API key reaches, instead of refusing
409 supplier_required when the key reaches more than one: GET /v1/bookings, GET /v1/settlements,
GET /v1/webhooks/deliveries and GET /v1/reference/policies. A key that reaches one supplier receives exactly
what it received before, byte for byte, page tokens included. This completes 1.45.0, which merged the reads that
have no page token.
- One walk, one order. Each page holds the next rows across all your suppliers in the list's own order
(bookings and policies by
updated_at, settlements by period, newest first, deliveries byoccurred_at, newest first). Every row carries thesupplier_idit belongs to, as it has since 1.36.0. - The page token keeps each supplier's place, and stays the same size however many suppliers your key reaches.
Pass
meta.next_pageback verbatim, as before. A token from a walk across several suppliers and a token from a single-supplier walk are not interchangeable: presenting one where the other is expected — for example after addingX-Supplier-Idpart-way through a walk — is refused422withmalformed_page_token, like any token that is not this endpoint's, and so is a token from another API key's walk. Start that walk again from its first page. meta.as_of(bookings, settlements, policies) is the earliest across your suppliers, as on the reference catalogue, andmeta.as_of_by_suppliershows each supplier's own, for diagnosis. OnGET /v1/settlementsthe whole walk keeps the value its first page minted, as it always has, andmeta.as_of_by_supplieris on that first page.- A supplier your key begins to reach during a walk is read from the walk's next page on, and every
meta.as_offrom then until the walk ends is set back to where the walk began — theupdated_sinceyou sent, or the beginning of time — so replaying it picks up whatever of that supplier's the walk had already passed. The same happens when more than 32 suppliers cannot be read at once. The delivery log has no watermark: its next walk lists everything. meta.suppliers_unavailable— present when a supplier could not be read. Its rows are withheld from that page and its place in the walk is kept: if the walk goes on, they arrive on a later page once it answers, and none is skipped. If the walk ENDS while a supplier is withheld,meta.as_ofis set back far enough that replaying it as your nextupdated_sincere-reads that supplier rather than passing it by. The delivery log has no watermark, so there: re-run the walk later to pick up a withheld supplier's deliveries. 🚨 As on the catalogue, a page carrying this member is never evidence that anything was removed.sequence_from/sequence_toonGET /v1/webhooks/deliveriescount ONE supplier's delivery stream. A key that reaches several suppliers names one withX-Supplier-Idwhen it sends them; without it the request is refused409 supplier_required. Every other filter works across suppliers.X-Supplier-Idstays optional on every call: a sequence number is the one value that belongs to a single supplier's stream, so this is the one filter that needs the header.
What to do: nothing, unless you want to be ready for a key that reaches several suppliers. If you do: key every
row you store on supplier_id, treat next_page as opaque (it already was), and when a page names
suppliers_unavailable, keep walking or retry rather than concluding anything from that supplier's absence.
1.45.0 — 2026-09-28 — ONE KEY, MERGED READS: GET /v1/suppliers lists every supplier you reach, and whoami gains books[]
Additive. Two routes answer for every supplier an API key reaches instead of refusing when the key
reaches more than one. A key that reaches one supplier receives exactly what it received before, byte for
byte: one entry on GET /v1/suppliers, and the same GET /v1/whoami members with one new array beside them.
Why this exists. 1.43.0 gave a key that reaches several suppliers a way to pick one per request, and
refused 409 supplier_required when a request named none. A read does not have to pick: it can answer for
all of them. This release does that for the two account-describing routes and the reference catalogue;
the paged lists (/v1/bookings, /v1/settlements, /v1/webhooks/deliveries, /v1/reference/policies)
follow in the next release, because a page token has to keep every supplier's place.
GET /v1/suppliers— one entry per supplier you reach, ordered bysupplier_idand stable between calls. Unchanged for a key that reaches one supplier. It was always an array; now it can hold more than one.GET /v1/whoamigainsbooks[]— one entry per supplier, each carrying that supplier'sopt_ins,per_broker_policies,webhooks,currencyandapi_version. This array is the authoritative answer for anything that can differ between suppliers. Key it onsupplier_id.- The members above
books[]describe your KEY, and are a safe summary. With several suppliers: a boolean opt-in istrueonly where it is true for every supplier,webhooks.enabledlikewise,webhooks.first_enabled_atis the earliest, andper_broker_policieslists only the keys in force with every supplier. Under-reporting, never over-reporting: act on the summary and you are right everywhere. - Three members are
nullwhen your suppliers disagree:attribution_token(it names one supplier),currency, andapi_version.pinned. Readbooks[]for the real value of each. They are never null for a key that reaches one supplier, which is every key today. - The reference catalogue answers for every supplier you reach —
/v1/reference/vehicles,/extras,/protections,/fees,/locationsand/termsreturn the union of your suppliers' records in one page, each record carrying thesupplier_idit belongs to (it has carried that since 1.36.0). Unchanged for a key that reaches one supplier.meta.as_ofandmeta.changed_as_ofbecome the earliest across the suppliers read — replay those, never a per-supplier value — andmeta.as_of_by_supplier/meta.changed_as_of_by_suppliershow each supplier's own, for diagnosis. meta.suppliers_unavailable— new on the six catalogues above,GET /v1/suppliersandGET /v1/whoami, present only when one supplier could not be read while others could. That supplier is withheld rather than failing your whole request; what you did get is complete. Retry to pick up the rest. If nothing could be read at all you get an error, never an empty success. OnGET /v1/whoamithe withheld supplier has nobooks[]entry, and onGET /v1/suppliersit has no entry indata: in both, it is unread, not withdrawn. 🚨 A page carrying this member is PARTIAL, and is never evidence that anything was removed. If your sync deletes records a full refresh did not return, skip that step whenever this member is present — the named supplier's records are missing because it was not read, not because it withdrew them.X-API-Versionon a request that names no supplier must agree with every supplier's pin, or it is refused409 version_mismatchas before. Sending1, or omitting it, agrees with everything.- Documented, not changed: the
metaofGET /v1/suppliersnow declares thenext_page(alwaysnull) andas_ofit has carried since 1.36.0, besidesuppliers_unavailableandas_of_by_supplier. The paged lists (/v1/bookings,/v1/settlements,/v1/webhooks/deliveries,/v1/reference/policies) declare none of the merged members yet: they gain them when they start answering for every supplier.
What to do: nothing, unless you want to be ready for a key that reaches several suppliers. If you do:
read books[] instead of the top-level opt_ins, currency and attribution_token, and treat those three
top-level members as nullable.
1.44.0 — 2026-09-22 — DOCUMENTED: supplier_id on the webhook configuration object, and the delivery-routing rule said once
Additive. One member the wire has carried since 1.41.0 is now in the machine-readable document, and one instruction that two documents gave differently is now given once. Nothing you send or parse changes, beyond the version members moving as they do on every release; a client that tolerates unknown members (§1.2) was never affected.
supplier_idon the webhook configuration object — the answer toGET /v1/webhooks/endpointandPUT /v1/webhooks/endpoint, and theendpointmember ofPOST /v1/webhooks/secret/rotate's answer. It is the supplier whose webhook configuration you are reading or writing: the same opaque valueGET /v1/suppliersreturns. The wire has carried it since 1.41.0 and that entry announced it; the OpenAPI document'sWebhookEndpointConfigschema did not list it, so a client generated from the document typed the object without the member. It is published optional, as every member added to an existing response is (CHANNEL-API.md§4.1, the rule for a response'srequiredset), although it is present on every answer. Regenerate fromGET /v1/openapi.jsonif you want it typed; a client that tolerates unknown members needs nothing.- Which envelope member you group deliveries on, said once.
WEBHOOKS.md§3b has said since 2026-09-01 to group onoperator_id; the 1.41.0 entry below andCHANNEL-API.md§2.0 said to route onsupplier_id. Both now say the same thing: group onoperator_id, which is on every delivery and names the streamsequenceis counted on;supplier_idis the label of that same stream — store it beside the counter. It is optional and nevernull: absent only on a delivery whose supplier label has not been synced yet. A delivery without it is filed byoperator_id— never rejected, never counted as a sequence gap. A receiver keyed on(operator_id, sequence)needs no change; one keyed onsupplier_idalone should fall back tooperator_idwhen the member is absent. - What you should do: regenerate if you want the member typed; otherwise nothing.
Certification with an operator-issued key — 2026-09-22 — the run is HOSTED on your sandbox account (no /v1 change)
Nothing in this section changes /v1 or production. It records how a certification run is started for a sandbox
key the operator issued.
- Your run is hosted on your own sandbox account, like a self-serve account's. Until now an operator-issued key was
certified through a separate run address we sent you for the session. Now the operator starts a hosted run on your
account and tells you it is running: your base URL does not change, and you drive the run with the same two control
calls —
GET https://sandbox-api.caravelway.com/_certification/statusandPOST https://sandbox-api.caravelway.com/_certification/advance— with your own key. A hosted run must be completed within 24 hours; a restarted run starts over from the beginning. - Nothing you send or parse changes. The phases, the scoring and the report are the ones
CERTIFICATION.mdalready describes; the report reaches the operator, who reviews and signs off exactly as before. POST https://sandbox-api.caravelway.com/_certification/startis still refused for an operator-issued key (403 forbidden): the operator starts your run.GET /_certification/statusandPOST /_certification/advanceon such a key answered403before this date; they now answer for your run, and404 not_foundwith a message saying who starts one while there is none.- A staffed session, on request, is unchanged: we run it through a separate address and send you that address.
Sandbox key issuance — 2026-09-17 — SANDBOX KEYS ARE ISSUED ON APPROVAL, and API mail comes from a dedicated API address (no /v1 change)
Nothing in this section changes /v1 or production. It records how self-serve sandbox keys are issued
and who partner mail is sent from.
- Confirming your email now REQUESTS sandbox access; it no longer issues a key. We review each request and email
you the decision. When it is approved, sign in at
https://sandbox-api.caravelway.com/portal/loginand reveal your keys — one per sandbox supplier — from your account page. They are shown once, they are never sent by email, and an approval not revealed within 14 days lapses (its keys are revoked; request access again). After that, issuing, rotating and revoking keys work exactly as before. - Keys you already hold are unchanged. An account that received keys before this date keeps them and keeps issuing new ones from its account page.
- Mail about the API now comes from a dedicated API sender address, and replies go to the API team rather than a booking desk: sign-in links, the access-request receipt and decision, and production access mails. If you filter or allow-list sender addresses, add the address the next such mail arrives from.
- The portal's verify response changed shape.
POST https://sandbox-api.caravelway.com/portal/verifyno longer carries a key:key,key_prefix,key_expires_at,scopes,partner_id,partner_name,key_noteandprovisioning_noteare gone, andkeysis always empty. It now carrieskey_issuance: "on_approval"and anaccessobject (request_id,status,requested_at,created). A script that readkeyfrom that response must instead sign in and reveal the keys:POST https://sandbox-api.caravelway.com/portal/account/access/revealreturns them once, in the samekeysshape the verify response used to. These are portal routes, not/v1. GET https://sandbox-api.caravelway.com/portal/statusgainskey_issuance: "on_approval"(additive).
1.43.0 — 2026-09-16 — ONE KEY, SELECTED: supplier_mismatch, and the two supplier codes reserved in 1.42.0 are returned
Additive. One new error code, and two codes published as reserved in 1.42.0 now have a sender. All three are returned only to an API key that reaches more than one supplier — a kind of key no integration holds today. A key that reaches one supplier receives none of them, and nothing it sends or parses has changed.
Why this exists. 1.42.0 stated the rules a key reaching several suppliers would follow. This release implements how such a key's request picks its supplier, and names the three ways that can fail.
422 supplier_mismatch— new. The ids in one request belong to more than one of your suppliers: for example a pickup station from one supplier and a vehicle from another. A request is executed by exactly one supplier. Take every id from the same supplier's reference data and sendX-Supplier-Idnaming it.409 supplier_required— now returned. A request that needs one supplier named none: noX-Supplier-Id, and no id that belongs to exactly one of your suppliers. Its catalogue meaning is widened from the webhook-configuration case 1.42.0 described to every such request.409 ambiguous_ref— now returned, with the meaning 1.42.0 published: anexternal_refin the path names a booking at more than one of your suppliers.- How a supplier is chosen, for a key that reaches several:
X-Supplier-Idwhen you send it; otherwise an id in the request that belongs to one supplier — abooking_id, a quote's stations and vehicle, a termsdocument_id; otherwise, for anexternal_refor asettlement_id, the one supplier that holds it. An id that belongs to none of your suppliers selects nothing and is reported by the endpoint exactly as it is today. 422 idempotency_key_reused— its catalogue meaning is widened: besides a different body, it is now also returned when an account whose credentials reach more than one supplier reuses anIdempotency-Keywith a different supplier. A key identifies one request to one supplier.- What you should do: a client that treats an unrecognised
codeas an unknown error of its status (§1.2) needs no change.
1.42.0 — 2026-09-13 — BROKER READINESS: supplier_id on policies and terms, external_ref resolves across your suppliers, and the shared-key rules stated
Additive. Two new optional response members on two objects (present on every row), nine supplier_id
members re-published as optional (present on every row, no wire byte changes), two reserved error codes,
and wording; nothing an earlier integration sent or parsed has been removed or reshaped. Nothing you have
built stops working, and nothing here requires a change from you today.
Why this exists. An investigation of the published contract asked one question of every surface: if a credential were granted a second supplier tomorrow, would a client built against today's documents have to change? Four places said yes. This release closes them, so the answer is no.
supplier_idonGET /v1/reference/policiesrows and on terms documents (GET /v1/reference/termsand/terms/{document_id}). Every other object you persist has carried it since 1.36.0; these two were missed. It is the same opaque value, stamped the same way, on every row; it is declared optional in the schema so a generated client does not have to regenerate.supplier_idis now published as OPTIONAL onLocation,Vehicle,Protection,Extra,Fee, theGET /v1/bookingssummary row,Settlement, the availability offer row and the quote response — the nine objects that existed before 1.36.0 and on which 1.36.0 had published it as required. Nothing on the wire changes: the member is on every row, exactly as before. The rule (§4 of the contract): a member added to a response that already exists is added optional, because a newly-required member forces a strictly generated client to regenerate.Supplier(born in 1.36.0) keeps it required — it is that record's own identity. No client had generated against the required form: nothing from 1.36.0 onward had been deployed when this shipped.external_refresolves across every supplier your key reaches, and you never reuse one across suppliers (§1.8). This supersedes the 1.41.0 advice to key your booking table on(supplier_id, external_ref)and to mint idempotency keys per supplier: that model would have made every{external_ref}lookup need a supplier selector on the day a key reached two suppliers — a change on your side. Under this releaseexternal_refalone stays a sufficient key,X-Supplier-Idstays optional forever, and a reused value is refused409 ambiguous_ref— reserved, never returned while a key reaches one supplier. What we enforce did not change: uniqueness is still per supplier's book.- The shared-key rules for
/v1/webhooks/*andGET /v1/whoami, stated now. A webhook write withoutX-Supplier-Idapplies to every supplier your key reaches; a read answers for the configuration they all share, and is refused409 supplier_required— reserved, never returned today — only if a write of yours made them diverge.whoamidescribes your key; per-supplier truth is onGET /v1/suppliers. - Wording. Every sentence that stated "a key reaches one supplier" as a property of keys now says
it is a fact about today; the duplicated paragraphs in §2.0 and §3.10 of the contract and in the
quickstart are gone;
sequenceis described per supplier stream; alocation_idsearch reaches one supplier's book; the ACRISS coherence rule is judged within a supplier.
What to do: nothing, unless you followed 1.41.0's (supplier_id, external_ref) advice — that
still works, and you may keep it; the only rule that binds you is the one both releases agree on: mint
a fresh external_ref per rental and never reuse one.
1.41.0 — 2026-09-13 — GRANT-PROOFING: supplier_id on the webhook envelope, and the money facts read per supplier
Additive. New optional members; nothing an earlier integration sent or parsed has been removed or reshaped. Nothing you have built stops working, and nothing here requires a change from you today.
Why this exists. One API key reaches exactly one supplier, so today you can infer the supplier
from which callback endpoint a delivery arrived at, and you can read your money facts off
GET /v1/whoami. Both inferences are true because of how credentials are granted right now — not
because of anything the contract promises. This release removes both, so that if a credential is ever
granted a second supplier, nothing changes on your side.
supplier_idon the webhook envelope. Every delivery now names the supplier it belongs to — the same opaque valueGET /v1/suppliersreturns and every/v1object already carries. Never key your handler on which endpoint the delivery arrived at. It is optional and nevernull: it is absent only on a delivery whose supplier label has not been synced yet, and a delivery is never withheld for lacking it. (Corrected in 1.44.0, above: this entry first said to key the handler onsupplier_id; group onoperator_id, which is on every delivery, and storesupplier_idbeside it as the stream's label.) 🔑sequenceis counted per supplier stream, sosupplier_idis the member that tells you which counter a gap is in.supplier_idonGET /v1/webhooks/deliveriesrows — the surface you read to resolve a gap — and on the webhook endpoint/secret config object, so "rotate my secret" is unambiguous the day you hold more than one stream.supplier_idon thewebhook.testevent too (POST /v1/webhooks/test, and the account page's inspector). The test event is what you prove your receiver with, so it carries the member the real stream carries — build and test one router, not two.external_refandIdempotency-Keyare stated as scoped PER SUPPLIER (§1.8). Nothing changed in how either behaves; what changed is that the contract now says so. Neither is globally unique — that is already true, another supplier's book may hold the sameexternal_reftoday — but because your key reaches one supplier you cannot currently observe the difference. Key your booking table on(supplier_id, external_ref)rather than onexternal_refalone, and mint idempotency keys per supplier — free now, a schema migration later.- Read
currency,tax_inclusiveandpayment_modelsfromGET /v1/suppliers, per supplier.GET /v1/whoamistill reports them and is not deprecated — it describes your key, and while a key reaches one supplier the two agree exactly. The supplier list is the surface keyed on the supplier itself, so it is the one that keeps agreeing.GET /v1/capabilities.currencyis, and always was, this host's own default — never yours.
What to do: if you store a currency or a tax convention, take it from GET /v1/suppliers next to
the supplier_id you already store. If you route webhooks, read supplier_id off the envelope. Both
are one-line changes today and neither is urgent; making them now is what buys you nothing to do later.
Sandbox fixture data — 2026-09-12 — A SECOND SANDBOX SUPPLIER, and one key per supplier (no surface change; sandbox only)
Nothing in this section changes /v1 or anything in production. It records a change to the
seeded fixture data in the sandbox environment, and a change to how sandbox keys are issued.
The sandbox now sells from TWO suppliers, and a sandbox account is issued one key per supplier.
1.36.0 made the contract multi-supplier; a sandbox with one supplier could not hold anyone to it,
because with one supplier a client that reads GET /v1/suppliers and a client that hardcodes its one
answer emit byte-identical requests. Now they do not.
- You get two keys. Each reaches exactly one supplier, so each still sees exactly one entry on
GET /v1/suppliers. Existing keys are unchanged and keep working; the second key is additional. Self-serve accounts see both on the portal account page, one table per supplier; the two-active-key cap is per supplier. - The two suppliers are deliberately unalike, so an assumption shows up as a failure: different
currency (EUR / GBP), different VAT rate (24% / 20%), a payment model stated by only one
of them (pay-on-arrival; the other stated none — corrected 2026-10-18: this line said prepaid /
pay-on-arrival. Both now permit every model; see Sandbox fixture data — 2026-10-16), different
timezone, different desk opening hours, one publishing station
iataandgeoand one publishing neither, and one carrying seasonal rate cards while the other is single-period. - 🚨 They share a station
codeand a vehicle categorycode, under different ids. Both publish a desk codedsbx-aptand a group codedECON, at different prices, with differentlocation_ids andvehicle_ids. This is legitimate — acodeis unique only within a supplier (contract §2.1) — and it is there on purpose: it is the case that makes a client which maps oncodealone observably wrong. Send one supplier'slocation_idwith the other's key and you are refused. Map onlocation_id, or on(supplier_id, code). - Take
currency,tax_inclusiveandpayment_modelsfromGET /v1/suppliers, per supplier.GET /v1/whoamireports the same facts for your KEY and stays correct while a key reaches one supplier; the supplier list is the surface keyed on the supplier itself. payment_modelsappeared for the second supplier only (corrected 2026-10-18: this line said the first supplier's entry carried it too, which was not so when this shipped). The first supplier's fixture had no commercial-terms row, so the member was omitted on its entry and its bookings carried nopaymentblock. Both entries carry it now, listing every model (Sandbox fixture data — 2026-10-16).- Supplier one's catalogue, prices, ids and published pages are unchanged — verified rather than
asserted: all nine frozen
/v1/referencepages are byte-identical.
If you hold sandbox goldens, they are unaffected; if you want to exercise the two-supplier path, walk your integration once with each key and compare.
1.40.0 — 2026-09-12 — VERSION PINNING: your account is served at the version you certified on
Additive. One new member on one authenticated route, one new capability code, and a header that accepts one more value; nothing an earlier integration sent or parsed is removed or reshaped, and no response you already receive changes by one byte.
What this is. Your account now carries a pinned surface version — the version it certified on. That pin, not the deployment's newest version, is what your requests are served at. From here on, a change that keeps every shape and alters what a value means reaches you only when you ask us to move your pin. Additive changes keep arriving as they always have.
GET /v1/whoamicarriesapi_version:{ "pinned": "1.40.0", "latest": "1.40.0", "upgrade_available": false }.pinnedis your account's version;latestis what this deployment serves, the same numberGET /v1/capabilitiespublishes asapi_version;upgrade_availableis simply the two differing. Logpinnedat start-up — it is the single fact that tells you which behaviour your integration is getting.GET /v1/capabilitiespublishes theversion_pinningfeature code,enabled: true. It says this deployment honours pins; it does not name yours, because that document is unauthenticated and identical for every caller.X-API-Versionaccepts one more value. It has always had to agree with the path ("1", or omitted); it now also accepts your exact pinned version, so a client can assert the version it believes it is on. Any other value is409 version_mismatch, as before. Every/v1request that answered 200 before answers 200 now — this only widens what is accepted. The header asserts, it never selects — naming a different version does not get you that version.- Moving your pin is a conversation, not a config flag. Ask the operator; they move it, and the move is recorded. It moves in both directions — if an upgrade surprises you, you can go back.
- Nothing behaves differently at 1.40.0. Every account is pinned here on the day this ships, so reference, availability, quote, booking, cancellation, settlement and delivery all answer exactly as before.
What to re-check:
- Nothing is required of you. If you send no
X-API-Versionand read no new member, this release is invisible. - A strict validator must accept
api_versiononGET /v1/whoamiand the newfeatures[].codevalue. If yours rejects unknown fields, this is the release to open it — that has always been the contract (§1.2). - If you hardcoded a version string into
X-API-Version, make sure it is"1"or your own pin. Reading it fromwhoamiis better than either.
1.38.0 — 2026-09-12 — A REPLAY SAYS IT IS A REPLAY: meta on a create tells you whether anything was written
Additive: one new object's worth of members in the envelope meta of POST /v1/bookings, and
nothing you send changes. Three different things could put a booking in a create response and the
status code separated only one of them: a first execution answered 201, while an Idempotency-Key
replay and an external_ref match shared one 200 and were indistinguishable on the wire.
The external_ref case is the one that could cost you. §1.8's intent hash deliberately excludes
customer fields, driver names, notes, flight_number and meta — so a retry carrying newer
customer details against a reference that already names a booking was answered with the old
booking, and the newer details were never applied. Nothing said so.
From 1.38.0 the create responses publish it:
meta |
What happened | What to do |
|---|---|---|
{} (with 201) |
This request created the booking. | Nothing. |
{"replayed": true, "replay_of": "idempotency_key"} |
Your retry under the same key replayed the stored outcome. Nothing ran twice. | Nothing — the retry worked as designed. |
{"replayed": true, "replay_of": "external_ref"} |
A booking already existed under this external_ref with the same intent. Nothing was written. |
Compare the returned booking with what you sent. If the customer, drivers, notes, flight number or meta differ, apply them with PATCH /v1/bookings/{ref} addressed by booking_id. Do not create again. |
What to re-check: nothing breaks — replayed is absent rather than false on a first
execution, so test for its presence, and a client that ignores meta behaves exactly as it did
before 1.38.0. meta stays an open object and the replay_of vocabulary grows additively within /v1.
The marker is on the two create shapes only; PATCH/cancel still publish {}.
2026-09-11 — DOC CORRECTIONS: five published statements that disagreed with the wire — no wire change
No code, status or member changed; five sentences did.
credential_state_changedis NOT retryable. The error catalogue answered "Yes — re-readGET /v1/credentialsfirst, then retry" under Retry?, while the retry policy on the same page says retry only429,5xx,409 idempotency_in_progressand network errors. Retry? now answers No — re-read, decide again, then repeat the operation once against what you read. The remedy is unchanged; only the retryability claim was wrong.POST /v1/webhooks/redeliverrequiresIdempotency-Key, and the Integration Guide's list of operations that require one omitted it. There are six, they are now listed identically in the contract (§1.8), in the guide (§5.4) and in the OpenAPI document, and the contract no longer says "everyPOST/PATCHthat creates or mutates" — that was never true of the account-management writes, which neither require the header nor replay a stored result.- The full-cover upgrade is no longer a subtraction on one row. The published descriptions
still explained it as one, which stopped being true at 1.32.0 when
protection_optionswas cut to exactly one entry. An upgrade is a comparison between rows — one row per plan, each priced with the package that plan includes. - The first webhook retry takes about a minute, not 30 seconds — and there is no floor under
it. The backoff schedule says when an attempt becomes due; the sender wakes once a minute and
sends on the first pass after that, and its passes are not aligned to your failure, so a due
attempt waits anything from nothing to a further minute. The first retry therefore arrives
between about 25 and 95 seconds after the failure: size a "have they retried yet?" alarm above
95 seconds, not on the 30 in the schedule and not on a bare 60.
WEBHOOKS.mdnow says so, and states the drain rate it never did: 25 deliveries per account per pass — roughly 25 a minute — so a catch-up arrives as a stream rather than a burst. external_refis "permanent" in one direction only. The contract called it a permanent id unique per partner; the quickstart warned it was "not guaranteed unique over time". Both are now precise and agree with what is enforced: unique per partner account per supplier, bound to its booking for the life of the record (cancelling never frees it) — but it is a name you allocate, and a value whose create never succeeded stays free to bind to a different rental. Look up byexternal_ref; change bybooking_id.
1.37.0 — 2026-09-11 — SPEC TRUTH: three places the published document did not match the server
Nothing on the wire changed; three things the document got wrong about it did. Every fix is to
what we PUBLISH, not to what we serve — the same requests return the same bytes today as yesterday.
The version moves anyway, because what a client GENERATED from our document looks like DID change,
and a version is how you pin that: on 1.37.0 you can rely on cross_border being sendable from a
generated client and on the test event's 422 being in your error type. Regenerate your client.
GET /v1/availabilityacceptscross_border, and now says so. The search has taken the parameter, and priced the operator's cross-border rule intoprice, since the fee type went live — but it was missing from the operation's parameter list, so a client generated from our document had no way to send it. It is now published, with the two values it takes: the literal stringtrueorfalse, and nothing else —1,yesorTRUEis422 validation_error, issuemalformed, because a surcharge must never be applied, or waived, by guessing at a truthy string. An EMPTY value (?cross_border=) is read as absent rather than asfalse, the same as every other optional parameter on that operation.meta.cross_borderechoestruewhen you declared it, and is absent otherwise. Declare it on the search, the quote and the create alike: those three price three different rentals otherwise.POST /v1/webhooks/testrefuses a body it does not recognise, and the document said it ignored one. The request body must be absent or exactly{}; anything else — a member likeurl, and equallynull, an array or a scalar — has always been422 validation_error. The operation now documents that 422, and the requestBody no longer claims a non-empty body "is ignored". Nothing about the route changed; if your client has no branch for a 422 here, that is the gap this closes.- Seven nullable members are now written in 3.1's own syntax. Our document is OpenAPI 3.1, where
an optional null is spelled
type: ["string", "null"]; seven members still carried 3.0'snullable: true, a keyword 3.1 does not define. They arewebhooks.endpoint.urlandenabled_at, the credentialexpires_at,rotated_at,replaced_key_idandrevoked_at, anderroron the webhook test response. Most likely nothing changes for you, and we would rather say so than imply a fix you need to chase: the wire is unchanged (all seven always could be null), and the common tools still read the old spelling correctly — regenerating withopenapi-typescriptproduces byte-identical types, because it carries a compatibility path for the 3.0 keyword. The reason to correct it is the tool that does not: a strictly conformant 3.1 consumer is entitled to ignore an undefined keyword, and would then type these seven as never-null. If yours did, they become nullable when you regenerate — which is what the wire has always done.
1.36.0 — 2026-09-11 — CONTRACT-FIRST SUPPLIERS: GET /v1/suppliers, and a supplier_id on everything you store
Additive. New route, new members, new reserved vocabulary; nothing an earlier integration sent or parsed is removed or reshaped, and every existing response is what it was plus the new member.
GET /v1/suppliers(a key, no scope) lists the suppliers you sell for:supplier_id,name,status,currency,tax_inclusiveand, where your agreement can be stated,payment_models. One entry today. Read it rather than hardcoding it — a supplier added later is an extra entry, and nothing else in this contract changes shape.supplier_idis on every object you persist: stations, vehicle groups, protections, extras, fees, availability offers, quotes, bookings (includingGET /v1/bookingssummaries) and settlement statements. It is the same opaque valueattribution_tokenalready gives you onGET /v1/whoami.X-Supplier-Idis a new optional request header naming the supplier a call is about. Sending it is never required; a value that is not one of your suppliers is refused422 unknown_supplier.- Two error codes are published and never returned today:
supplier_not_enabled(403) andsupplier_paused(409). Two webhook event types likewise:supplier.addedandsupplier.paused. They are in the enums so a client generated now already tolerates them. sequenceon a webhook delivery is counted per supplier — it always was, since a delivery stream is one (operator, partner) relationship; the documentation now says so in those words.
What to re-check:
- Add a
supplier_idcolumn beside the records you cache and key them on it. A stationcodeis unique per supplier only, so map stations onlocation_idor on(supplier_id, code). - Read
currencyandtax_inclusiveper supplier rather than from one global setting. - A strict validator must accept the new members on the objects above. If yours rejects unknown fields, this is the release to open it — that has always been the contract (§1.2).
- Nothing behaves differently. Search, quote, booking, cancellation, settlement and delivery all answer exactly as before.
1.35.0 — 2026-09-11 — CURRENCY PER KEY: your supplier's currency on GET /v1/whoami, and capabilities.currency kept for good
Additive. One member added; nothing an earlier integration sent or parsed is removed or reshaped.
GET /v1/whoamicarriescurrency— the ISO 4217 code your supplier prices in, resolved from the key you call with. It is the currency of every Money on your availability, quotes and bookings, and it is always present. Pin it per credential: two keys with different suppliers can carry different values. It is the only value to send as thecurrencyparameter, if you send one at all — omitting the parameter prices in the same currency.currencyonGET /v1/capabilitiesstays, and now always means one thing: the currency of this host's own operator — the deployment default. Until this version it was omitted on a host serving more than one supplier; it is now served however many suppliers the host carries, so it can no longer disappear from under you. It is not necessarily your supplier's — the document is read without a key and cannot know which supplier is yours. Its value today is unchanged.
What to re-check:
- If you pinned
capabilities.currency, move the pin tocurrencyonGET /v1/whoami, per key. Today the two agree for every account; they can differ only for an account whose supplier is not this host's own operator. - Nothing else moves. Prices, the
currencyon every Money, thecurrencyparameter andmeta.unservable_currencybehave exactly as before.
2026-09-11 — DESCRIPTIONS: the contract's descriptions edited for a partner reader — no wire change
No path, member, type, enum value, error code or header changed. The descriptions in
channel-api-v1.openapi.json are the docstrings of any client you generate from it and the text
REFERENCE.md renders. They had been written for an internal reader, with warning marks, history
and emphasis in capitals; they are now written for you. The descriptions total 59,502 words, down
from 73,935, the longest is 120 words instead of 723, and none carries a warning mark or shouts.
Every identifier the old descriptions named is still named somewhere in this pack.
Five statements had gone stale and were corrected:
Location.deliverydescribed one delivery line per rental, at the dearer end. Since 1.34.0 every end placed at a location that prices a delivery carries its owndeliveryline.Policy.deposit_payment_methodssaid the card terms were not on this API. They arePolicy.deposit_card_rules: whether the card must be the main driver's, and which card kinds count.Constants.restriction_codessaid no member encodes a refused pickup-dropoff pair.Policy.movement_restrictionsdoes.WebhookEvent.typelisted three types as the ones sent today.booking.no_showandbooking.completedare sent too;webhooks.event_typesonGET /v1/capabilitiesis the list.POST /v1/credentials/rotatesaid to rotate again if you lose the new key. While the overlap is open that answers409: ask the operator to revoke the lost key, then rotate again with the old one before its deadline.
2026-09-11 — SAMPLES: one runnable program per operation, in three languages, executed against the contract — no wire change
Nothing on the wire changed; every operation now ships with a sample that runs. Under each
operation in REFERENCE.md, and as files under samples/ in this pack, there is a curl script, a
Node program and a Python program that send the request the contract's own example describes, with
the sandbox fixture's values, reading CHANNEL_BASE_URL and CHANNEL_API_KEY from the environment.
They are generated from the contract, never written by hand, and every one of them is executed
against the contract before a pack is published — the status and the response shape are held to
what the operation documents — so a sample that stops matching the wire fails our build instead of
reaching you stale. Two request examples were corrected on the way: the POST /v1/bookings example
quote_id now satisfies its own schema (a UUID, as every quote id is), and the PATCH /v1/bookings/{ref}
example is a metadata-only change (flight_number, notes) — a change that re-prices needs the
expected_price you were just shown, which no static example can carry (Channel API §5.3).
2026-09-11 — API REFERENCE: a generated REFERENCE.md, and /docs/reference live — no wire change
Nothing on the wire changed; the pack gained its lookup document. REFERENCE.md is rendered
into this pack from channel-api-v1.openapi.json — every operation (method, path, the scope its key
needs, parameters, the request body member by member, each response's data shape, the one example
the contract publishes, the errors it answers, the headers it always returns), every object, every
enumeration with closed and extensible told apart. Nothing in it is written by hand: a sentence that
belongs in the reference lives in the contract's own descriptions, so a client generated from the
contract and this document can never disagree. The same render is live at /docs/reference on
every host, beside /docs/errors. The concepts — how availability, quoting and the booking lifecycle
behave — stay in CHANNEL-API.md; the reference is where you look a member up.
2026-09-11 — ERRORS: every code now says what to DO next, in the pack and on /docs/errors — no wire change
No code, status or member changed. The catalogue in ERRORS.md gains a fourth column, What to
do — the operation to call, the header to read, the person to ask — and the live page every
doc_url points at carries the same sentence under each code. Both are rendered from one catalogue,
so they cannot disagree. Retry? still answers "may I send the same request again?"; the new
column answers "so what do I do?".
1.34.0 — 2026-09-11 — DELIVERY CHARGED PER END: a rental delivered and collected at priced places pays both fees
Behaviour change. No member added, removed or reshaped — but a total can go up. The delivery
charge used to be
one line per rental, at the dearer of the two ends. It is now charged at each end: every end of
a rental placed at a location that prices a delivery carries its own delivery line at that
location's fee, and the two ends add.
| Pickup | Return | Before 1.34.0 | From 1.34.0 |
|---|---|---|---|
| a hotel pricing delivery at €30 | the same hotel | €30, one line | €60, two lines |
| a hotel at €20 | another at €30 | €30, one line | €50, two lines |
| an airport desk (no delivery fee) | a hotel at €30 | €30, one line | €30, one line |
| no delivery fee | no delivery fee | no line | no line |
What to re-check:
- Sum the delivery lines; never assume one per rental. A rental whose two ends both price a
delivery carries two
kind: "fee"lines withcode: "DELIVERY"andtype: "delivery", pickup first — the same shape out-of-hours has always had. Each line'slabelnames its end (Delivery (pickup)andDelivery (dropoff)by default; the operator may reword them), so show them as they come — do not parse them. Neither carries afee_id, as before (§4.1). - Where the money moves.
price,line_itemsandtotalson availability, the quote and the booking rise by the second end's fee on such rentals; each delivery line is payable exactly as the delivery fee always was (§6.2). Nothing else changes. - What does not move. A quote minted before this change books at its quoted price (§5.1), and a
booking already made keeps the delivery charge it was sold with. A later
PATCHthat re-prices a booking prices it under the new rule.Location.delivery.fee(§2.1) still states what ONE end placed at that location costs. - The sandbox prices no delivery at any location, so nothing you certify against changes there.
2026-09-11 — PRODUCTION ACCESS: self-serve sandbox accounts request it from the account page (no /v1 change)
Portal only — nothing on /v1 moved. A self-serve sandbox account asks for production from its
account page; a named manager approves after terms, evidence and sign-off; the system mints the
production key and you reveal it once in the portal, within 14 days — never in an email.
CERTIFICATION.md "What you get, and what comes next".
2026-09-11 — CERTIFICATION: self-serve sandbox accounts run it themselves (no /v1 change)
Documentation and portal only — nothing on /v1 moved. A self-serve sandbox account starts its
own certification run (POST /_certification/start, or the account page), drives it with the same
status / advance calls, must finish within 24 hours, and finds the report on its account page.
The webhook signature self-test is now verified on record: POST /_certification/hmac-self-test.
CERTIFICATION.md and WEBHOOKS.md §5. Operator-issued keys:
unchanged.
1.33.0 — 2026-09-11 — TEST EVENT: ask for a signed webhook delivery before your first booking
Additive. One new operation and one new event type; nothing an earlier integration sent or parsed changes.
POST /v1/webhooks/test(scopewrite:webhooks) sends onewebhook.testdelivery — the envelope ofWEBHOOKS.md§1, signed exactly as §2 describes, over a synthetic booking (external_refWEBHOOK-TEST-…,data.test: true). An armed account receives it once at its registered endpoint and reads the outcome in the response; a self-serve sandbox account, which is never armed, gets the exact request captured into the inspector on its account page instead. See WEBHOOKS.md §7 "Asking for a test event".webhook.testjoins the event-type enum (capabilities.webhooks.event_types, theWebhookEventschema). Receivers that follow §1's rule — never reject a type you do not know — need no change; a receiver that projectsdataas a booking will see a plausible, clearly synthetic snapshot.- Self-serve sandbox keys now carry
read:webhooksandwrite:webhooks, so a self-serve account can register an endpoint, rotate its secret and ask for test events. Arming delivery stays with the operator and is never granted to a self-serve account.
1.32.0 — 2026-09-10 — ONE PACKAGE PER PLAN: protection_options carries exactly one package
Behaviour change. No member added, removed or reshaped; what one member contains narrows. Each price plan now carries exactly one package — the protection it includes in its rate — and nothing else.
What changed.
GET /v1/availability: every row'sprotection_optionsholds exactly one entry, the package the row'splanincludes, equal in value to the row'sprotection. A plan no longer lists higher packages as upgrades priced on top of its rate, and no plan offers a no-cover option beside its package. Another package for the same vehicle group is another plan's row — key your rows on(vehicle_id, plan)as 1.30.0 asked, and show a customer the choice of packages by showing the plans.GET /v1/reference/protectionspublishes the served plan's catalogue, as it has since 1.30.0 — now one package per vehicle group.POST /v1/quoteandPOST /v1/bookings: aprotection_idis bookable on the plan that sells it. Naming another plan's package without sending that plan asplanis422withissue: unknown_protection, as for any protection the priced plan does not sell. A booking modify re-prices on the booking's own plan, so there is no other cover to change to within a booking.- The capability codes
availability_protection_ladderandavailability_protection_ladder_pricingkeep their names and members; the list they describe is one entry long.
What to re-check. If you rendered protection_options[1..] as upgrades, render the plans instead.
If you computed an upgrade price by subtracting rung totals, compare the plan rows' totals.
1.31.0 — 2026-09-10 — CONTRACT PRECISION: the machine-readable document says what the wire always did
Additive in every member. Nothing an earlier integration sent or parsed changes; a generated client rebuilt from this document gains what it could not express before.
X-Request-Idis declared. Every operation now accepts the optionalX-Request-Idrequest header (XRequestId, alphabet[A-Za-z0-9_.:-]{1,128}), and every response of every operation declares theX-Request-IdandContent-Languageresponse headers. The wire has carried both since 1.0.0 (§1.5, §1.4); the document did not say so, so a generated client could neither send its own id nor read ours back — while §13.3 asks you to quote it. Re-generate your client.- The full-refresh collections declare their own
limit/page_token.GET /v1/reference/locations,vehicles,extras,protections,feesandtermsare served in ONE page and always have been (§1.9, §3);meta.next_pageis always null there. Their OpenAPI parameters are nowFullRefreshLimit/FullRefreshPageToken— same wire names, same validation (an out-of-range value is still422), descriptions that say the value never narrows.policiesandbookingskeep the paginating pair. Nothing to change unless your generated client surfaced alimitargument on those six that you believed did something. GET /v1/availabilityis bounded, and says so. At mostlimits.availability_rows_maxrows (GET /v1/capabilities; 500 by default — no real supplier's page reaches it). When the bound binds the page carriesmeta.truncated: { rows_omitted, rows_max }, keeps the first rows in(vehicle_id, plan)order, and thewithheldaccounting still closes (§4.1). Absent otherwise.GET /v1/capabilitieslimitsgainsavailability_rows_max.- Housekeeping you cannot see on the wire: the
/healthexamples in the document carried a staleapi_versionliteral; they now carry the document's own version. The §9 error table in the contract is now held to the error enum and the live/docs/errorscatalogue by a test, so a code can no longer ship on the wire and be missing from the contract.
1.30.0 — 2026-09-08 — PLANS: one availability row per vehicle group per price plan
Additive in every member; the availability page gains rows. Three new optional members, one new per-account switch (on by default), one new capability code.
What changed. A supplier may now hold more than one price plan for your account — a plan
being the protection tier the supplier includes in the rate (basic, medium, premium or
no_deposit_with_excess, the same vocabulary as tier). Each plan is its own rate card. (As first
published, this entry also said each plan carried the higher packages above it as upgrades. That was
withdrawn by 1.32.0: a plan carries exactly one package, its own.)
GET /v1/availability: one row per vehicle group per plan the supplier holds for you, each row carryingplan, ordered(vehicle_id, plan);meta.planslists the plans withservedmarking the one you are priced from by default; each per-plan entry ofmeta.withheldcarriesplan, and the accounting closes per plan. 🚨 The samevehicle_idappears once per plan. Key your rows on(vehicle_id, plan)— the shape the industry's vehicle-availability messages have always had. This is the default for every account (availability_plansonGET /v1/whoami); an integration that consumes one price per car can ask the supplier to switch it off, after which the page is the pre-1.30.0 one.POST /v1/quoteand the directPOST /v1/bookingsaccept an optionalplan— send the row'splanback and you are priced from that plan's book. It works whether the switch is on or off. Omit it and you are priced from the plan the supplier serves you by default, exactly as before. A plan your account holds no book for is422withissue: unknown_planon fieldplan.- The quote echoes
planwhen you sent one; the booking carriesplanwhen it was priced from a named plan, and a modify re-prices on that frozen plan — never on whichever plan the supplier serves by default later. A booking priced from the served default carries noplanmember, including every booking made before 1.30.0. GET /v1/capabilitiespublishes the codeavailability_plans(deployment-wide, as every capability code is) and registersavailability[].plan,availability.meta.plans[].plan,availability.meta.withheld[].plan,quote.planandbooking.planas extensible enums.
What to re-check. Key availability rows on (vehicle_id, plan), carry plan from the row to
the quote, and read plan on the booking. If your storefront sells one price per car and adds cover
at checkout, ask the supplier to switch availability_plans off for your account instead. GET /v1/reference/protections keeps publishing
the served plan's catalogue; the other plans' protection figures are on their availability rows.
1.29.0 — 2026-09-07 — a FOURTH protection slot: no_deposit_with_excess
Additive. Nothing you send changes, nothing you already parse changes shape. One new member in
the tier vocabulary, and it can now appear on any protection you receive.
What changed. protection_tiers on GET /v1/reference/constants gains a fourth member,
no_deposit_with_excess, and tier on a Protection — in availability's protection_options, on
GET /v1/reference/protections, and on a booking — may now carry it. It is a product with no
security deposit and a real excess, for operators who sell one.
What to re-check, and for most integrations the answer is nothing. tier has always been
published as x-extensible-enum, and §1.2 has always asked you to tolerate unknown enum members, so
a generated client carries no closed type for it and keeps working untouched. The one case that
needs a look is a hand-written exhaustive switch on the three old names — if you have one, it now
has a fourth case to handle. The contract has always told you not to write that
("READ THE NUMBERS, NOT THE WORD"), but it is worth saying plainly rather than leaving you to find
out: that is the code this release touches.
It is not a rung above premium. Nothing ranks the slots, exactly as before. Rank a group's
options by price and describe them from excess and deposit.
The one thing that is different about this name. The other three tiers are nominal — nothing
enforces what they mean. This one states its terms in its own name, so its terms are enforced: a
product published in this slot always has deposit of zero and excess above zero, guaranteed by a
database constraint rather than by convention. You may rely on that. It remains true that ranking and
description should come from the numbers.
Nothing is withdrawn. basic, medium and premium behave exactly as they did, and an operator
who does not sell this product simply never publishes the new value. The registry that lists which
enums may grow now names reference.constants.protection_tiers, which it should have done all along.
1.28.0 — 2026-09-06 — nine specific error codes replace one undifferentiated conflict
Additive. Nothing you send changes, and no HTTP status changes. Every status these routes
returned before, they return now; only the error.code string inside the 409 became specific enough
to branch on.
What was wrong. Three routes on the self-service surface — POST /v1/credentials/rotate,
POST /v1/credentials/{key_id}/revoke and POST /v1/webhooks/secret/rotate — answered with
error.code: "conflict", a code that appeared in no published enum and on no catalogue page. Two
consequences you may have hit: the doc_url on those responses pointed at an anchor that did not
exist, and one code carried seven different remedies ranging from stop and get a new credential to
you already succeeded, so the only way to tell them apart was to read message — which §9 tells you
never to parse.
What to do. If you branch on conflict anywhere, move to the specific code. If you do not branch
on it, you need do nothing: the statuses are unchanged, and §1.2's rule still applies — treat an
unrecognised code as an unknown error of its HTTP status.
New code (all 409) |
Returned when | Your move |
|---|---|---|
credential_inactive |
The key you presented is no longer active | Ask the operator for a new credential |
rotation_in_progress |
An overlap is already open — two live keys is the maximum | Retire the outgoing key, then rotate again |
credential_state_changed |
The key changed state mid-request; nothing was rotated | Re-read GET /v1/credentials, then retry |
self_revocation_refused |
A key tried to revoke itself | Present the successor and revoke this one with it |
rotation_required |
The key was not issued by a rotation, so it has no predecessor | Rotate first, then present the key that rotation issues |
not_your_predecessor |
A key may only retire the key it replaced | Present the right successor, or ask the operator |
no_webhook_secret |
No webhook secret exists to rotate | The operator sets the first one; rotation works after that |
webhooks_disabled |
Webhook delivery is not enabled for this account | Ask the operator to enable delivery |
webhooks_disabled was also already being returned by POST /v1/webhooks/redeliver while missing
from the enum; it is now published there too.
POST /v1/credentials/{key_id}/revoke is now idempotent
Revoking a key that is already revoked now answers 200 with that key, where it previously
answered 409. Revocation is a request for an end state, and that end state holds — so a client whose
first call succeeded but whose response was lost to a timeout now gets a success on the retry instead
of an error it has to special-case. Nothing else about the route changed: revoking an active key
answers 200 exactly as before, an unknown key is still 404 not_found, and no audit entry is written
for the no-op, because nothing changed.
The two 200s are deliberately indistinguishable on the wire — that is what idempotent means, and
it is why you can retry safely without inspecting the body. If you need to know which call actually
performed the revocation, that is in the operator's audit trail, not in this response.
1.27.0 — 2026-09-05 — how a booking is paid under YOUR agreement
- A new optional
paymentobject on the booking response, and a new optionalpayment_modelon the two create bodies. Both are additive: nothing you already send or parse has changed shape, type or value.
What payment tells you. For one booking: how much of price.totals.grand_total falls due up
front (amount_prepaid), how much at the desk (amount_on_arrival), who takes each part
(who_collects, stated per portion), the commercial model that applied, and the settlement_basis
your agreement settles on. The two amounts always sum to grand_total exactly.
⚠️ Note added 2026-10-16: the wire and the published schema disagreed on the two amounts. The
schema published with this version gives amount_prepaid and amount_on_arrival the Money shape
{ "amount", "currency" } that every other money member of the booking has. The wire has sent each
as a bare integer in minor units. This entry is not changed by that: what an account pinned to
this version or any version up to 1.64.0 receives is still the integer, and it keeps receiving it
until its pin moves. The Money shape is a new behaviour, introduced in 1.65.0 and sent only to
an account pinned at or above 1.65.0. See that entry.
🚨 Do not read it as price.totals.pay_now, and do not read pay_now as it. That split is the
operator's own product-level split of its priced lines and is identical for every partner of that
operator. payment is your relationship's schedule. They are the same question at different grains
and are computed from different sources — substituting one for the other inverts the answer.
⚠️ payment is OPTIONAL and you must null-check it. It is deliberately absent wherever
publishing it would be a guess: bookings made before this version, bookings sold under no commercial
term, and bookings whose stored term cannot be shown to have been stated by a person rather than
defaulted by the database. Absence means "we are not saying", never "nothing is prepaid."
⚠️ It is not a receivable. These are amounts the customer pays — never what either party owes
the other. What the operator has remitted to you stays on GET /v1/settlements.
What payment_model lets you do. Name the model you are selling a booking under, if your
agreement permits more than one. Omit it and your agreement's default applies — which is exactly
what every booking did before this version, so no change is required of you. Naming a model your
agreement does not permit is refused with 422, never silently replaced by your default.
It is optional forever within /v1 and will never become required. It cannot be changed by
PATCH: the terms are fixed when the booking is created.
Sandbox fixture data — 2026-09-05 (no surface change; sandbox only)
Nothing in this section changes /v1 or anything in production. It records a change to the
seeded fixture data in the sandbox environment, because integrations sometimes pin test
expectations to it.
🔑 The standing rule, which is why this is a note and not a breaking change: sandbox fixture data is illustrative and may change at any time. Read prices, ids, schedules and availability from the API at run time; never hardcode a sandbox value into your client or your tests. Production price commitments are a separate and binding promise and are unaffected by anything here.
- Seasonal rate cards on the sandbox ECON and COMP groups. Those two groups now price from four
periods instead of one, so a sandbox rental can cross a rate-card boundary and exercise the
per-night proration rule (§6.2) — which no sandbox rental could do before, because every group had
a single card. Which dates moved: only
2028-01-01 → 2028-08-31. A sandbox rental charged for at least one night in that range prices differently than it did before this entry (low season2028-01-01 → 2028-03-31, high season2028-04-01 → 2028-08-31). Dates outside that range, including all of 2026 and 2027 and everything from2028-09-01onward, are unchanged, as are the SUV and VAN groups, which keep a single card. If you hold sandbox golden files for an ECON or COMP rental touching that window, re-baseline them from the API. - A permanently sold-out sandbox group (VAN). It is advertised on
GET /v1/reference/vehiclesand priced like any other group, but every availability search withholds it (no_units_for_window) and every quote refuses it with422 no_availability. This exists so you can exercise the refusal path before production. It sorts last of the four groups. - Real opening hours on both sandbox desks, and the out-of-hours fee now fires against them.
Previously both sandbox desks published no schedule, and the fee was judged by a legacy
before-07:00 rule instead. Out-of-hours behaviour at the sandbox desks therefore changes in BOTH
directions: some handovers that were charged the fee no longer are, and some that were not
charged now are. The current sandbox schedules are published on
GET /v1/reference/locations— read them there rather than from this note. Note also that the fee is charged per out-of-hours handover, so a sandbox rental that is out of hours at both ends carries the fee twice (§3.6).
1.26.0 — 2026-09-05
- A minimum lead time is now publishable and enforced.
GET /v1/reference/policiesgains an optionalmin_lead_hours: the least whole hours between the moment you send a request and the pickup instant. It is omitted when a supplier publishes no minimum, which is the state of every supplier until one states a figure — absence means none is enforced, not that it is zero. - Where it bites.
POST /v1/quote, a directPOST /v1/bookings, and a modify that moves the pickup refuse a too-soon pickup with422 validation_errorcarryingissue: "lead_time_too_short". A modify that leaves the pickup alone is never judged against it, so extending a rental you already hold is unaffected. - ⚠️ A QUOTE CAN NOW EXPIRE INTO INVALIDITY, and this is the line to read if you hold quotes. A
quote is firm for 30 minutes. Redeeming one whose pickup has since fallen inside the supplier's
minimum fails
409 availability_changed— not 422 — because nothing in your request is wrong: the clock moved. Re-quote. We check at both doors deliberately; checking only at quote would let a document we issued walk straight through the supplier's own policy. - Telling the two apart. Every refusal carries
min_lead_hours,value(the pickup instant you sent) andas_of(the clock the refusal was judged at).as_ofis what distinguishes a quote that aged out from a request that was always too late — you can reproduce the arithmetic rather than infer it. - The boundary is inclusive. A pickup exactly
min_lead_hoursaway is inside the policy, matching howmin_rental_daystreats its own boundary. - Nothing you already parse changes. The member is optional and additive, every existing response for a supplier publishing no minimum is byte-identical, and no existing field moved.
- Not yet on search.
GET /v1/availabilitydoes not currently withhold on lead time, so a page can still list groups a quote would refuse for a very near pickup. Pre-filter from the publishedmin_lead_hours— it is one number for the whole account, so the filter is a comparison rather than a per-desk rule.
1.25.0 — 2026-09-04
GET /v1/reference/policiesnow carriesmovement_restrictions— which (pickup, dropoff) movements this supplier will and will not perform, and we enforce it. Until this version any pair of a supplier's published locations could be booked; from now on a movement the rule forbids is refused atPOST /v1/quote, at bothPOST /v1/bookingspaths and on aPATCHthat moves an end, with422 validation_errorand the issue codemovement_not_permitted. Pre-filter your routes against this member and you will not meet the refusal.- The member is
{ policy, forbidden_pairs }OR{ policy, permitted_pairs }— readpolicyfirst.forbidden_pairs: the supplier performs movements generally and the list names the ones they do not do.permitted_pairs_only: the supplier performs only the listed movements. Exactly one of the two lists is present andpolicyis what decides which — the lists have different NAMES precisely so that a client which reads a list without readingpolicycannot silently invert the supplier's intent; it simply finds the member it looked for absent. - 🚨 Every pair is DIRECTED, and neither desk is necessarily the problem. A rule about Heraklion → Chania says nothing about Chania → Heraklion, and both desks can be individually open, individually delivering and individually sellable — it is the ordered combination the supplier does not serve. A rental collected and returned at the same location is never restricted, under either policy.
- ABSENT means the supplier has not stated a movement policy, which is what every supplier's wire
looked like before this version, and any pair of their published locations can be booked. Under
permitted_pairs_only, an emptypermitted_pairsis a real statement — we sell same-location rentals only — and must not be read as "unrestricted". GET /v1/availabilitycan screen it too, as a per-account opt-in that is off by default. Withavailability_movement_restrictionsenabled on your account, a search for a movement the supplier refuses returns no vehicles — the status stays200— plusmeta.unsellable_movementcarrying the same detail object the quote422raises, and amovement_not_permittedentry inmeta.withheld. Readopt_ins.availability_movement_restrictionsonGET /v1/whoamito see whether yours is on; the capability code of that name is deployment-wide and cannot tell you (§3.0). Without the opt-in the refusal still applies at quote and at booking — search simply does not screen it, exactly as the 1.5.0 and 1.11.0 opt-ins work.- Nothing you already parse changes. A supplier who has stated no movement policy emits no member, and every existing response is byte-identical.
1.24.0 — 2026-09-03
- A protection's catalogue
pricecan now be a LOWER BOUND, and it tells you when it is. A new optional member,price_basis, appears onGET /v1/reference/protections. When it is present and reads"minimum",priceis the least that product can cost per billed day — not what a quote will charge. When it is ABSENT,priceis exact and is what a quote charges, which is the case for every product of every supplier today and the behaviour you already have. - ⚠️ If you render the catalogue
priceas THE price, read this one. For a minimum-basis product a customer would be shown one number and charged a higher one at checkout. Either branch onprice_basis === "minimum"and present a "from" price, or take the figure from a surface that has a rental in it:POST /v1/quote, a booking create, or thetotals_for_windowalready published on eachprotection_optionsrung of aGET /v1/availabilityrow. Those are exact, always. - Why the catalogue cannot be exact for these. Some suppliers price cover as a share of the car rate, and the car rate varies by season and by rental length. The reference catalogue is a full-refresh sync with no window in it, so no single per-day figure there can be true of every rental. The share itself is not published — you do not hold the supplier's rate card and could not apply it — so what you get is the bound, a flag saying it is one, and the exact figure one call away.
- Nothing you already parse changes.
pricekeeps its type and stays required,price_basisis optional, and every response for a supplier that does not price this way is byte-identical.
1.22.0 — 2026-09-02
GET /v1/reference/locationsnow carries a realcode— the operator's own short label for a desk ("HER-APT","CHQ-T1"). The member has been declared since/v1shipped and has beennullon every desk of every operator until now; it staysnullfor any desk whose operator has not assigned one, so treat it as optional and expect it to fill in as operators complete their catalogues. Nothing you already parse changes — the declared type (["string", "null"]) is unmoved and every response for an unassigned desk is byte-identical.- ⚠️ This REVERSES what the field's own description used to tell you. It read "Never map on it
and never key on it", and it was right to: nothing stopped a code being reassigned or reissued.
From this version we guarantee, in our database and not by convention, that a code is set once
— it can never be changed or cleared — and is never reused, not for a different desk, not after
a desk is withdrawn, not ever. So it is now safe to key your own station mapping on
code, within one operator.location_idis still the identifier every other surface uses (quotes, bookings, one-way fees) and is still the one to use where you can;codeis for the mapping and the support conversation, and for airportsiatastill tells you which airport — one airport commonly has several desks, so a code does not replace it. - Codes are unique per OPERATOR, not across the platform. If you talk to several suppliers
through this API, two of them may legitimately use the same code for their own desks — key on
(operator, code), never oncodealone. - A desk you can no longer see has not changed meaning. Withdrawing a desk removes the whole
location from this collection, exactly as an archived desk always has; its code is retained and is
never handed to another desk, so a code you hold and stop seeing means "that desk is not currently
sellable" — never "that code is now somewhere else". If the desk returns, it returns under the
same
location_idand the samecode. - No feature flag, and nothing to feature-detect. Each location tells you directly whether it has
a code, by carrying a string or a
null.
1.21.0 — 2026-08-29
- The desk-side lifecycle reaches you. When the operator's desk marks your booking a no-show or
completed, you now receive
booking.no_show/booking.completed— each at a newrevision, the payload carrying the status the booking now holds (Channel API §7.1, §5.2). A desk reinstatement (a terminal moved back toconfirmed) is still poll-only, so keep the reconcile loop (Integration Guide §8). Nothing to build if your receiver already skips types it does not handle. - A booking carries its own cancellation terms. Every booking minted from now on carries
cancellation_policy: the rule FROZEN onto that booking when it was made —type,source(whose rule:operator_default,broker_termsorlegacy_zero_fee), an absolutefree_untildeadline with itslate_fee_percent, or thetiersladder, and alwaysfee_basis— so the rule you display for a booking already made is the rule we will charge it under (Channel API §5.1). Display-only;per_broker_cancellation_policyreadstrue. Absent on a booking whose latest revision predates this version. - Two more
changereasons, and a stated cause.vehicle_replacedandcannot_fulfiljoin thechange.reasonenum, and the operator's desk can now state any published reason (with a note) when it changes your booking — thebooking.modifiedyou receive carries it (Channel API §5.1). Keep the enum open in your handler. - ⚠️ This supersedes what 1.20.0 told you about the desk terminals. That entry closed by saying
desk
no_show/completed"still emit nothing and still leaverevisionuntouched — polling stays their detection path". That was true when it was written and is false from this version: both now emit, and both mint arevision. The 1.20.0 text is left standing as the historical record of what 1.20.0 shipped — read it as dated, not as current. If you built a polling loop on the strength of that sentence, it is not wrong and does not need removing — it will simply stop being the only way you hear about a terminal, and your dedupe on(booking_id, revision)already makes the overlap harmless. A desk REINSTATEMENT is still poll-only.
1.20.0 — 2026-08-28
booking.modifiedcan now be operator-initiated, and says so. When a shared-fleet oversell resolution moves your CONFIRMED booking to a different inventory class, you are told — the event carries achangeblock withactor: "operator"andreason: "overbooking", and the booking's snapshot restates the class it now holds.vehicle_id,acrissanddisplay_namestay frozen at creation: do not read the event as a new group to map or re-sell. It bumpsrevisionlike any other content change, so your ordering and dedupe need no special handling. Feature-detect viaoperator_initiated_changeonGET /v1/capabilities.- Tell your own modifies apart from the operator's by
change, not by a second event type — its presence and itsactorare the discriminator; its absence means the change was a/v1write of your own. A/v1modify does not populatechange. - Nothing to re-check in what you already parse. The
changeblock has shipped reserved inbooking.schema.jsonsince spec 16 and is emitted for the first time; deskno_show/completedstill emit nothing and still leaverevisionuntouched — polling stays their detection path.
1.19.0 — 2026-08-28
GET /v1/reference/locationsnow carriesphotos— an ordered list of photographs of the desk itself (the building, the entrance, the counter, the parking area), each{ "url": "…", "role": "…" }with an optional free-textroleyou should treat as untagged when you do not recognise it. Fetch eachurlexactly as given: absolute, unsigned, no expiry, and the host is not part of the contract. Feature-detect asreference_location_photos.- Vehicle groups can now describe themselves —
GET /v1/reference/vehicles(and each availability row's vehicle block) may carrygps_included,body_style, and for electric groups the pairelectric_range_km+electric_range_basis. All four are additive and OMITTED where the operator has not stated them — a missing member is "not stated", andgps_included: falseis the positive statement that no navigation device is included in the rate; never collapse the two.body_styleis a stable code drawn from the newbody_styleslist onGET /v1/reference/constants(treat a code you do not recognise asother), andelectric_range_basisvalues are enumerated there aselectric_range_bases. Feature-detect viaavailability_vehicle_attributes. - Nothing to re-check in what you already parse. The member is additive and optional; no existing
value changed and no row is withheld. Two things worth knowing before you validate:
photosis nevernull(a desk with none simply has nophotoskey, because an empty list would assert something the operator did not say), and a published photograph can be withdrawn — the URL then stops resolving, which is an ordinary outcome and not an outage. A photograph is never replaced in place; a different picture is always a different URL.
1.18.0 — 2026-08-26
- You can now re-point your own webhook callback and rotate your own signing secret.
PUT /v1/webhooks/endpointmoves the destination;POST /v1/webhooks/secret/rotateissues a new secret and both secrets sign every delivery until your next rotation, so your receiver keeps verifying throughout and needs no code change.GET /v1/webhooks/endpointshows the current state, including whether an overlap is open. See WEBHOOKS.md. - The new secret is returned once and cannot be retrieved again — we hold only its ciphertext.
- A URL that is not deliverable is refused, not stored (
422): https only, no embedded credentials, no private/reserved/metadata addresses — the same check the delivery worker runs. - Two new scopes,
read:webhooksandwrite:webhooks.write:webhooksis the most consequential grant on this API: whoever holds a key carrying it can redirect a stream that carries personal data. Do not put it on the key that carries your booking traffic. - Arming and disarming delivery is still the operator's, and your FIRST secret still comes from them
out of band. Nothing changes for you unless you ask for the scopes. Feature-detect with
webhook_self_service.
1.17.0 — 2026-08-26
- The five full-refresh reference catalogues now carry a REAL per-record change stamp, so finding
one edit no longer means re-reading the whole catalogue.
GET /v1/reference/vehicles,/extras,/locations,/protectionsand/feeseach gain arow_updated_at: the instant THAT record last changed. Unlike theupdated_atbeside it — which on these five is the response instant, so every row's value moves together on every read —row_updated_atdoes not move when you merely re-read. Two calls a second apart return the same value unless the row actually changed. This is the member to diff, to order by, and to store per row. - Pass
?changed_since=to narrow a catalogue to what moved, then UPSERT what comes back by id and store that response'smeta.changed_as_ofas your next watermark. Never use a clock of your own:changed_as_ofis deliberately a short lap behind the read instant, so a write still in flight cannot fall between two syncs and be missed by both. - 🪤 On a narrowed page an ABSENT row means UNCHANGED, never withdrawn. A
changed_sinceloop is an update channel, not a deletion channel. Membership changes are visible only on a full refresh, so keep calling withoutchanged_sinceon your own schedule (§1.10). - On
protectionsandfeesthe narrowing is COARSE, and we would rather tell you than let you find out. Those two carry ONE shared stamp per collection, sochanged_sincereturns the whole collection or nothing at all. The other three narrow per record. Both behaviours are honest; only one of them saves you bytes. - A correction you previously could not see is now visible. An operator can correct a group's
ACRISS code under a stable
vehicle_id; before this,updated_atcould not tell you which group moved, so the only honest response was a full re-sync. That correction now moves exactly that group'srow_updated_at. It is not retroactive — a booking carries its own frozenvehicle.acriss, so a settled booking is never re-classified underneath you. - Nothing changes unless you ask for it.
row_updated_atis purely additive, no existing member changed shape or meaning, and a request that omitschanged_sincereturns exactly the full catalogue it always did. Feature-detect with thereference_changed_sincecode onGET /v1/capabilities.
1.16.0 — 2026-08-25
GET /v1/reference/constantsalso publishesprotection_coverage_codes, the coverage vocabulary. It is the value set shared bycoversandexcludesonGET /v1/reference/protections. The codes name PARTS of the vehicle (glass,undercarriage,tyres_and_wheels) and EVENTS (theft,fire,wrong_fuel), and they carry NO language — render each one in your customer's language from your own catalogue. They do not state the legal extent of any cover: that lives in the operator's rental terms, which is what binds. The list grows ADDITIVELY within/v1, so read it at runtime rather than hard-coding the members, and treat a code you do not recognise the way the rest of this contract asks — ignore it, never reject the response. (Recorded late: this member shipped with 1.16.0 and was not announced here at the time. It is described exactly as its own schema describes it; nothing about it has changed.)- You can now read, rotate and retire your own API credentials. Three new paths:
GET /v1/credentialslists the keys on your account,POST /v1/credentials/rotatemints a successor to the key you are authenticating with, andPOST /v1/credentials/{key_id}/revokeretires a predecessor early. Before this, issuing and revoking were admin-only, so a suspected compromise meant an out-of-band request and a hard cut-over. - A rotation has an OVERLAP window in which BOTH keys work, so you can roll a fleet of workers without a synchronised restart. Revoke the old key when the last of them is on the new one; if you do nothing, the overlap closes on its own.
- The secret is shown ONCE, at the moment it is minted, and is never retrievable afterwards.
GET /v1/credentialsreturns metadata only — key id, prefix, scopes, state and timestamps — so a key read back from the list cannot be used to authenticate. Store the secret when you receive it. - Nothing changes unless you ask for it. These routes are gated on two NEW scopes,
read:credentialsandwrite:credentials, which the operator grants deliberately; a key minted without them gets403 forbidden. Existing keys keep working exactly as before, and no existing field changed shape or meaning. Feature-detect with thecredential_self_servicecode onGET /v1/capabilities— and note thatenabledthere reports the DEPLOYMENT, never whether YOUR account holds the scopes.
1.15.0 — 2026-08-23
- A per-account source-address restriction is now available, and is off for everyone. Ask the
operator to attach IPv4/IPv6 CIDR ranges to your account and a request that authenticates but
arrives from outside them is refused
403 forbidden. Nothing changes for you unless you ask: no account is restricted by default, and an account with no list behaves exactly as before. Contract §1.3 — and ask[email protected]for our Security and Data Handling page, which states the limits, before relying on it: the control is only as good as our origin being reachable solely through the edge. That page is not in this pack, so asking for it by name is the route. - An address we cannot resolve counts as OUTSIDE the list, so the control fails closed. If you take it, tell us when your egress ranges change before they change.
1.12.0 — 2026-08-13
- Archived or deactivated pickup/dropoff locations leave the partner plane entirely. They stop
appearing in
GET /v1/reference/locations, and the selling doors — availability, quote, booking create and a modify that moves an end — refuse an archived location like an unknown one. A location that returns re-appears under the samelocation_id. Nothing to build: your sync loop already handles a record dropping out of a refresh (Integration Guide §2.2), and the refusal codes are the ones you already handle.
1.11.0 — 2026-08-12
- Search can now screen out rentals whose ENDS the operator refuses (an out-of-hours handover
at a desk that answered "no", or a delivery end where the operator does not deliver). Per-account
opt-in, off by default — ask us to enable it. With it on:
datacomes back empty for such a search,meta.unsellable_endsnames the offending end(s), and the withhold accounting carriesout_of_hours_not_offered/delivery_not_offered(Channel API §4.1). Without it, those refusals arrive at quote and create exactly as before. - The out-of-hours and delivery answers themselves are disclosed on the location object (Channel API §2.1), so you can pre-filter before searching.
1.10.0 — 2026-08-11
- The cancellation rule in force is published on the read surfaces: the detail (deadline,
tiers, fee basis) on
GET /v1/reference/policies, and availability'smeta.policiescarries the same resolved rule (Channel API §2.6, §3.7). Where those surfaces report a rule, it is the one applied at cancellation (§5.4).
1.9.0 — 2026-08-09
- The protection ladder is priced per availability row: every sellable tier carries
totals_for_windowandtax_breakdown_for_window, so an upsell figure is a subtraction between two published integers instead of onePOST /v1/quoteper tier (Channel API §4.1).
Documentation corrections — 2026-08-24 (no surface change)
deposit, in the Integration Guide's quote-field list. The entry now names the amounts the pre-authorisation sits outside:grand_total,pay_nowandpay_at_pickup. Nothing about the field has changed: adepositis a card hold placed at pickup, it is never part ofgrand_total, and it is never insidepay_noworpay_at_pickup(Channel API §6.1). It is surfaced separately so you can warn the customer about the card hold before they book. This completes the 2026-08-19 correction below, which left this one line describing the money path in the older frame. No value changed, and no member was added, renamed or re-typed. Nothing to re-check: the hold was never one of the rental amounts.
Documentation corrections — 2026-08-19 (no surface change)
- What the
pay_now/pay_at_pickupsplit means (§2.3, §4.1, §6.2, the Commercial Overview, and thetotalsdescription in the OpenAPI document). Earlier revisions described the split by naming which party takes the money at each stage. That answers a different question — a collecting party — and the split does not record one.payableis the operator's configuration for its own product — which priced lines it has marked prepaid and which pay on arrival — and it is the same for every partner of that operator. It is not derived from your agreement and does not change when your agreement does; it is not a schedule for a booking made through you (under a pay-on-arrival agreement none of these rental amounts falls due before pickup, however largepay_nowis); and it is not a record that any customer has paid anything — no customer money is collected through this API. No value changed.pay_nowandpay_at_pickupcarry exactly the numbers they carried before, and no member was added, renamed or re-typed. §6.2 now carries one positive statement of what the split does and does not tell you, and the other sites point at it. Worth re-checking: if anything you built infers a collection or a payment obligation from a non-zeropay_now, that inference was never supported by the wire.
Documentation corrections — 2026-08-13/14 (no surface change)
The current pack supersedes every earlier copy in full — discard them. If you sized work against an earlier copy, these are the corrections worth re-reading:
- Rate model (§6.2): an earlier revision said the rate card covering the pickup date prices the entire rental. A rental that spans card periods is prorated per night; the section now states the exact rule and carries a dated correction note.
- Feature detection (§3.0): two per-account options were wrongly described as detectable on
the unauthenticated
GET /v1/capabilities. That endpoint reports what the deployment supports, never what your account has enabled. - Environments (Integration Guide §1.1): an earlier revision implied both environments serve
the same route set. Which routes a host mounts is that host's own
GET /v1/openapi.json. - Cancellation reads (§2.6): the pre-sale instruction to read cancellation terms is conditional on the surfaces reporting one — where they are silent, terms negotiated with you can still be in force.
- Plus precision fixes across §0–§11 and the guide from the full external review of 2026-08-13/14 — every page of this pack has now been through it.
Before this changelog began — /v1 1.2.0 to 1.8.0 (all earlier than 1.9.0, 2026-08-09)
Until 2026-09-11 the contract's own title block carried these seven entries and the
feature-detection note beneath them; they moved here, verbatim, when CHANNEL-API.md became the
concept document and REFERENCE.md the generated lookup. Nothing about the surface
changed on that day; the history is simply in one place now.
- 1.2.0 — the availability enrichment (§4.1): rows carry
protection+line_items, meta carriesrental_days,extras[]andpolicies. - 1.3.0 — added the optional
driver_agesearch parameter. - 1.4.0 — puts the FULL §6 price breakdown on every row —
tax_breakdownandtotals— so nothing on a results card needs arithmetic on your side. - 1.5.0 — lets accounts with the duration opt-in enabled see a window outside the published rental-duration bounds returned as no vehicles plus
meta.unsellable_window. - 1.6.0 — lands three additive availability enrichments: the sellable protection LADDER on every row (
protection_options, plusdeposit_payment_methodsonmeta.policies); fee IDENTITY on a row's fee lines —typeto classify,fee_idto join toGET /v1/reference/fees; and the vehicle DESCRIPTION on every row — the driver-age band plus the group's physical attributes and images — so a results card needs no catalogue join either. - 1.7.0 — declares the TAX CONVENTION machine-readably —
tax_inclusiveonGET /v1/capabilitiesand on the §2.6 policy object (§1.6, §6.2) — and stopsGET /v1/availabilityanswering a currency it cannot price with a silent empty list: it now carriesmeta.unservable_currencynaming the currency you sent and the one this deployment prices in (§4.1). It also puts the arrival detail on the §2.1 location —terminal,shuttle_info,pickup_instructions— feature-detected asreference_location_detail. - 1.8.0 — makes an empty or short availability page EXPLAIN ITSELF —
meta.withheldsays why vehicle groups are missing, by reason, with a count of groups (§4.1).
All additive; feature-detect as availability_enrichment, availability_driver_age, availability_full_breakdown, availability_protection_ladder, availability_protection_ladder_pricing, availability_fee_identity, availability_vehicle_attributes, availability_withheld_reasons, reference_location_detail. The three opt-ins above are deliberately not in that list: availability_duration_bounds (1.5.0), availability_end_terms (1.11.0) and availability_movement_restrictions (1.25.0) are switched on per ACCOUNT, while the capability codes of those names are deployment-wide like webhooks — they say this deployment can screen, never that yours is screened (§3.0). GET /v1/whoami (§3.8) says whether yours is — opt_ins on that response is the per-account answer. 1.25.0's movement_restrictions on GET /v1/reference/policies carries no feature code and is not an opt-in: it is published whenever the supplier has stated a movement policy, and the refusal at quote and booking applies to every account. 1.7.0's other two additions, the tax convention and meta.unservable_currency, carry no feature code, and neither does 1.10.0's cancellation detail — read the fields themselves, which are always present when they apply. 1.21.0's cancellation_policy on the booking is feature-detected as per_broker_cancellation_policy; its two new events carry no code of their own (§7.1). 1.22.0's code carries no feature code either, and needs none: each location tells you directly whether it has one, by carrying a string or a null.