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:

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:

  1. INTEGRATION-GUIDE.md §2 — sync the reference catalogues, so you are not fetching locations on every search.
  2. INTEGRATION-GUIDE.md §5.4 and the contract's §1.8 — idempotency in full, including what a replay returns.
  3. WEBHOOKS.md — if you want booking changes pushed to you rather than polled.
  4. CERTIFICATION.md — what we check before you go live, and how to rehearse it yourself first.
  5. ERRORS.md — every error code and whether to retry it, so a 4xx in step 3 is a lookup rather than a guess.
  6. samples/ — one runnable program per operation in curl, Node and Python (nothing to install), generated from the contract and executed against it before every release; the same programs sit under each operation in REFERENCE.md. Set CHANNEL_BASE_URL and CHANNEL_API_KEY and run one.
  7. 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 of off; read yours on GET /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

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

  1. Supplier owns price — you never recompute; Caravel returns priced options and re-prices authoritatively at booking.
  2. Partner-agnostic — the same endpoints, ids and rules serve every integration; nothing here is a special case that can rot.
  3. Idempotent & retry-safe where it books — quoting, creating, modifying, cancelling and webhook redelivery are safe to retry: Idempotency-Key + external_ref prevent 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.
  4. Versioned & evolvable — /v1/ path prefix; additive changes only within a major version; a published deprecation policy (§1.2).
  5. ACRISS-based vehicles — the industry-standard 4-char code maps to what you already hold (§8.4).
  6. Incremental sync for static data — updated_at on every record, updated_since where a list narrows (§1.10).
  7. Async truth via webhooks — changes made through /v1, and the desk-side acts §7 lists, are pushed as signed events carrying a monotonic revision; a desk reinstatement is not, so the list poll (§5.5) stays the loop that catches everything.
  8. 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

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.

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

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.

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

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.

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.

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.

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.

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.

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:

  1. share = round_half_up(B × share_bps ÷ 10000) — in whole minor units, one rounding, applied to the whole stay and never to a day.
  2. charge = max(share, min_per_day × N) where min_per_day is present; then charge = min(charge, max_per_day × N) where max_per_day is 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:

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.

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.

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.

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.

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

