The easyGDS Partner API
A single, product-agnostic REST API your team uses to search inventory and confirm bookings across nine travel products. It lets Ethiopian integrate booking directly into the airline's own booking engine and mobile app and control the customer experience end-to-end, while the easyGDS platform supplies the business logic, product catalogue and supplier connectivity behind one stable contract.
You hold a deposit balance with easyGDS; every booking debits that balance and issues immediately — there is no payment redirect to handle. The request and response shapes follow familiar Duffel-style conventions, so if you have integrated a modern REST travel API before, you are home: point your base URL at easyGDS, send your key, and work through one consistent checkout pipeline. The one deliberate design choice is settlement — a pre-funded balance instead of a hosted card page.
What you can do
- Search flights, hotels, tours, transfers, trains, buses, stopovers, tour packages and car rental.
- Sell flight seats, meals and baggage as part of the booking.
- Book and ticket on behalf of an app or a B2B agent within it.
- Settle from the app or agent deposit balance — book only when funded.
# Staging https://<your-easygds-host> # Local development http://localhost:8020 # Every request carries your key + tenant Authorization: Bearer <your_api_key> X-Tenant-Id: <your_app_id> X-Currency: USD
/v2 contract. Breaking changes ship as /v3;
/v2 stays stable.Authentication
Authenticate every request with the API key easyGDS issues you. The key identifies your app — and, for agent keys, the specific B2B agent — and decides which balance a booking settles against.
Credentials
| Header | Required | Purpose |
|---|---|---|
Authorization: Bearer <key> | Yes* | Your secret API key. X-Api-Key is accepted as an alias. |
X-Tenant-Id | Yes | Your app id. Must match the key’s app. |
X-Currency | No | ISO currency for pricing. Defaults to USD. |
X-Shopping-Id | Checkout | The session token returned by the price-lock step. |
*Send the key with either Authorization: Bearer or X-Api-Key.
App keys vs. agent keys
An app key books against the app balance. An agent key is bound to one B2B agent and books against that agent’s balance. You choose per integration — tell us at onboarding which you need.
# Verify your key — list balances curl https://<your-easygds-host>/balances \ -H "Authorization: Bearer pk_live_8f2c…" \ -H "X-Tenant-Id: bbf70af6-4382-…" \ -H "X-Currency: USD"
import httpx client = httpx.Client( base_url="https://<your-easygds-host>", headers={ "Authorization": "Bearer pk_live_8f2c…", "X-Tenant-Id": "bbf70af6-4382-…", "X-Currency": "USD", }, ) r = client.get("/balances") print(r.json()["data"])
const base = "https://<your-easygds-host>"; const headers = { "Authorization": "Bearer pk_live_8f2c…", "X-Tenant-Id": "bbf70af6-4382-…", "X-Currency": "USD", }; const res = await fetch(`${base}/balances`, { headers }); const { data } = await res.json();
Quickstart: a flight booking
Five calls take you from a search to a ticketed booking. The same checkout pipeline works for every product — only the search differs.
01Search for offers.
02Price-lock the offer you want → get a shoppingId.
03Prebook to re-confirm the locked total.
04Book with traveller + contact details. easyGDS checks your balance, debits it, and issues the ticket.
05Confirm — read the booking and e-ticket.
POST /flights/offer/offer_requests { "data": { "slices": [{ "origin":"ADD", "destination":"DXB", "departure_date":"2026-08-15" }], "passengers": [{ "type":"adult" }], "cabin_class": "economy" } }
{
"data": { "offer_request": {
"id": "9840832ab564…",
"offers": [{
"id": "ET445$2026-08-15…",
"total_amount": "312.00",
"total_currency": "USD",
"slices": [{ "segments": [{
"marketing_carrier": {"iata_code":"ET"},
"marketing_carrier_flight_number": "445",
"departing_at": "2026-08-15T08:15:00",
"duration": "PT4H00M" }] }],
"passengers": [{"id":"adult-0","type":"adult"}]
}]
}}
}
# 02 price-lock the offer → shoppingId POST /shopping/{offerId} { "vertical":"flight", "searchId":"9840832ab564…" } → { "data": { "shoppingId": "4fe96…" } } # 03 prebook (re-confirm the locked total) POST /shopping/prebook # X-Shopping-Id: 4fe96… → { "total_amount": "312.00", "total_currency": "USD" } # 04 book — debits balance, issues ticket POST /shopping/book # X-Shopping-Id: 4fe96… { "vertical": "flight", "contact": { "first_name":"Sara", "last_name":"Tesfaye", "email":"sara@acme.travel", "phone":"+251911223344" }, "travelers": [{ "type":"adult", "title":"Ms", "first_name":"Sara", "last_name":"Tesfaye" }] } → { "booking_reference":"ET7F2A9", "status":"confirmed", "etickets":[{ "ticket_no":"0712…" }] }
Requests & responses
Every successful response wraps its payload in a data envelope.
Requests and responses are JSON; send Content-Type: application/json.
The data envelope
A 2xx response always looks like { "data": … }. Read your result from
data; future fields are added alongside it without breaking you.
Sessions
Checkout is stateful. The price-lock step returns a shoppingId; pass it as
X-Shopping-Id on every later call in that booking.
Polling is handled for you
Supplier searches are asynchronous. Our search endpoints poll upstream and return when results are ready, so a single request is enough.
HTTP/1.1 200 OK { "data": { /* your result */ } }
HTTP/1.1 402 Payment Required { "errorId": "BFF-PAY-INSUFFICIENT", "originSystem": "BFF", "correlationId": "550e8400-e29b-…", "httpStatus": 402, "message": "Balance does not cover the booking", "context": { "available":120.0, "payable":512.4 } }
Errors
Errors use one envelope across every endpoint, so you can handle them in one
place. Branch on errorId (stable, machine-readable), show message
to ops, and log correlationId for support.
| Field | Meaning |
|---|---|
errorId | Stable code, e.g. BFF-PAY-INSUFFICIENT. Branch on this. |
originSystem | BFF (the partner API) or EGDS (the easyGDS booking engine). |
correlationId | Trace id — quote it when you contact support. |
httpStatus | Mirrors the HTTP status. |
context | Optional structured detail (e.g. available vs. payable). |
See the full error code reference.
# Inspect the envelope { "errorId": "BFF-VAL-0010", "originSystem": "BFF", "httpStatus": 400, "message": "Flight book requires `contact`" }
r = client.post("/shopping/book", json=body, headers={"X-Shopping-Id": sid}) if r.is_error: err = r.json() if err["errorId"] == "BFF-PAY-INSUFFICIENT": top_up(err["context"]["payable"]) else: log.error(err["correlationId"], err["message"])
const r = await fetch(`${base}/shopping/book`, opts); if (!r.ok) { const err = await r.json(); if (err.errorId === "BFF-PAY-INSUFFICIENT") await topUp(err.context.payable); else log.error(err.correlationId, err.message); }
Settlement & balance
You pre-fund a deposit balance with easyGDS. Every booking debits that balance — there is no card page and no redirect. This is the one place the API differs from a card-based integration.
How a booking settles
- At
bookwe resolve the payable, then check the balance — the agent balance for an agent key, otherwise the app balance. - Funded → we debit and book/ticket in the same call.
- Short →
402 BFF-PAY-INSUFFICIENTwithavailableandpayableincontext. Nothing is charged; top up and retry.
Payment options
GET /shopping/payment/options always returns the single offline/balance method.
There is nothing for your UI to choose.
GET /shopping/payment/options { "data": [{ "method_id": "offline", "label": "Offline / deposit (balance)", "type": "offline" }]}
HTTP/1.1 402 { "errorId": "BFF-PAY-INSUFFICIENT", "context": { "available": 120.00, "payable": 512.40, "currency": "USD" } }
Nine products, one checkout
Each product has its own search. After you pick an offer, every product flows through the same shopping pipeline and settles the same way.
Duffel-style offers with slices & segments. Sell seats, meals and baggage. Guide →
Destination autocomplete, availability, per-hotel rooms & rates. Guide →
Search by destination and dates; book activity options. Guide →
Airport ↔ property transfers by airport, place and time. Guide →
Rail search between stations, one-way or round trip. Guide →
Coach search between stations — mirrors trains. Guide →
Hotel-shaped stopover packages with room occupancy. Guide →
Multi-day packages and self-drive rental. Guide →
location_id + location_type
(or the per-product *_code / *_type fields). Supported types:
place_id (a numeric area id, e.g. 3168), airport_code
(AUH, LHR, ADD), railway_station (JED, MKX),
and property_id (a specific hotel/property, e.g. 2612). We provision the
codes for your markets at onboarding.Flights
Search returns Duffel-style offers. One slice is one-way; two mirrored
slices are a round trip; two non-mirrored slices — or three to six slices — are a
multi-city / open-jaw trip. Each offer carries priced slices (one per
requested leg), each with segments.
Request
| Field | Notes |
|---|---|
data.slices[] | origin, destination (IATA), departure_date. 1 = one-way; 2 mirrored (A→B, B→A) = round trip; 2 non-mirrored or 3–6 = multi-city (each slice is one dated leg). |
data.passengers[] | type: adult · child · infant_without_seat. |
data.cabin_class | economy · premium_economy · business · first. |
data.max_connections | Optional cap, 0–3. |
data.promotion_code | Optional promo code applied to offer prices.
The response's offer_request.promotion block reports
{code, applied, reason}; an invalid code never fails the search. |
Fare families (branded fares)
Once you price an offer, the response carries fare_families[]
({ upsell_id, code, cabin_class, baggage, … }) when the airline offers
branded fares — it is empty on the raw search offer. Commit one against the cart
(send the X-Shopping-Id from the price step):
Seats, meals & baggage
Fetch the priced catalog for the cart, then commit the selection — both cart-scoped
(X-Shopping-Id). Sellable seats depend on the airline and may be empty in
staging. Re-read the total via /shopping/prebook before booking.
book needs contact and travelers in the body
(title, names, date of birth, and a travel document for international fares). We book, settle
from balance, then issue the e-ticket.POST /flights/offer/offer_requests { "data": { "slices": [ {"origin":"ADD","destination":"NBO","departure_date":"2026-08-01"}, {"origin":"NBO","destination":"JNB","departure_date":"2026-08-03"}, {"origin":"JNB","destination":"ADD","departure_date":"2026-08-07"} ], "passengers": [{"type":"adult"}], "cabin_class": "business" } } # price the offer → shoppingId + fare_families[] POST /flights/offer/offers/{offerId}/actions/price?searchId=… # pick a branded fare (cart-scoped) POST /shopping/flights/select-fare # X-Shopping-Id: … { "upsell_id": "<from fare_families[].upsell_id>" } # fetch + commit seats/baggage POST /shopping/flights/ancillaries # → { ancillaries, seats } POST /shopping/flights/select-ancillaries { "segments": [{ "seats":[…], "ancillaries":[…] }] }
Hotels
Resolve a destination, search availability, then fetch rooms and rates for the hotel a guest picks.
Search request
| Field | Notes |
|---|---|
check_in_date / check_out_date | ISO dates. |
guests[] | One object per guest; age null = adult. |
rooms | Room count. Guests are spread across rooms. |
location_id + location_type | An easyGDS place_id (e.g. 3168). See place codes. |
nationality | Guest nationality (ISO-2); affects rates. |
promotion_code | Optional promo code applied to result prices.
The response's promotion block reports
{code, applied, reason}; an invalid code never fails the search. |
# 1 — search availability (destination is a place_id) POST /hotels/search { "location_id": "3168", "location_type": "place_id", "check_in_date": "2026-10-14", "check_out_date": "2026-10-16", "guests": [{"age":null}], "rooms": 1, "nationality": "et" } → { "data": { "search_id":"…", "results":[ { "id":"2139774", … } ] } } # 2 — rooms & rates for a hotel (pass its `id`) GET /hotels/search/{searchId}/rooms?hotel_id=2139774
Tours & activities
Search experiences by destination and date window. Resolve a destination with
/activities/places, or send a name and we resolve it.
| Field | Notes |
|---|---|
destination / location_id | Place name (auto-resolved) or a resolved id. |
date_from / date_to | ISO dates. date_to defaults to date_from. |
pax | Number of travellers. |
POST /bff/v2/activities/offer_request { "location_id": "3168", "location_type": "place_id", "date_from": "2026-08-19", "date_to": "2026-08-20", "pax": 1 } → { "data": { "offer_request": { "id":"…", "offers":[ … ] } } }
Transfers
Airport ↔ property transfers. Give the airport, the property, the direction, and a pickup time.
| Field | Notes |
|---|---|
airport_code | The airport (e.g. LHR). |
from_airport | true = airport → property; false = property → airport. |
place_code + place_type | The property, e.g. 2612 / property_id. |
pickup_date / pickup_time | Pickup date and HH:MM. |
passengers | Traveller count. |
A TAP-style pickup/dropoff pair also works — the airport side maps to airport_code.
POST /transports/search { "airport_code": "LHR", "from_airport": true, "place_code": "2612", "place_type": "property_id", "pickup_date": "2026-08-19", "pickup_time": "14:00", "passengers": 1 }
Stopover, train, bus, tour packages & car rental
easyGDS extends the four core products with four more. Each searches via its
own offer_request and checks out through the same pipeline.
Stopover hotel-shaped
A destination plus check-in/out and room occupancy — like hotels, with rooms under
/stopover/search/{searchId}/rooms.
Bus mirrors train
Same railway_station place codes and request/response shape as trains —
it is a separately-activatable product, so an app can enable trains, buses, or both.
Tour packages
Destination plus a start date; offers carry selectable package options.
Car rental
Pickup (and optional different dropoff), pickup/dropoff date-times, and driver age.
# Stopover (airport_code destination) POST /bff/v2/stopover/offer_request { "location_id":"AUH", "location_type":"airport_code", "checkin_date":"2026-09-16", "checkout_date":"2026-09-17", "guests":[{"age":null},{"age":null}], "rooms":1 } # Train (railway_station codes, round trip) POST /bff/v2/trains/offer_request { "departure_code":"JED", "arrival_code":"MKX", "from_date":"2026-09-16", "return_date":"2026-09-18", "round_trip":true, "passengers":1 } # Bus (same shape as trains — separately-activatable product) POST /bff/v2/buses/offer_request { "departure_code":"JED", "arrival_code":"MKX", "from_date":"2026-09-16", "return_date":"2026-09-18", "round_trip":true, "passengers":1 } # Tour package POST /bff/v2/tour_packages/offer_request { "location_id":"3168", "location_type":"place_id", "date_from":"2026-08-19", "pax":1 } # Car rental POST /bff/v2/car_rental/offer_request { "pickup":{"location_id":"LHR","type":"airport_code"}, "pickup_datetime":"2026-09-10T10:00", "dropoff_datetime":"2026-09-12T10:00", "driver_age":35 }
The shopping pipeline
Once you have an offer, every product checks out the same way. Carry the
shoppingId from the first step as X-Shopping-Id through the rest.
| Step | Call | Does |
|---|---|---|
| Price-lock | POST /shopping/{offerId} | Locks price, opens a session, returns shoppingId. |
| Upsell | POST /shopping/upsell | Add a line item (multi-product carts). |
| Fare family | POST /shopping/flights/select-fare | Flight only — pick a branded fare (upsell_id); reprices. |
| Ancillaries | POST /shopping/flights/{ancillaries,select-ancillaries} | Flight only — priced seat/baggage catalog + commit. |
| Prebook | POST /shopping/prebook | Re-confirm the locked total. |
| Travellers | POST /shopping/travelers | Pin traveller details on the session. |
| Contact | POST /shopping/contact | Pin the contact block. |
| Fields | GET /shopping/options | Supplier-required fields to collect. |
| Book | POST /shopping/book | Balance-check, debit, book + ticket. |
| Status | GET /shopping/payment-status | Settlement status by transaction. |
| Booking | GET /shopping/bookings | The confirmed booking + tickets. |
contact and travelers in the book body.
For other products, set them with /shopping/travelers and /shopping/contact
first, then book.# after search, you have offerId + searchId POST /shopping/{offerId} { "vertical":"hotel", "searchId":"…" } → shoppingId # carry X-Shopping-Id from here on POST /shopping/travelers { "travelers":[ … ] } POST /shopping/contact { "contact":{ … } } POST /shopping/prebook POST /shopping/book { "vertical":"hotel", "accept_tnc":true } GET /shopping/bookings?transaction_id=…
Endpoint reference
| Method | Path | Description |
|---|---|---|
| GET | /balances | App / agent balance. |
| POST | /flights/offer/offer_requests | Flight search. |
| POST | /flights/offer/offers/{id}/actions/price | Price an offer (+ ancillaries) → shoppingId. |
| GET | /flights/offer/seat_maps | Seat maps (where the airline exposes them). |
| GET | /hotels/places | Destination autocomplete. |
| POST | /hotels/search | Hotel availability. |
| GET | /hotels/search/{id}/rooms | Rooms & rates for a hotel. |
| POST | /bff/v2/activities/offer_request | Tours & activities search. |
| POST | /transports/search | Transfer search. |
| POST | /bff/v2/trains/offer_request | Train search. |
| POST | /bff/v2/buses/offer_request | Bus search (mirrors trains). |
| POST | /bff/v2/stopover/offer_request | Stopover search. |
| POST | /bff/v2/tour_packages/offer_request | Tour-package search. |
| POST | /bff/v2/car_rental/offer_request | Car-rental search. |
| POST | /shopping/{offerId} | Price-lock + open session. |
| POST | /shopping/flights/select-fare | Flight: pick a branded fare family. |
| POST | /shopping/flights/ancillaries · /shopping/flights/select-ancillaries | Flight: seat/baggage catalog + commit. |
| POST | /shopping/prebook | Re-confirm total. |
| POST | /shopping/travelers · /shopping/contact | Pin traveller / contact. |
| GET | /shopping/options | Required checkout fields. |
| GET | /shopping/payment/options | Offline / balance method. |
| POST | /shopping/book | Balance-settle + book + ticket. |
| GET | /shopping/payment-status · /shopping/bookings | Settlement / booking status. |
Machine-readable OpenAPI per product:
GET /documentation/{flights|hotels|activities|transports}/openapi.yaml.
Error codes
| errorId | HTTP | Meaning & fix |
|---|---|---|
BFF-AUTH-0001 | 401 | Missing or invalid API key. Check the Authorization header. |
BFF-AUTH-0002 | 403 | X-Tenant-Id doesn’t match the key’s app. |
BFF-VAL-0010 | 400 | Request validation failed. message says which field. |
BFF-PAY-INSUFFICIENT | 402 | Balance won’t cover the booking. See context.available / payable; top up and retry. |
BFF-UPSTREAM-0051 | 404 | Session expired or resource not found. Re-run the search. |
BFF-UPSTREAM-0070 | 502 | Booking engine error. Safe to retry; quote correlationId if it persists. |
BFF-NOTIMPL-0001 | 501 | Capability not yet available (e.g. SSE streaming). |
Going live
Build against staging, then easyGDS issues production credentials and funds your live balance.
- Staging mirrors production wire-for-wire. Inventory is limited — flights return live offers; other products may return empty results when suppliers have no sandbox data.
- Migrating an existing integration? Change only the base URL and key. The request/response shapes match familiar Duffel-style conventions; settlement becomes balance.
- Idempotency & retries. Searches and reads are safe to retry. Treat a
bookthat times out as unknown — read/shopping/bookingsbefore retrying.
✓ Store keys server-side only ✓ Send X-Tenant-Id on every call ✓ Handle 402 BFF-PAY-INSUFFICIENT ✓ Carry X-Shopping-Id through checkout ✓ Read /shopping/bookings to confirm ✓ Log correlationId on every error
partner api