v1
⌘K
Get API keys
Support
Partner Distribution API · v1

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.

Mapping your cities to ours

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.

DoShow offers to your customer, then book the one they pick
DoSearch again if 60 minutes pass
Don'tStore an offer id as your permanent reference to a departure — it is a quote, like an exchange rate
Don'tTry 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"}}' | jq

Authentication

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

Response · 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.

Keep the secret on your server

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}
FieldTypeWhat to do with it
idstringStore this. It is what /search expects for origin and destination. Opaque and stable.
namestringEnglish name. Match your own city list against this at integration time.
name_arstringArabic name — use it if you serve Arabic-speaking customers.
countrystringISO code. Everything is EG today.
Stations are not cities

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}

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:

kindRender as
SEATA button. Find it in seats[] by matching seat to number for price and availability.
AISLE · EMPTYBlank space — the gap people walk down.
DRIVERThe driver. Always at the front, so it tells you which way the coach faces.
DOOR · STAIRS · WC · TABLEFixed 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}
Seat checks are best-effort — the operator decides

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.

When there is no seat plan

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.

StatusMeans
heldLive. Book it or release it.
consumedAlready turned into a booking.
releasedYou cancelled it.
expiredThe 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}
One fare class per booking

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}
FieldUse it for
idYour permanent reference to this booking. Show it to the traveller and quote it to support.
origin.station
destination.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 / longitude
origin.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.
ticketsThe ticket to give your traveller. SHAFFL's own ticket, one per traveller, as a PDF — see Tickets. null until the booking is confirmed.
passengers[].seatThe seat each traveller sits in. null when the operator assigns at boarding.
boarding.operator_referencePrint 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_ticketsFor 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_minorWhat one seat cost, so you can show a breakdown rather than only a total.
failurenull on success. On a failed booking it carries code and detail — tell your customer something true rather than "booking failed".
confirmed_atWhen the operator confirmed, which can be later than created_at if a retry was involved.

The four answers you must handle

StatusMeaningWhat to do
201Booked and chargedShow the reference, send the traveller their details
402 insufficient_fundsWallet too low. Nothing was booked or charged.Top up. Tell the customer you could not complete it.
409 seats_unavailableTaken while you were booking. Wallet already credited back.Search again and offer an alternative
503 service_unavailableCould 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}
Idempotency-Key — the one rule that prevents double bookings

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 bookings

Tickets

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 Arabic

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

WhenWhat you get
ConfirmedThe tickets. valid_for_travel: true.
The operator moved the departureThe tickets, with the new departure time printed at the top and in the journey details (you also get trip.rescheduled).
The operator cancelled the departureThe 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 confirmed409 ticket_not_ready with Retry-After. The list says ready: false.
Failed409 booking_not_confirmed — there is no ticket, and it cost you nothing (any wallet debit was credited back).
SandboxThe same ticket, printed SANDBOX — NOT VALID FOR TRAVEL, with test values (SBX-…). valid_for_travel: false.
Prices on the ticket

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.

EventFires whenWhy you care
booking.confirmedA booking succeededThe authoritative confirmation, including one that lands after a retry
booking.failedFulfilment failed after chargingYour wallet is already credited back; tell your customer
booking.ticket_updatedAnything printed on a booking's ticket changedFetch the new PDF (data.tickets.pdf_url) and give it to your traveller — data.version is the new ticket version
wallet.low_balanceBalance hits your thresholdBefore bookings start failing — top up
wallet.topped_upWe credited your walletReconcile against your own ledger
trip.rescheduledThe operator moved the departureReach the traveller before they reach the coach
trip.cancelledThe operator cancelled the departure — confirmed with the operator by our team, never assumed from a trip we can no longer findReach 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 prefixcid_sandbox_…cid_live_…
InventoryReal search resultsReal search results
BookingSimulated — no operator is contactedReaches the real operator
WalletPlay credit we give youYour prepaid funds
StatementSandbox ledger onlyLive 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 containsWhat happens
TEST CLASH409 seats_unavailable — someone took the seat while you were booking
TEST FAIL409 provider_rejected — the operator refused
TEST TIMEOUT503 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

  1. Integrate against sandbox. The go-live checklist in your dashboard tracks this — search, a completed booking, a handled failure, and a notification endpoint.
  2. Request production from the Go live tab. We review the account.
  3. We approve it. The Production switch at the top of your dashboard unlocks.
  4. Issue your own live keys from the Keys tab, with the switcher set to Production. You do not need to ask us for them.
  5. 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_minorMeans
47000EGP 470.00
94000EGP 940.00 (two seats at 470)
50EGP 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.

Enter your sandbox keys and send a request — the response appears here.

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}
CodeStatusWhat to do
invalid_client401Check the key pair; it may be revoked
unauthorized401Token missing or expired — mint a new one
insufficient_scope403This credential lacks that permission
invalid_reference404Unknown id, or one issued to another account
insufficient_funds402Top up; nothing was booked
offer_expired409Search again for a fresh quote
seats_unavailable409Taken while booking — search again
idempotency_key_reused422Same key, different body — use a fresh key
request_in_progress409Identical request in flight; retry shortly
rate_limited429Back off; see Retry-After
service_unavailable503Temporary — 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-3976a6fa21c1

Sandbox & 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.

EnvironmentKey prefixBehaviour
Sandboxsk_sandbox_… Real inventory and real prices, simulated fulfilment. Your wallet moves, so you can verify reconciliation end to end. Self-service from the dashboard.
Productionsk_live_… Real seats for real travellers. Issued by your account manager once commercial terms are agreed.
No cancellation, no refunds

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.

Next steps