easyGDS partner api
← Back to proposal v2 · staging
Partner API · integration reference · Ethiopian Holidays

Search, book and ticket
travel from one balance.

One REST API to integrate booking into Ethiopian's own booking engine and mobile app — flights, hotels, tours, transfers, trains, buses, stopovers, packages and car rental, settled from your easyGDS balance. No card leg, no redirect.

9Products
1Auth header
/v2Wire version
1Checkout pipeline
Introduction

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.
BASE URL
# 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
Wire version All paths are served under the /v2 contract. Breaking changes ship as /v3; /v2 stays stable.
Getting started

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

HeaderRequiredPurpose
Authorization: Bearer <key>Yes*Your secret API key. X-Api-Key is accepted as an alias.
X-Tenant-IdYesYour app id. Must match the key’s app.
X-CurrencyNoISO currency for pricing. Defaults to USD.
X-Shopping-IdCheckoutThe session token returned by the price-lock step.

*Send the key with either Authorization: Bearer or X-Api-Key.

Keep keys secret API keys carry the power to spend your balance. Use them only from your server. Never ship a key in a browser, mobile app, or public repo. Rotate immediately if exposed.

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.

cURL
# 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();
Getting started

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.

Async search Flight and hotel inventory streams in. The search endpoint polls upstream for you and returns once results are ready — a single call, no looping on your side.
STEP 01 — SEARCH
POST /flights/offer/offer_requests

{
  "data": {
    "slices": [{ "origin":"ADD", "destination":"DXB",
                "departure_date":"2026-08-15" }],
    "passengers": [{ "type":"adult" }],
    "cabin_class": "economy"
  }
}
RESPONSE
{
  "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"}]
    }]
  }}
}
STEPS 02–04 — CHECKOUT
# 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…" }] }
Core concepts

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.

SUCCESS
HTTP/1.1 200 OK

{
  "data": { /* your result */ }
}
ERROR
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 }
}
Core concepts

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.

FieldMeaning
errorIdStable code, e.g. BFF-PAY-INSUFFICIENT. Branch on this.
originSystemBFF (the partner API) or EGDS (the easyGDS booking engine).
correlationIdTrace id — quote it when you contact support.
httpStatusMirrors the HTTP status.
contextOptional structured detail (e.g. available vs. payable).

See the full error code reference.

cURL
# 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);
}
Core concepts

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 book we 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.
  • Short402 BFF-PAY-INSUFFICIENT with available and payable in context. 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.

No automatic refunds Failed or cancelled bookings are reconciled by operations, not auto-refunded to balance. Build for that — surface failures to your team rather than assuming an instant reversal.
PAYMENT OPTIONS
GET /shopping/payment/options

{ "data": [{
  "method_id": "offline",
  "label": "Offline / deposit (balance)",
  "type": "offline"
}]}
INSUFFICIENT BALANCE
HTTP/1.1 402

{
  "errorId": "BFF-PAY-INSUFFICIENT",
  "context": {
    "available": 120.00,
    "payable":   512.40,
    "currency":  "USD"
  }
}
Products

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.

Flights

Duffel-style offers with slices & segments. Sell seats, meals and baggage. Guide →

Hotels

Destination autocomplete, availability, per-hotel rooms & rates. Guide →

Tours & activities

Search by destination and dates; book activity options. Guide →

Transfers

Airport ↔ property transfers by airport, place and time. Guide →

Trains

Rail search between stations, one-way or round trip. Guide →

Buses

Coach search between stations — mirrors trains. Guide →

Stopover

Hotel-shaped stopover packages with room occupancy. Guide →

Tour packages & car rental

Multi-day packages and self-drive rental. Guide →

Place codes & types Destinations are easyGDS place codes, passed as 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.
Products · Flights

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.

POST/flights/offer/offer_requests

Request

FieldNotes
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_classeconomy · premium_economy · business · first.
data.max_connectionsOptional cap, 0–3.
data.promotion_codeOptional 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):

POST/shopping/flights/select-fare

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.

POST/shopping/flights/ancillaries
POST/shopping/flights/select-ancillaries
Booking flights Flight 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.
MULTI-CITY + FARE FAMILY
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":[…] }] }
Products · Hotels

Hotels

Resolve a destination, search availability, then fetch rooms and rates for the hotel a guest picks.

GET/hotels/places?q=Addis+Ababa
POST/hotels/search
GET/hotels/search/{searchId}/rooms?hotel_id=…

Search request

FieldNotes
check_in_date / check_out_dateISO dates.
guests[]One object per guest; age null = adult.
roomsRoom count. Guests are spread across rooms.
location_id + location_typeAn easyGDS place_id (e.g. 3168). See place codes.
nationalityGuest nationality (ISO-2); affects rates.
promotion_codeOptional promo code applied to result prices. The response's promotion block reports {code, applied, reason}; an invalid code never fails the search.
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
Products · Tours & activities

