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
A human checkout takes minutes. A hold keeps the quoted price for 20 minutes and charges nothing; the seats you name are checked against the coach and kept from every other partner of ours until you book or release them. A hold does not reach the operator — the seat is secured with the operator when you book.
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":"Mona Ali","phone":"+49 151 23456789","email":"mona@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.
station_ar is the same place in Arabic — what the driver and the operator's office use; print it too. latitude/longitude are given only for a boarding point whose exact position we have verified — every Blue Bus stop, and the stations of other operators checked against the operator's own website and Google Maps — and are otherwise null, never an area's centre. address / address_ar are the verified street address in English and Arabic, under the same rule. destination.station is null when the coach calls at several stops in that city and the traveller may get off at any of them.
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. Every offer and booking names its operator with the same id and logo_url, so you can join on the id.
Seat classes and amenities belong to a departure, not an operator — the same company runs different coaches — so they come with each offer (seat_class, fare_classes, amenities).
Routes
GET /reference/routes lists every city pair with departures seen in the last two days: the operators on it, departures per day, journey time and the weekdays it runs. Fetch it daily and search only these pairs — any other pair returns no offers. One search returns every operator's departures on that route for the day.
1{2 "data": [3 {4 "origin_id": "cty_MSZkeg5…", "origin_name": "Cairo",5 "destination_id": "cty_LZ9wNWT…", "destination_name": "Hurghada",6 "operator_ids": ["opr_9PZPBYZN…", "opr_k2Lw…"],7 "departures_per_day": 28,8 "duration_minutes": { "min": 330, "max": 510 },9 "runs_on": ["mon","tue","wed","thu","fri","sat","sun"],10 "days_checked": ["mon","tue","wed","thu","fri","sat","sun"],11 "last_seen_at": "2026-10-06T00:51:02.000Z"12 }13 ]14}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 "station_ar": "6 اكتوبر - الحصري",9 "latitude": 29.968428, "longitude": 30.938219,10 "address": "Central Axis, El Hosary, 6th of October",11 "address_ar": "المحور المركزي، الحصري، 6 أكتوبر"12 },13 "destination": {14 "name": "Hurghada",15 "station": "El Nasr Street",16 "station_ar": "شارع النصر",17 "latitude": 27.241535, "longitude": 33.828012,18 "address": "El Nasr St, Hurghada",19 "address_ar": "شارع النصر، الغردقة"20 },21 "departure_at": "2026-10-12T02:00:00+03:00", // Egypt time, with Egypt's offset22 "arrival_at": "2026-10-12T09:30:00+03:00",23 "duration_minutes": 450,24 "operator": { "id": "opr_9PZPBYZN…", "name": "Blue Bus", "name_ar": "بلو باص",25 "logo_url": "https://api.shaffl.com/partner/assets/operator/opr_9PZPBYZN…" },26 "seat_class": "Business Prime", // the class net_price is for27 "seats_available": 29,28 "net_price": {29 "amount_minor": 87000, // 870.00 EGP per seat — see Money30 "currency": "EGP"31 },32 "fare_classes": [33 { "name": "Business Prime", "name_ar": "كرسي رجال أعمال مميز", "price": { "amount_minor": 87000, "currency": "EGP" } },34 { "name": "First Prime", "name_ar": "كرسي درجة أولى ممتاز", "price": { "amount_minor": 117500, "currency": "EGP" } }35 ],36 "amenities": ["usb", "entertainment", "refreshments"],37 "expires_at": "2026-10-06T14:18:05.544Z" // 60 min from now38 }39 ],40 "meta": { "count": 25, "currency": "EGP", "offer_ttl_seconds": 3600 }41}| Field | Notes |
|---|---|
id | The offer id. Pass it to /offers/{id}, /holds or /bookings. |
net_price.amount_minor | Your price per seat of the cheapest class, in piastres. |
fare_classes | Every class this departure sells, each with your own price, cheapest first. Most coaches sell one class; some sell two on the same departure — a Blue Bus "Prime" coach has Business Prime and First Prime seats. A booking is for one class and costs that class's price × passengers. |
operator.id | The same id as GET /reference/operators. |
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 operator-specific token may appear — show or ignore it, never fail on it. |
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+03:00" },6 "destination": { "name": "Hurghada", "station": "El Nasr Street",7 "arrives_at": "2026-09-01T08:30:00+03:00" },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
Keep the price for 20 minutes. Nothing is charged.
Use one the moment your customer starts checking out, and release it if they walk away. The seats you name are checked against the coach when you create the hold — a seat that is taken, does not exist or is in another fare class is refused now, not after your customer has paid — and are kept from every other partner of ours until you book, release or the hold expires.
A hold does not reach the operator. The seat is secured with the operator only when you book; until then the operator can still sell it through its own counters, so book promptly once your customer has paid.
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 assigned at booking7 "fare_class": "Business Prime" // optional; default: the named seats' class, else the cheapest8}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 "fare_class": "Business Prime",8 "total": { "amount_minor": 174000, "currency": "EGP" },9 "expires_at": "2026-10-06T14:47:31.000Z",10 "status": "held"11 }12}Then book by passing PH-… as the offer_id — the seats and class you held carry over automatically, the price is the one you held, and it can be booked for the hold's full 20 minutes even if the offer it came from has since expired.
DELETE /holds/PH-… releases it early. Costs nothing, and the seats are free again immediately — the very next GET /offers/{id} shows them. There is nothing to release at the operator, because a hold never reached it.
| 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 "fare_class": "Business Prime", // optional — see fare classes below16 "contact": {17 "name": "Mona Ali", // required — the lead traveller18 "email": "mona@example.com",19 "phone": "+49 151 23456789" // REQUIRED — the operator finds the booking by name + phone20 }21}A booking is for one fare class and costs that class's price × passengers: fare_class if you send it, else the class of the seats you name, else (confirming a hold) the class held, else the cheapest. Seats you leave unnamed are assigned from that class. Naming seats of two classes in one booking is refused — book each class separately.
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 "station_ar": "6 اكتوبر - الحصري",10 "departs_at": "2026-09-01T00:01:00+03:00",11 "latitude": 29.968428, "longitude": 30.938219, // verified boarding point, else null12 "address": "Central Axis, El Hosary, 6th of October",13 "address_ar": "المحور المركزي، الحصري، 6 أكتوبر"14 },15 "destination": {16 "name": "Hurghada",17 "station": "El Nasr Street",18 "station_ar": "شارع النصر",19 "arrives_at": "2026-09-01T08:30:00+03:00",20 "latitude": 27.241535, "longitude": 33.828012,21 "address": "El Nasr St, Hurghada",22 "address_ar": "شارع النصر، الغردقة"23 },24 "duration_minutes": 509,25 "operator": { "id": "opr_9PZPBYZN…", "name": "Blue Bus", "name_ar": "بلو باص",26 "logo_url": "https://api.shaffl.com/partner/assets/operator/opr_9PZPBYZN…" },27 28 "travel_date": "2026-09-01",29 "departure_at": "2026-09-01T00:01:00+03:00",30 "arrival_at": "2026-09-01T08:30:00+03:00",31 "seat_class": "Business Prime",32 33 "boarding": {34 "operator_reference": "2026100600000001", // the operator's own booking number (illustrative)35 "e_tickets": [ // Blue Bus issues one per traveller36 { "passenger_index": 0, "seat": "3", "ticket_code": "0000000-00001",37 "qr": "0000000EXAMPLEQR000000001" }, // encode as a QR code (illustrative)38 { "passenger_index": 1, "seat": "4", "ticket_code": "0000000-00002",39 "qr": "0000000EXAMPLEQR000000002" }40 ]41 },42 43 "seats": 2,44 "passengers": [45 { "full_name": "Mona Ali", "seat": "3", "phone": "+201000000000", "email": null },46 { "full_name": "Omar Khaled", "seat": "4", "phone": null, "email": null }47 ],48 49 "price": {50 "per_seat_minor": 87000,51 "total_minor": 174000,52 "currency": "EGP"53 },54 "total_charged": { "amount_minor": 174000, "currency": "EGP" },55 56 "contact": { "name": "Mona Ali", "email": "mona@example.com", "phone": "+49 151 23456789" },57 "failure": null, // populated when status is "failed"58 "tickets": { // SHAFFL's ticket per traveller — see Tickets59 "version": "9c2f…e41a", // changes whenever a ticket changes60 "updated_at": "2026-08-11T13:33:49.120Z",61 "valid_for_travel": true,62 "list_url": "https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets",63 "pdf_url": "https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets.pdf",64 "passengers": [65 { "passenger_index": 0, "pdf_url": "https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets/0.pdf" },66 { "passenger_index": 1, "pdf_url": "https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets/1.pdf" }67 ]68 },69 "created_at": "2026-08-11T13:33:48.289Z",70 "confirmed_at": "2026-08-11T13:33:49.104Z"71 }72}| Field | Use it for |
|---|---|
id | Your permanent reference to this booking. Show it to the traveller and quote it to support. |
origin.stationdestination.station | A city has several boarding points — this is the one they physically go to. station_ar is the same place in Arabic. |
origin.latitude / longitudeorigin.address / address_ar | The boarding point's exact position and street address, given when we have verified it against the operator (every Blue Bus stop and, for other operators, each station as it is checked); otherwise null, never an area's centre. Looked up when you read the booking, so a station verified later shows here from then on. |
tickets | The ticket to give your traveller. SHAFFL's own ticket, one per traveller, as a PDF — see Tickets. null until the booking is confirmed. |
passengers[].seat | The seat each traveller sits in. null when the operator assigns at boarding. |
boarding.operator_reference | Print it. The operator's own booking number. Every operator hands out its official printed ticket free at its office before departure, to the traveller who gives their name and phone number or this number. |
boarding.e_tickets | For operators that issue e-tickets (today Blue Bus): one per traveller. Print ticket_code and render qr as a QR code — the traveller boards with it. Empty for other operators. In sandbox the values start SBX-. |
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 bookingsTickets
Give your traveller SHAFFL's ticket — the same ticket our own customers get. Please do not design your own: the operator's office and our support both work from this one.
Each traveller gets one page. It carries their name and seat, our booking reference, the operator's booking number, the boarding point with its verified address and a Google Maps link, the departure date and time, the operator's QR e-ticket where the operator issues one (Blue Bus), and the instructions for collecting the operator's official printed ticket free of charge. It is available in English and Arabic (lang=en or lang=ar).
1GET https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets // the list (JSON)2GET https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets.pdf // every traveller, one PDF, a page each3GET https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets/0.pdf // one traveller (passenger_index, from 0)4GET https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets.pdf?lang=ar // in ArabicThe list
1{2 "data": {3 "booking_reference": "PB-MSOR8JZNDE2CA7",4 "status": "confirmed",5 "ready": true,6 "valid_for_travel": true,7 "version": "9c2f…e41a",8 "updated_at": "2026-08-11T13:33:49.120Z",9 "languages": ["en", "ar"],10 "pdf_url": "https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets.pdf",11 "passengers": [12 { "passenger_index": 0, "full_name": "Mona Ali", "seat": "3",13 "ticket_number": "PB-MSOR8JZNDE2CA7-1",14 "pdf_url": "https://api.shaffl.com/partner/v1/bookings/PB-MSOR8JZNDE2CA7/tickets/0.pdf" }15 ],16 "reason": null17 }18}Always current
A ticket is rendered from the booking as it is now every time you fetch it. When anything on it changes — the operator issues its e-ticket, moves or cancels the departure, a boarding point is verified, or we improve the ticket itself — the next download has it, and version changes. We also send booking.ticket_updated, usually within minutes, so you can fetch the new PDF and give it to your traveller. Each PDF carries an ETag; send it back as If-None-Match and you get 304 Not Modified while nothing has changed.
| When | What you get |
|---|---|
| Confirmed | The tickets. valid_for_travel: true. |
| The operator moved the departure | The tickets, with the new departure time printed at the top and in the journey details (you also get trip.rescheduled). |
| The operator cancelled the departure | The tickets, marked Departure cancelled with a notice not to travel on them, and valid_for_travel: false (you also get trip.cancelled). We confirm a cancellation with the operator before we tell you: a departure we can no longer find (it sold out, say) is never reported as cancelled. |
| Still being confirmed | 409 ticket_not_ready with Retry-After. The list says ready: false. |
| Failed | 409 booking_not_confirmed — there is no ticket, and it cost you nothing (any wallet debit was credited back). |
| Sandbox | The same ticket, printed SANDBOX — NOT VALID FOR TRAVEL, with test values (SBX-…). valid_for_travel: false. |
A ticket for your booking shows no price and no payment code: it says it was booked through you and is paid. Your customer paid you, at your price.
Notifications
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 |
booking.ticket_updated | Anything printed on a booking's ticket changed | Fetch the new PDF (data.tickets.pdf_url) and give it to your traveller — data.version is the new ticket version |
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.rescheduled | The operator moved the departure | Reach the traveller before they reach the coach |
trip.cancelled | The operator cancelled the departure — confirmed with the operator by our team, never assumed from a trip we can no longer find | 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}1{2 "id": "7b1d0c3e-…",3 "type": "booking.ticket_updated",4 "created_at": "2026-08-12T09:10:00.000Z",5 "data": {6 "booking_reference": "PB-MSOR8JZNDE2CA7",7 "environment": "LIVE",8 "version": "51aa…07c2",9 "previous_version": "9c2f…e41a",10 "updated_at": "2026-08-12T09:10:00.000Z",11 "valid_for_travel": true,12 "tickets": { "list_url": "…/tickets", "pdf_url": "…/tickets.pdf",13 "passengers": [ { "passenger_index": 0, "pdf_url": "…/tickets/0.pdf" } ] }14 }15}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.
Times
Departure and arrival times are ISO 8601 with Egypt's offset at that moment: 2026-10-12T02:00:00+03:00 is 02:00 in Egypt. Show the digits as they are — that is the time on the traveller's ticket — or convert the exact instant; both are right. Egypt is +03:00 in summer and +02:00 in winter. Timestamps of our own events (created_at, expires_at) are UTC.
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.
1{2 "type": "https://api.shaffl.com/partner/docs/errors#offer_expired",3 "title": "Offer expired",4 "status": 409,5 "code": "offer_expired",6 "detail": "Prices and availability change; search again.",7 "request_id": "0f2c…"8}| 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.
1RateLimit: limit=120, remaining=118, reset=602RateLimit-Policy: 120;w=603X-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.