What the split is, and what it is not. payable is 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 large pay_now is, so a non-zero pay_now is 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 on GET /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 as payment (§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.

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:

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


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

  1. Poll §11.1 after each period closes and match amount_outstanding against your own receivable; store meta.as_of, replay it as updated_since, follow meta.next_page to exhaustion.
  2. 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 returns null.
  3. Treat the last page's header as the period total and bookings_allocated as the check that your walk finished and the period did not move.
  4. 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:

  1. 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.
  2. POST /v1/quote — turn one selection into a firm, persisted total with a quote_id.
  3. POST /v1/bookings — book against that quote_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.

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>

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:

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:

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:

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

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
  1. Send your stored watermark as ?changed_since=. You get the records whose row_updated_at is at or after it — inclusive, so a boundary change is never lost.
  2. UPSERT them by id.
  3. Store that response's meta.changed_as_of as your next watermark — never meta.as_of, never your own clock. changed_as_of trails 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…"
  1. Store the ETag beside 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 a changed_since read has a tag of its own and must not borrow the full catalogue's.
  2. On 304 keep what you hold — including your as_of / changed_as_of watermarks. A 304 is "your stored copy is still current": its request_id, meta.as_of and updated_at are those of the read you stored, so quote the X-Request-Id header of the 304 itself when you contact support. It does not refresh your watermarks, and replaying the older ones can only re-read a row, never skip one.
  3. On 200 merge 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 }
  1. A supplier named in meta.suppliers_unavailable was 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 the distribution_channel you named is named there too. Replace only the slice you asked for (meta.collection.scope): a read narrowed by plan or distribution_channel is not the whole card.
  2. A stay of N days costs totals[N] for N up to 7 and N × extra_day beyond. A null is no price, never €0, and that length cannot be quoted. Read the plan's package from protection and add its price per day when it is per_day; when it carries price_basis: "minimum" that figure is only the floor. price_percent gives 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.
  3. 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.
  4. 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 }
  1. 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 in meta.suppliers_unavailable was not read — leave its closures as they are.
  2. 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. Dates are the operator's own calendar dates.
  3. 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.
  4. It can miss a closure, never invent one. A manual row 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.
  5. 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), for meta.rules_by_supplier[*].sold_out_horizon_days days from today, through sold_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 a manual row. As of meta.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 unknown source as 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
  1. 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.
  2. Once mapped, null is an answer. The members appear on every row they apply to, and null means the supplier has no code of yours for that object — ask it for one, do not guess.
  3. Per supplier. Key your table on (supplier_id, partner_code). Two suppliers can reuse a code for different objects.
  4. 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.
  5. Only on the reference reads. Availability's embedded accessories and protections, and the protection inside a rate row, carry no partner_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
  1. Start from the GET. It lists exactly the objects you may write a code for, with your code where you have one and null where you do not. The CSV form is the file to work in: fill the code column, delete nothing, send it back as text/csv. An empty code cell 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).
  2. Dry-run first. ?dry_run=true checks 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 a 429.
  3. 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 a more_problems entry for the rest. Fix them and send again. Nothing in a refused request is applied.
  4. Retry knowing what it does. Sending the same body again changes nothing further, and a request that restates your map is 200 with everything unchanged, 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. GET first when you do not know why a request failed.
  5. 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_limited with Retry-After.
  6. unknown_object is 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.
  7. 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.released counts 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&currency=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.

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": { }
}

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.

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

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" } } }

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:

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:

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

  1. Call with your stored updated_since (start from meta.as_of of the last successful run).
  2. Follow meta.next_page to exhaustion.
  3. Compare each row's status with the one you hold, and apply a difference from the list row itself. Do not gate this on revision: a reinstatement leaves revision untouched.
  4. For any booking whose revision is higher than the one you hold, fetch GET /v1/bookings/{ref} for the full snapshot — the path for content changes.
  5. Store this run's meta.as_of as the next updated_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:

  1. GET /v1/capabilities on the base URL you use. No key needed; a 200 proves that host is up and answering on /v1 — not that every route you need is mounted (that is its GET /v1/openapi.json) and not that a search or booking will succeed.
  2. GET /health on the same base URL. No key; returns ok, api_version, exposed and nothing else. ok: false (served 503) 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: false means its /v1 surface is switched off and the authenticated routes answer 404 whatever 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.
  3. 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.

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

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

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.

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:

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:

  1. 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 the updated_since list in (2).
  2. A gap in revision on any single booking (as above).
  3. Startup / after downtime — before you trust your local state, pull GET /v1/bookings?updated_since=<your last known-good instant>, following meta.next_page to exhaustion, and apply every summary's current state. This is the same reconciliation list the INTEGRATION-GUIDE.md describes for scheduled polling, reused here as gap recovery.
  4. 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:

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:

  1. Compute the signature over the fixture's exact fields with the given secret and confirm it matches expected_signature_hex / the full header value.
  2. Apply the SAME header against tamper_test.tampered_body (one field inside data is 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:

Verify each capture with your receiver code and your secret, exactly as you would a delivery. Where this differs from an armed endpoint:

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:webhooks on 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:

  1. 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.
  2. Add it to your verifier alongside the one you already have. Both are verifying now.
  3. 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 attempts and last_status_code and clears delivered_at. Every per-attempt figure means "since the last reset", never "ever".

🚨 And "cleared" can be permanent. delivered_at is restored only when the replayed delivery SUCCEEDS. Replay a row that was already delivered, and if the replay then exhausts its attempts, the original success instant is gone from this log for good — the row ends exhausted with delivered_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:

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.

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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

Parameters

Responses

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

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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

Parameters

Responses

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&currency=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.

Parameters

Request body — QuoteRequest

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

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

Parameters

Responses

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.

Parameters

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

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

Parameters

Responses

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

Parameters

Request body — ModifyBookingRequest

Example request:

{
  "expected_revision": 1,
  "flight_number": "A3 993",
  "notes": "Customer lands late — please hold the car until 23:00"
}

Responses

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.

Parameters

Request body — CancelBookingRequest

Example request:

{
  "expected_revision": 1,
  "reason": "customer_request"
}

Responses

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

Parameters

Request body — ModifyBookingRequest

Example request:

{
  "expected_revision": 1,
  "flight_number": "A3 993",
  "notes": "Customer lands late — please hold the car until 23:00"
}

Responses

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.

Parameters

Responses

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.

Parameters

Request body — WebhookEndpointRequest

Example request:

{
  "url": "https://hooks.your-domain.example/inbound"
}

Responses

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.

Parameters

Request body — WebhookSecretRotateRequest

Example request:

{
  "reason": "scheduled quarterly rotation"
}

Responses

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.

Parameters

Responses

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.

Parameters

Request body — WebhookTestRequest

No members.

Example request:

{}

Responses

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.

Parameters

Request body — WebhookRedeliverRequest

No members.

Example request:

{
  "sequence_from": 4120,
  "sequence_to": 4180
}

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Responses

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.

Parameters

Request body — CredentialRotateRequest

Example request:

{
  "overlap_seconds": 86400
}

Responses

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.

Parameters

Request body — CredentialRevokeRequest

Example request:

{
  "reason": "replaced by its successor"
}

Responses

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.

Parameters

Responses

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

Parameters

Request body — PartnerCodeSaveRequest

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

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

Vehicle

Extra

Protection

Fee

Rate

StopSale

Policy

TermsDocument

TermsDocumentDetail

Constants

Error

Booking

CreateBookingRequest

CreateQuotedBookingRequest

CreateBookingRequestBody

No members.

WebhookRedeliverByWindow

Re-drive everything that occurred in a time window.

WebhookRedeliverByIds

Re-drive an explicit list of events, by id from your delivery log.

WebhookRedeliverBySequence

Re-drive an inclusive range of your own delivery sequence for this subscription.

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

ModifyBookingRequest

Capabilities

HealthStatus

WebhookEvent

Breakdown

Money

BookingListItem

WebhookEndpointRequest

PartnerCodeRow

PartnerCodeSaveRequest

PartnerCodeSaveResult

WebhookSecretRotateRequest

WebhookEndpointConfig

WebhookSupplierConfig

One supplier's webhook configuration.

WebhookWriteResult

One supplier's outcome of a webhook write that applied to several.

WebhookReplayResult

One supplier's outcome of a replay that applied to several.

RotatedWebhookSecret

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.

Supplier

DistributionChannel

AccountOptIns

Per-account switches. All default false; each one changes what you receive, so an integration that behaves unexpectedly should check here first.

Account

CredentialRotateRequest

CredentialRevokeRequest

ApiCredential

RotatedCredential

QuoteRequest

Settlement

SettlementDetail

PriceLineItem

TaxLine

PriceTotals

The rolled-up totals for this price. Read these rather than summing the lines yourself: the engine's arithmetic is what is billed.

AvailabilityItem

QuoteResponseData

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.

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.

TermsClause

One clause of the contract. May nest.

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.

EnumList

No members.

BookingStop

StopInput

MoneyInput

SignatureScheme

PartnerCodeEntryInput

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:

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:

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:

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.

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:

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-IP header our edge sets, falling back to X-Real-IP and 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.

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.

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.

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

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.

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.

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.

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.

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

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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:

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

What to re-check:

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.

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.

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.

What to re-check:

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.

What to re-check:

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:

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:

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.

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.

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.

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

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

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.

1.26.0 — 2026-09-05

1.25.0 — 2026-09-04

1.24.0 — 2026-09-03

1.22.0 — 2026-09-02

1.21.0 — 2026-08-29

1.20.0 — 2026-08-28

1.19.0 — 2026-08-28

1.18.0 — 2026-08-26

1.17.0 — 2026-08-26

1.16.0 — 2026-08-25

1.15.0 — 2026-08-23

1.12.0 — 2026-08-13

1.11.0 — 2026-08-12

1.10.0 — 2026-08-11

1.9.0 — 2026-08-09

Documentation corrections — 2026-08-24 (no surface change)

Documentation corrections — 2026-08-19 (no surface change)

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:

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.

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.