v1
⌘K
Get API keys
Support OpenAPI
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

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

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.

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:

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

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.

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  "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}
FieldUse it for
idYour permanent reference to this booking. Show it to the traveller and quote it to support.
origin.station
destination.station
Print these. A city has several boarding points — this is the one they physically go to.
passengers[].seatThe seat each traveller sits in. null when the operator assigns at boarding.
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

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
wallet.low_balanceBalance hits your thresholdBefore bookings start failing — top up
wallet.topped_upWe credited your walletReconcile against your own ledger
trip.rescheduled
trip.cancelled
The operator changed or cancelledReach 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 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.

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.

{}JSON
{
  "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…"
}
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.

HTTP
RateLimit: limit=120, remaining=118, reset=60
RateLimit-Policy: 120;w=60
X-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