Tours & activities

Search experiences by destination and date window. Resolve a destination with /activities/places, or send a name and we resolve it.

POST/bff/v2/activities/offer_request
GET/activities/places?q=…
FieldNotes
destination / location_idPlace name (auto-resolved) or a resolved id.
date_from / date_toISO dates. date_to defaults to date_from.
paxNumber of travellers.
SEARCH
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":[ … ] } } }
Products · Transfers

Transfers

Airport ↔ property transfers. Give the airport, the property, the direction, and a pickup time.

POST/transports/search
FieldNotes
airport_codeThe airport (e.g. LHR).
from_airporttrue = airport → property; false = property → airport.
place_code + place_typeThe property, e.g. 2612 / property_id.
pickup_date / pickup_timePickup date and HH:MM.
passengersTraveller count.

A TAP-style pickup/dropoff pair also works — the airport side maps to airport_code.

SEARCH
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
}
Products · Stopover · Package · Car

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.

SEARCHES
# 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 }
Checkout

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.

StepCallDoes
Price-lockPOST /shopping/{offerId}Locks price, opens a session, returns shoppingId.
UpsellPOST /shopping/upsellAdd a line item (multi-product carts).
Fare familyPOST /shopping/flights/select-fareFlight only — pick a branded fare (upsell_id); reprices.
AncillariesPOST /shopping/flights/{ancillaries,select-ancillaries}Flight only — priced seat/baggage catalog + commit.
PrebookPOST /shopping/prebookRe-confirm the locked total.
TravellersPOST /shopping/travelersPin traveller details on the session.
ContactPOST /shopping/contactPin the contact block.
FieldsGET /shopping/optionsSupplier-required fields to collect.
BookPOST /shopping/bookBalance-check, debit, book + ticket.
StatusGET /shopping/payment-statusSettlement status by transaction.
BookingGET /shopping/bookingsThe confirmed booking + tickets.
Flight vs. the rest For flights, pass contact and travelers in the book body. For other products, set them with /shopping/travelers and /shopping/contact first, then book.
FULL CHECKOUT
# 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=…
Reference

Endpoint reference

MethodPathDescription
GET/balancesApp / agent balance.
POST/flights/offer/offer_requestsFlight search.
POST/flights/offer/offers/{id}/actions/pricePrice an offer (+ ancillaries) → shoppingId.
GET/flights/offer/seat_mapsSeat maps (where the airline exposes them).
GET/hotels/placesDestination autocomplete.
POST/hotels/searchHotel availability.
GET/hotels/search/{id}/roomsRooms & rates for a hotel.
POST/bff/v2/activities/offer_requestTours & activities search.
POST/transports/searchTransfer search.
POST/bff/v2/trains/offer_requestTrain search.
POST/bff/v2/buses/offer_requestBus search (mirrors trains).
POST/bff/v2/stopover/offer_requestStopover search.
POST/bff/v2/tour_packages/offer_requestTour-package search.
POST/bff/v2/car_rental/offer_requestCar-rental search.
POST/shopping/{offerId}Price-lock + open session.
POST/shopping/flights/select-fareFlight: pick a branded fare family.
POST/shopping/flights/ancillaries · /shopping/flights/select-ancillariesFlight: seat/baggage catalog + commit.
POST/shopping/prebookRe-confirm total.
POST/shopping/travelers · /shopping/contactPin traveller / contact.
GET/shopping/optionsRequired checkout fields.
GET/shopping/payment/optionsOffline / balance method.
POST/shopping/bookBalance-settle + book + ticket.
GET/shopping/payment-status · /shopping/bookingsSettlement / booking status.

Machine-readable OpenAPI per product: GET /documentation/{flights|hotels|activities|transports}/openapi.yaml.

Reference

Error codes

errorIdHTTPMeaning & fix
BFF-AUTH-0001401Missing or invalid API key. Check the Authorization header.
BFF-AUTH-0002403X-Tenant-Id doesn’t match the key’s app.
BFF-VAL-0010400Request validation failed. message says which field.
BFF-PAY-INSUFFICIENT402Balance won’t cover the booking. See context.available / payable; top up and retry.
BFF-UPSTREAM-0051404Session expired or resource not found. Re-run the search.
BFF-UPSTREAM-0070502Booking engine error. Safe to retry; quote correlationId if it persists.
BFF-NOTIMPL-0001501Capability not yet available (e.g. SSE streaming).
Reference

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 book that times out as unknown — read /shopping/bookings before retrying.
Need access? Email partners@easygds.com with your app, whether you need app or agent keys, and your products. We’ll set up staging and your deposit balance.
CHECKLIST
 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