Sell coach travel through your own platform.
One REST API for live availability, net pricing and booking across Egypt's intercity network. You set retail and keep the spread — we handle the operators.
How this API works
Five ideas. If you read only one section, read this one — everything else follows from it.
1 · Cities are ids, never names
You cannot search for "Cairo". Every route is expressed with city ids you fetch once from GET /reference/cities and cache.
Why: "Cairo" is ambiguous (Cairo, New Cairo, Cairo Airport), it is spelled differently in English and Arabic, and matching on text means your integration silently breaks the day we rename a stop. An id does not.
Ids look like cty_kcr_rp4Lnl05dGN4NWhnuI4ag3m7IObX…. They are opaque — there is nothing to parse inside — and stable, so store them against your own cities and reuse them forever. Refresh the list monthly to pick up new destinations.
Fetch the list once, match on name (and name_ar for Arabic), and save our id next to your own city record. Do this at integration time, not on every search — the list changes a few times a year, not a few times a minute.
2 · An offer is a priced quote, not a departure
Search does not return "trips". It returns offers: this specific departure, for you, at this price, right now, valid for 60 minutes.
Why it matters: the same coach has a different price for different partners (your net rate is yours), and availability changes minute to minute. An offer bundles the departure, your price and a moment in time into one thing you can act on. That is why the id is long and opaque — your account and the expiry are sealed inside it, so nobody can book on your rate but you, and nobody can book at a price that has moved.
| Do | Show offers to your customer, then book the one they pick |
| Do | Search again if 60 minutes pass |
| Don't | Store an offer id as your permanent reference to a departure — it is a quote, like an exchange rate |
| Don't | Try to parse or construct one — it is encrypted, and a modified id is refused |
A lapsed offer returns 409 offer_expired. That is normal, not an error condition to alarm anyone about — search again.
3 · A hold pauses the clock
Fifteen minutes is generous for an API call and tight for a human checkout. A hold reserves the price and the seats for 20 minutes and charges nothing.
Use one when your customer still has to enter passenger names or card details. Without it you face a bad choice: book before they have paid — and carry the loss if they abandon, because this API has no cancellation — or let them pay for a seat that may be gone by the time you book it.
4 · You pay from a prepaid wallet
There is no card and no invoice at booking time. You pre-fund a balance in EGP; every booking debits it at your net rate. You sell at whatever price you like and keep the difference.
If the balance cannot cover a booking you get 402 insufficient_funds and nothing is reserved. Watch the balance in your dashboard and subscribe to wallet.low_balance so you hear about it before a customer does.
5 · Money never gets stuck
Booking charges your wallet and confirms the seats together. If the operator rejects it after we have charged you, we credit you back automatically before answering and return the booking as failed. You are never charged for a booking you did not get.
And if a request times out, retry it with the same Idempotency-Key: you get the original answer back, never a second booking.
Your first booking in five calls
Copy this whole block. With sandbox keys from the dashboard it runs end to end and books a real sandbox seat.
1# ── 0 ─ your sandbox credentials ─────────────────────────────2CLIENT_ID=cid_sandbox_xxxxxxxxxxxx3CLIENT_SECRET=sk_sandbox_xxxxxxxxxxxx4API=BASE5 6# ── 1 ─ get a token (valid 1 hour) ───────────────────────────7TOKEN=$(curl -s -X POST $API/oauth/token \8 -H 'Content-Type: application/json' \9 -d "{\"client_id\":\"$CLIENT_ID\",\"client_secret\":\"$CLIENT_SECRET\"}" \10 | jq -r .access_token)11 12# ── 2 ─ find the city ids you need (do this ONCE, then cache) ─13curl -s $API/reference/cities -H "Authorization: Bearer $TOKEN" \14 | jq -r '.data[] | select(.name==\"Cairo\" or .name==\"Hurghada\") | \"\\(.name)\\t\\(.id)\"'15 16# → Cairo cty_kcr_rp4Lnl05dGN4NWhnuI4ag3m7IObX…17# → Hurghada cty_LHrkVv7ptIVoZIacyfdZVpoN80cPFivJ…18 19# ── 3 ─ search (paste the two ids from step 2) ───────────────20OFFER=$(curl -s -X POST $API/search -H "Authorization: Bearer $TOKEN" \21 -H 'Content-Type: application/json' \22 -d '{"origin":"cty_CAIRO_ID","destination":"cty_HURGHADA_ID",23 "departure_date":"2026-09-01","passengers":1}' \24 | jq -r '.data[0].id')25 26# ── 4 ─ look at the seats (optional, but this is where the map is)27curl -s $API/offers/$OFFER -H "Authorization: Bearer $TOKEN" | jq '.data.seats[0:3]'28 29# ── 5 ─ book it. Idempotency-Key is REQUIRED ─────────────────30curl -s -X POST $API/bookings -H "Authorization: Bearer $TOKEN" \31 -H 'Content-Type: application/json' \32 -H "Idempotency-Key: $(uuidgen)" \33 -d '{"offer_id":"'$OFFER'",34 "passengers":[{"full_name":"Mona Ali","seat":"3"}],35 "contact":{"name":"Your Booking Desk","email":"desk@example.com"}}' | jqAuthentication
OAuth 2.0 client credentials. Exchange your key pair for a bearer token, then send it on every call. Tokens last one hour. Mint a new one when it expires; there is no refresh token and you do not need one.
Request
1curl -sX POST https://api.shaffl.com/partner/v1/oauth/token \2 -H "Content-Type: application/json" \3 -d '{4 "grant_type": "client_credentials",5 "client_id": "cid_sandbox_84a49ff745daea89742e93bb",6 "client_secret": "sk_sandbox_…"7 }'1const r = await fetch("https://api.shaffl.com/partner/v1/oauth/token", {2 method: "POST",3 headers: { "Content-Type": "application/json" },4 body: JSON.stringify({5 grant_type: "client_credentials",6 client_id: process.env.SHAFFL_CLIENT_ID,7 client_secret: process.env.SHAFFL_CLIENT_SECRET,8 }),9});10const { access_token } = await r.json(); // cache it for expires_in seconds1import os, requests2 3r = requests.post(4 "https://api.shaffl.com/partner/v1/oauth/token",5 json={6 "grant_type": "client_credentials",7 "client_id": os.environ["SHAFFL_CLIENT_ID"],8 "client_secret": os.environ["SHAFFL_CLIENT_SECRET"],9 },10 timeout=15,11)12access_token = r.json()["access_token"] # cache it for expires_in secondsResponse · 200
1{2 "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",3 "token_type": "Bearer",4 "expires_in": 3600, // seconds — one hour5 "scope": "inventory.read wallet.read booking.write",6 "environment": "SANDBOX" // or LIVE — check this in your logs7}Send it as Authorization: Bearer <access_token> on everything else.
A client secret can book travel against your wallet. It belongs in your backend, never in a browser or a mobile app. You can hold two credentials at once, so you can rotate without downtime.
Cities and stations
The first call of any integration. Fetch once, store our id against your own city, reuse forever.
Request
1GET https://api.shaffl.com/partner/v1/reference/cities2Authorization: Bearer <token>Response · 200
1{2 "data": [3 {4 "id": "cty_kcr_rp4Lnl05dGN4NWhnuI4ag3m7IObXgZGTUxqZ…",5 "name": "Cairo",6 "name_ar": "القاهرة",7 "country": "EG"8 },9 {10 "id": "cty_LHrkVv7ptIVoZIacyfdZVpoN80cPFivJ6m_M-A5V…",11 "name": "Hurghada",12 "name_ar": "الغردقة",13 "country": "EG"14 }15 // … 121 cities in total16 ]17}| Field | Type | What to do with it |
|---|---|---|
id | string | Store this. It is what /search expects for origin and destination. Opaque and stable. |
name | string | English name. Match your own city list against this at integration time. |
name_ar | string | Arabic name — use it if you serve Arabic-speaking customers. |
country | string | ISO code. Everything is EG today. |
A city usually has several boarding points. You do not choose one when searching — you search city to city, and each offer tells you which station it departs from and arrives at, in origin.station and destination.station. Show that to your traveller: it is where they physically go.
Operators
GET /reference/operators lists the coach companies, for filtering or showing a logo.
1{2 "data": [3 {4 "id": "opr_9PZPBYZN_YVth…",5 "name": "SuperJet",6 "name_ar": "سوبر جيت",7 "logo_url": "https://api.shaffl.com/partner/assets/operator/opr_9PZPBYZN…"8 }9 ]10}logo_url is null when we have no logo for that operator — render your own placeholder rather than a broken image.
Searching
Request
1POST https://api.shaffl.com/partner/v1/search2Authorization: Bearer <token>3Content-Type: application/json4 5{6 "origin": "cty_kcr_rp4Lnl05dGN4…", // city id, NOT a name7 "destination": "cty_LHrkVv7ptIVoZIac…",8 "departure_date": "2026-09-01", // YYYY-MM-DD, the local departure date9 "passengers": 2 // 1–10; filters out coaches without room10}Response · 200
1{2 "data": [3 {4 "id": "off_kcqaogpKJnmLZ_dl-We1Hr7Gy3IrCNoOi5G8VyQyWgS0…",5 "origin": {6 "name": "Cairo",7 "station": "6 October - El Hussary" // where they board8 },9 "destination": {10 "name": "Hurghada",11 "station": "El Nasr Street"12 },13 "departure_at": "2026-09-01T00:01:00.000Z", // UTC, always14 "arrival_at": "2026-09-01T08:30:00.000Z",15 "duration_minutes": 509,16 "operator": { "name": "Blue Bus", "name_ar": "بلو باص" },17 "seat_class": "Comfort",18 "seats_available": 40,19 "net_price": {20 "amount_minor": 47000, // 470.00 EGP — see Money21 "currency": "EGP"22 },23 "amenities": ["usb", "wifi"],24 "expires_at": "2026-08-11T14:18:05.544Z" // 60 min from now25 }26 ],27 "meta": { "count": 25, "currency": "EGP", "offer_ttl_seconds": 3600 }28}| Field | Notes |
|---|---|
id | The offer id. Pass it to /offers/{id}, /holds or /bookings. |
net_price.amount_minor | Your price, per seat, in piastres. Multiply by passengers for the total. |
duration_minutes | null when the operator does not publish a reliable arrival time. Don't display "0h" — hide it. |
seats_available | At the moment of searching. It moves; do not treat it as a promise. |
amenities | Stable tokens: usb, wifi, toilet, air_conditioning, entertainment, refreshments, blanket, extra_legroom, power_socket. |
An empty data array means no coaches on that route and date. It is a normal answer, not an error.
Seats and seat maps
GET /offers/{id} returns everything you need to draw a seat picker: live availability, per-seat pricing, and the coach as a grid.
It re-reads the coach live on every call, so what you show is what exists right now — a cached map would sell a seat that has already gone.
Response · 200 (abridged)
1{2 "data": {3 "id": "off_kcqaogpKJnmLZ…",4 "origin": { "name": "Cairo", "station": "6 October - El Hussary",5 "departs_at": "2026-09-01T00:01:00.000Z" },6 "destination": { "name": "Hurghada", "station": "El Nasr Street",7 "arrives_at": "2026-09-01T08:30:00.000Z" },8 "duration_minutes": 509,9 "operator": { "name": "Blue Bus", "name_ar": "بلو باص" },10 "vehicle": { "capacity": 44, "label": "Comfort" },11 "seats_available": 40,12 "seat_selection": true, // false → book without seats13 "net_price": { "amount_minor": 47000, "currency": "EGP" },14 15 "fare_classes": [16 { "name": "Comfort", "price": { "amount_minor": 47000, "currency": "EGP" },17 "seats_available": 40, "features": ["usb"] }18 ],19 20 "seats": [21 { "number": "1", // this is what you send when booking22 "available": true,23 "price": { "amount_minor": 47000, "currency": "EGP" },24 "class": "Comfort",25 "features": ["usb"],26 "accessible": false, // wheelchair-accessible27 "premium": false,28 "position": { "row": 1, "column": 0 } }29 // … 44 seats30 ],31 32 "seat_map": {33 "layout": "coach_4across_44",34 "rows": 13, "columns": 5, "seat_count": 44,35 "cells": [36 { "row": 0, "column": 1, "kind": "DRIVER", "seat": null },37 { "row": 1, "column": 0, "kind": "SEAT", "seat": "1" },38 { "row": 1, "column": 2, "kind": "AISLE", "seat": null }39 // … 65 cells40 ]41 },42 "amenities": ["usb"],43 "expires_at": "2026-08-11T13:33:05.544Z"44 }45}Drawing the coach
Lay the cells on a rows × columns grid. For each cell, look at kind:
| kind | Render as |
|---|---|
SEAT | A button. Find it in seats[] by matching seat to number for price and availability. |
AISLE · EMPTY | Blank space — the gap people walk down. |
DRIVER | The driver. Always at the front, so it tells you which way the coach faces. |
DOOR · STAIRS · WC · TABLE | Fixed furniture. Not selectable. |
1// the whole renderer, in fifteen lines2const byNumber = Object.fromEntries(detail.seats.map(s => [s.number, s]));3const byCell = Object.fromEntries(detail.seat_map.cells.map(c => [c.row + ":" + c.column, c]));4 5for (let r = 0; r < detail.seat_map.rows; r++) {6 for (let c = 0; c < detail.seat_map.columns; c++) {7 const cell = byCell[r + ":" + c];8 if (!cell || cell.kind === "AISLE" || cell.kind === "EMPTY") { drawGap(); continue; }9 if (cell.kind !== "SEAT") { drawFurniture(cell.kind); continue; }10 11 const seat = byNumber[cell.seat];12 drawSeat({ label: seat.number, selectable: seat.available, price: seat.price });13 }14}We reject a seat that does not exist or is already taken at the moment you book, which catches the common case. It cannot be atomic: between that check and the operator confirming, another booking can take the seat.
So handle 409 seats_unavailable as a normal outcome, not an exception. Your wallet is credited back automatically before we answer, and you can search again and offer an alternative.
Some operators assign seats at boarding. Then seat_map is null and seat_selection is false. Book without seats — do not block your customer waiting for a plan that will never come.
Holds
Reserve the price and the seats for 20 minutes. Nothing is charged.
Use one the moment your customer starts checking out, and release it if they walk away.
Create · request
1POST https://api.shaffl.com/partner/v1/holds2 3{4 "offer_id": "off_kcqaogpKJnmLZ…",5 "seats": 2,6 "seats_requested": ["7", "8"] // optional; omit to have seats assigned7}Response · 201
1{2 "data": {3 "id": "PH-MSOR8JBOC3CD7B", // use this where an offer id goes4 "offer_id": "off_kcqaogpKJnmLZ…",5 "seats": 2,6 "seats_requested": ["7", "8"],7 "total": { "amount_minor": 94000, "currency": "EGP" },8 "expires_at": "2026-08-11T14:47:31.000Z",9 "status": "held"10 }11}Then book by passing PH-… as the offer_id — the seats you named carry over automatically, and the price is the one you held.
DELETE /holds/PH-… releases it early. Costs nothing, frees the seats for someone else.
| Status | Means |
|---|---|
held | Live. Book it or release it. |
consumed | Already turned into a booking. |
released | You cancelled it. |
expired | The 20 minutes passed. Search again. |
Booking
One call. It charges your wallet and confirms the seats together.
Request
1POST https://api.shaffl.com/partner/v1/bookings2Authorization: Bearer <token>3Idempotency-Key: 6f1c9e2a-3b4d-… ← REQUIRED. Any unique value per attempt.4Content-Type: application/json5 6{7 "offer_id": "off_kcqaogpKJnmLZ…", // or a hold: \"PH-MSOR8J…\"8 "passengers": [9 { "full_name": "Mona Ali", // required10 "seat": "3", // optional; from seats[].number11 "phone": "+201000000000", // optional12 "email": "mona@example.com" }, // optional13 { "full_name": "Omar Khaled", "seat": "4" }14 ],15 "contact": {16 "name": "Cairo Travel Desk", // required — who we call about a disruption17 "email": "desk@example.com",18 "phone": "+201000000001"19 }20}Response · 201 confirmed
Everything needed to render a ticket is here — you do not need to keep your search result and join it back.
1{2 "data": {3 "id": "PB-MSOR8JZNDE2CA7", // YOUR permanent reference. Store it.4 "status": "confirmed",5 6 "origin": {7 "name": "Cairo",8 "station": "6 October - El Hussary", // where the traveller boards9 "departs_at": "2026-09-01T00:01:00.000Z"10 },11 "destination": {12 "name": "Hurghada",13 "station": "El Nasr Street",14 "arrives_at": "2026-09-01T08:30:00.000Z"15 },16 "duration_minutes": 509,17 "operator": { "name": "Blue Bus", "name_ar": "بلو باص" },18 19 "travel_date": "2026-09-01",20 "departure_at": "2026-09-01T00:01:00.000Z",21 "arrival_at": "2026-09-01T08:30:00.000Z",22 "seat_class": "Comfort",23 24 "seats": 2,25 "passengers": [26 { "full_name": "Mona Ali", "seat": "3", "phone": "+201000000000", "email": null },27 { "full_name": "Omar Khaled", "seat": "4", "phone": null, "email": null }28 ],29 30 "price": {31 "per_seat_minor": 47000,32 "total_minor": 94000,33 "currency": "EGP"34 },35 "total_charged": { "amount_minor": 94000, "currency": "EGP" },36 37 "contact": { "name": "Cairo Travel Desk", "email": "desk@example.com", "phone": null },38 "failure": null, // populated when status is "failed"39 "created_at": "2026-08-11T13:33:48.289Z",40 "confirmed_at": "2026-08-11T13:33:49.104Z"41 }42}| Field | Use it for |
|---|---|
id | Your permanent reference to this booking. Show it to the traveller and quote it to support. |
origin.stationdestination.station | Print these. A city has several boarding points — this is the one they physically go to. |
passengers[].seat | The seat each traveller sits in. null when the operator assigns at boarding. |
price.per_seat_minor | What one seat cost, so you can show a breakdown rather than only a total. |
failure | null on success. On a failed booking it carries code and detail — tell your customer something true rather than "booking failed". |
confirmed_at | When the operator confirmed, which can be later than created_at if a retry was involved. |
The four answers you must handle
| Status | Meaning | What to do |
|---|---|---|
| 201 | Booked and charged | Show the reference, send the traveller their details |
402 insufficient_funds | Wallet too low. Nothing was booked or charged. | Top up. Tell the customer you could not complete it. |
409 seats_unavailable | Taken while you were booking. Wallet already credited back. | Search again and offer an alternative |
503 service_unavailable | Could not reach the operator. Wallet untouched or credited back. | Retry with the same Idempotency-Key. |
Response · 409 failed
1{2 "type": "https://api.shaffl.com/partner/docs/errors#provider_rejected",3 "title": "Booking rejected",4 "status": 409,5 "code": "provider_rejected",6 "detail": "The operator rejected this booking. Your wallet has been credited back.",7 "request_id": "5dfee7f3-cc78-4a76-…"8}Generate one value per booking attempt (a UUID is ideal) and send it. If anything goes wrong — timeout, dropped connection, your own crash — retry with the exact same value. You get the original response back, whether that was a success or a failure. You cannot double-book, and you cannot double-charge.
Use a different body under a key you already used and you get 422 idempotency_key_reused. That means a bug on your side — you reused a key for a different journey.
Retrieving bookings
1GET https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7 // one booking2GET https://api.shaffl.com/partner/v1/bookings?limit=50 // your recent bookingsNotifications
We call your endpoint when something happens, so you never poll. Add one in the dashboard and copy the signing secret it shows once.
| Event | Fires when | Why you care |
|---|---|---|
booking.confirmed | A booking succeeded | The authoritative confirmation, including one that lands after a retry |
booking.failed | Fulfilment failed after charging | Your wallet is already credited back; tell your customer |
wallet.low_balance | Balance hits your threshold | Before bookings start failing — top up |
wallet.topped_up | We credited your wallet | Reconcile against your own ledger |
trip.rescheduledtrip.cancelled | The operator changed or cancelled | Reach the traveller before they reach the coach |
What we send
1POST https://your-api.example.com/shaffl/webhooks2webhook-id: 4f2c9e1a-… // SAME across retries — dedupe on it3webhook-timestamp: 1786894000 // reject anything older than ~5 min4webhook-signature: v1,K5s8… // base64 HMAC-SHA256 of \"id.timestamp.body\"5Content-Type: application/json6 7{8 "id": "4f2c9e1a-…",9 "type": "booking.confirmed",10 "created_at": "2026-08-11T13:33:48.289Z",11 "data": { "booking": { "id": "PB-MSOR8JZNDE2CA7", "status": "confirmed", /* … */ } }12}Reply with any 2xx as soon as you have stored it, then process it. Anything else is retried with backoff over roughly 24 hours; an endpoint that fails 25 times in a row is paused, and you can resume or replay individual deliveries from the dashboard.
Test and production
Two completely separate worlds. Same API, same code, different keys — and different money.
| Test (sandbox) | Production (live) | |
|---|---|---|
| Key prefix | cid_sandbox_… | cid_live_… |
| Inventory | Real search results | Real search results |
| Booking | Simulated — no operator is contacted | Reaches the real operator |
| Wallet | Play credit we give you | Your prepaid funds |
| Statement | Sandbox ledger only | Live ledger only |
The two wallets never touch. Testing cannot spend the money you have prepaid, and a live booking cannot be paid for with test credit. Your sandbox balance and your live balance are separate numbers with separate statements — this is why you can keep integrating after you go live without it costing you anything.
The environment is decided entirely by which key you authenticate with. There is no environment parameter to pass and no header to set: a sandbox key can only ever reach sandbox, whatever else it asks for. POST /oauth/token tells you which one you are on:
1{2 "access_token": "eyJ…",3 "token_type": "Bearer",4 "expires_in": 3600,5 "scope": "inventory.read wallet.read booking.write",6 "environment": "SANDBOX" // or "LIVE" — check this in your own logs7}Rehearsing failures
Sandbox exposes the failure modes on purpose, triggered by passenger name, so you can build error handling against real behaviour rather than hoping:
| Passenger name contains | What happens |
|---|---|
TEST CLASH | 409 seats_unavailable — someone took the seat while you were booking |
TEST FAIL | 409 provider_rejected — the operator refused |
TEST TIMEOUT | 503 service_unavailable — retry with the same Idempotency-Key |
In every case your wallet is credited back, so you can check your reconciliation handles a reversal correctly.
Going live
- Integrate against sandbox. The go-live checklist in your dashboard tracks this — search, a completed booking, a handled failure, and a notification endpoint.
- Request production from the Go live tab. We review the account.
- We approve it. The Production switch at the top of your dashboard unlocks.
- Issue your own live keys from the Keys tab, with the switcher set to Production. You do not need to ask us for them.
- Fund your live wallet. It starts at zero and stays there until funds actually arrive — a live booking with an empty wallet is declined rather than sold on credit.
A live key issued before your first real booking still fulfils in sandbox until we open the final gate with you. That is deliberate: it means your first production smoke test cannot accidentally buy a seat.
Money
Every amount is an integer in minor units — piastres. There are no decimals anywhere in this API.
amount_minor | Means |
|---|---|
47000 | EGP 470.00 |
94000 | EGP 940.00 (two seats at 470) |
50 | EGP 0.50 |
Divide by 100 to display. Never store it as a float. This is deliberate — a rounding error in a price nobody notices becomes a reconciliation dispute at month end.
Your wallet
1GET https://api.shaffl.com/partner/v1/wallet2 3{4 "data": {5 "balance": { "amount_minor": 965000, "currency": "EGP" },6 "credit_limit": { "amount_minor": 0, "currency": "EGP" },7 "low_balance_threshold": { "amount_minor": 50000, "currency": "EGP" },8 "updated_at": "2026-08-11T13:17:31.000Z"9 }10}GET /wallet/transactions gives you the statement — every debit tied to a booking reference, every credit back from a failure.
Try it live
Paste your sandbox credentials and call the real API from this page. Nothing is stored — the values stay in this browser tab.
Errors
Failures use RFC 9457 application/problem+json. Key off code — it is stable. title and detail are for humans and may be reworded.
{
"type": "https://api.shaffl.com/partner/docs/errors#offer_expired",
"title": "Offer expired",
"status": 409,
"code": "offer_expired",
"detail": "Prices and availability change; search again.",
"request_id": "0f2c…"
}| Code | Status | What to do |
|---|---|---|
invalid_client | 401 | Check the key pair; it may be revoked |
unauthorized | 401 | Token missing or expired — mint a new one |
insufficient_scope | 403 | This credential lacks that permission |
invalid_reference | 404 | Unknown id, or one issued to another account |
insufficient_funds | 402 | Top up; nothing was booked |
offer_expired | 409 | Search again for a fresh quote |
seats_unavailable | 409 | Taken while booking — search again |
idempotency_key_reused | 422 | Same key, different body — use a fresh key |
request_in_progress | 409 | Identical request in flight; retry shortly |
rate_limited | 429 | Back off; see Retry-After |
service_unavailable | 503 | Temporary — retry with the same key |
Every response carries X-Request-Id. Quote it to support and we can trace the exact call.
Rate limits
Responses carry RateLimit and RateLimit-Policy, plus the legacy X-RateLimit-* headers most clients read. Search is the expensive path and has a tighter budget than reference data.
RateLimit: limit=120, remaining=118, reset=60
RateLimit-Policy: 120;w=60
X-Request-Id: b6e8ff5f-fa8d-488e-8767-3976a6fa21c1Sandbox & production
Two environments, told apart by the key prefix — so a misconfigured deployment fails loudly instead of quietly booking real seats. The same code works against both; only the credentials change.
| Environment | Key prefix | Behaviour |
|---|---|---|
| Sandbox | sk_sandbox_… |
Real inventory and real prices, simulated fulfilment. Your wallet moves, so you can verify reconciliation end to end. Self-service from the dashboard. |
| Production | sk_live_… |
Real seats for real travellers. Issued by your account manager once commercial terms are agreed. |
This API has no endpoint to cancel a booking or move money back, by design. Disruptions — a coach cancelled, a service not running — are handled by your account manager, who settles any adjustment against your wallet.