Skip to main content

Flight Booking

Use this API to search for a flight, review its current price and booking requirements, reserve it for a traveler, purchase it from your float wallet, and retrieve the issued tickets.

Your integration must use an active third-party agent account with enough float balance to complete the purchase.

Flight booking flow from search through issued ticket retrieval

Base URL

Use the development environment while building your integration:

https://booking-api-dev.safiri.app

All examples on this page use that environment.

Authentication and headers

Every flight request requires a bearer token obtained from POST /api/logon.

HeaderRequiredPurpose
Authorization: Bearer <token>YesAuthenticates the third-party agent account.
Content-Type: application/jsonYes for requests with a bodyIdentifies the request as JSON.
X-External-User-IdNoStores your own customer reference with the booking. When supplied, send the same value on every retry for that offer.
Content-LanguageNoRequests localized content when available. The default is en.
Content-CurrencyNoSets the display currency when currencyCode is not supplied in the search request. The default is USD.

If both currencyCode and Content-Currency are supplied, the search request's currencyCode takes precedence.

Identifiers and deadlines

Keep each identifier for its intended purpose:

ValueHow to use it
offerReferenceReview and reserve the selected offer.
reservationIdKeep for support and error tracking. Do not send it in a price-acceptance retry.
bookingReferenceGive this booking reference to the traveler.
ticketIdPurchase the reservation and retrieve its issued tickets.

There are two separate deadlines:

DeadlineMeaning
Offer expiresAtThe latest time you may use the selected offer to reserve.
Reservation paymentDeadlineAtThe latest time you may purchase the reservation.

Always use the returned timestamps. Do not assume or hard-code either duration.

Pricing

The offer price contains two totals that matter to an agent:

FieldMeaning
grandTotalThe selling total for the booking.
walletDebitTotalThe amount that will be deducted from your float wallet.

Creating a reservation does not deduct any money. When the purchase succeeds, the tickets are issued, walletDebitTotal is deducted from the agent's float wallet, and final customer ticket notifications are sent unless the reservation set notifications.ticketPurchase to false.

1. Get the airport stops

Fetch the airport list before searching so the agent can choose valid departure and arrival locations:

GET https://gtfs-flight.safiri.app/api/stops

curl --request GET \
--url "https://gtfs-flight.safiri.app/api/stops"
[
{
"stop_id": "JRO:FLT:0",
"stop_code": "JRO",
"stop_lat": -3.42941,
"stop_lon": 37.0745,
"stop_name": "JRO Kilimanjaro International Airport",
"stop_desc": "Kilimanjaro, Tanzania",
"stop_timezone": "Africa/Dar_es_Salaam",
"city_geohash": "kz6k7x",
"popularity": 30
},
{
"stop_id": "DAR:FLT:0",
"stop_code": "DAR",
"stop_lat": -6.87811,
"stop_lon": 39.202599,
"stop_name": "DAR Julius Nyerere International Airport",
"stop_desc": "Dar es Salaam, Tanzania",
"stop_timezone": "Africa/Dar_es_Salaam",
"city_geohash": "kygbu5",
"popularity": 75
}
]

Use this response to power an airport search or selector. The stop_code field is the airport IATA code. You can also get the code from the three-letter prefix of stop_id if needed. For example, either use the stop_code value JRO directly or extract JRO from JRO:FLT:0.

Send the resulting code as originLocationCode or destinationLocationCode. Do not send the complete stop_id in the flight search.

2. Search for flights

  • POST /api/flight/search
curl --request POST \
--url https://booking-api-dev.safiri.app/api/flight/search \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--header "Content-Currency: USD" \
--data '{
"originLocationCode": "JRO",
"destinationLocationCode": "DAR",
"departureDate": "2027-02-15",
"adults": 1,
"children": 0,
"infants": 0,
"travelClass": "ECONOMY",
"nonStop": false,
"currencyCode": "USD",
"max": 10
}'

Search fields

FieldRequiredDefaultDescription
originLocationCodeYesOrigin airport or city IATA code.
destinationLocationCodeYesDestination airport or city IATA code.
departureDateYesDeparture date in YYYY-MM-DD format.
returnDateNoReturn date in YYYY-MM-DD format. Omit for a one-way trip.
adultsNo1Number of travelers aged 12 or older, from 1 to 9.
childrenNo0Number of travelers aged 2–11, from 0 to 9.
infantsNo0Number of travelers under 2, from 0 to 9. This cannot exceed the number of adults.
travelClassNoAnyECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST.
includedAirlineCodesNoAllArray of airline IATA codes to include.
excludedAirlineCodesNoNoneArray of airline IATA codes to exclude.
nonStopNofalseSet to true to return only non-stop flights.
currencyCodeNoHeader or USDDisplay currency. Overrides Content-Currency.
maxPriceNoNo limitMaximum total in the selected display currency.
maxNo200Maximum results to return, from 1 to 200.
lookAheadDaysNoOpt in to checking later date pairs after an empty requested-date result. Send a JSON integer from 1 through 7; the value is the exact number of later dates checked.

Do not put the same airline code in both airline filter arrays.

If flight search is temporarily unavailable, the API returns 502 with FLIGHT_SEARCH_UNAVAILABLE. When data.retryable is true, wait briefly and try the same search again. Increase the wait between attempts. When it is false, keep the error code and message and contact Safiri support. A successful search with an empty result simply means that no matching flights were found.

A compact response looks like this:

{
"meta": {
"count": 1
},
"data": [
{
"offerReference": "9e57df84-b41a-4b4e-bdac-35aa9e55a3cb",
"expiresAt": "2027-02-01T12:15:00.000Z",
"price": {
"currency": "USD",
"total": "185.00",
"grandTotal": "195.00",
"walletDebitTotal": "180.00"
},
"itineraries": [
{
"duration": "PT1H10M",
"segments": [
{
"departure": {
"iataCode": "JRO",
"at": "2027-02-15T08:15:00"
},
"arrival": {
"iataCode": "DAR",
"at": "2027-02-15T09:25:00"
},
"carrierCode": "PW",
"number": "417"
}
]
}
],
"travelerPricings": [
{
"travelerId": "1",
"travelerType": "ADULT"
}
]
}
]
}

Retain the offerReference from the offer the agent selects and do not attempt to reserve it after its expiresAt. When no flights match the search, the API returns a successful empty result:

{
"meta": {
"count": 0
},
"data": []
}

Show the agent that no matching flights were found and let them change the search.

Check later dates after an empty result

Add lookAheadDays when the UI should offer an explicit nearby-date search after the requested dates return no matching offers:

curl --request POST \
--url https://booking-api-dev.safiri.app/api/flight/search \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"originLocationCode": "JRO",
"destinationLocationCode": "DAR",
"departureDate": "2027-02-15",
"returnDate": "2027-02-22",
"adults": 1,
"travelClass": "ECONOMY",
"nonStop": false,
"currencyCode": "USD",
"max": 10,
"lookAheadDays": 7
}'

For a round trip, each candidate shifts both dates by the same number of calendar days, preserving the requested trip length. For a one-way search, candidate date objects contain only departureDate. The API returns 400 if the requested horizon would shift either date beyond 2099-12-31.

When the requested dates are empty and the first matching candidate is four days later, the response is:

{
"meta": {
"count": 0,
"availability": {
"status": "NO_RESULTS",
"requested": {
"departureDate": "2027-02-15",
"returnDate": "2027-02-22"
},
"lookAhead": {
"requestedDays": 7,
"status": "FOUND",
"firstChecked": {
"departureDate": "2027-02-16",
"returnDate": "2027-02-23"
},
"confirmedThrough": {
"departureDate": "2027-02-19",
"returnDate": "2027-02-26"
}
},
"next": {
"departureDate": "2027-02-19",
"returnDate": "2027-02-26"
}
}
},
"data": []
}

The availability matrix is:

Requested-date resultavailability.statuslookAhead.statusfirstCheckedconfirmedThroughnext
Matching offers returnedRESULTS_FOUNDNOT_NEEDEDnullnullnull
Earliest chronologically confirmed candidate foundNO_RESULTSFOUNDFirst candidate date pairSame date pair as nextSuggested date pair
Every requested candidate confirmed emptyNO_RESULTSNO_RESULTSFirst candidate date pairFinal candidate date pairnull
An earlier required candidate failed or timed outNO_RESULTSINCOMPLETEFirst candidate date pairLast consecutive confirmed-empty pair, or nullnull

An auxiliary candidate failure produces the successful INCOMPLETE state; it does not replace the already-successful requested-date search with a 5xx. Validation, authentication, and requested-date provider failures still use their normal non-2xx responses and do not start look-ahead.

When lookAheadDays is omitted, meta.availability is omitted and the existing response envelope is unchanged. When it is supplied, meta.availability is present even if the requested dates return offers. meta.count and data always describe the originally requested dates.

The look-ahead response contains no candidate offers and creates no offerReference. If lookAhead.status is FOUND, show the suggested dates and ask the agent whether to search them. After confirmation, clone the original POST body, replace only departureDate and, for a round trip, returnDate from next, preserve every passenger and flight filter including lookAheadDays, and submit a new exact search. Only an offer returned by that follow-up search can be selected and reviewed.

Treat all YYYY-MM-DD values as provider-local calendar dates. Do not parse or shift them through UTC. Use “Today” or “Tomorrow” only when the UI has a verified timezone for the origin airport; otherwise display a localized form of the returned calendar date.

Use neutral messages because an empty result can be caused by the active airline, cabin, nonstop, price, or ticketing filters:

  • FOUND: “No matching flights were found for these dates. Matching flights were found for [date pair].”
  • NO_RESULTS: “No matching flights were found in the dates checked.”
  • INCOMPLETE: “No matching flights were found for these dates. We couldn’t finish checking nearby dates.”

Do not describe these states as sold out, a route that cannot operate, or a guarantee that the suggested offer remains bookable.

3. Review the selected offer

POST /api/flight/offer

Send only the selected offerReference:

curl --request POST \
--url https://booking-api-dev.safiri.app/api/flight/offer \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"offerReference": "9e57df84-b41a-4b4e-bdac-35aa9e55a3cb"
}'

The response confirms the current offer, its expiry, and the traveler details needed to reserve it:

{
"offerReference": "9e57df84-b41a-4b4e-bdac-35aa9e55a3cb",
"expiresAt": "2027-02-01T12:15:00.000Z",
"flightOffer": {
"price": {
"currency": "USD",
"grandTotal": "195.00",
"walletDebitTotal": "180.00"
}
},
"bookingRequirements": {
"journeyType": "INTERNATIONAL",
"operatingCarrierCodes": ["KQ"],
"emailAddressRequired": true,
"phoneCountryCodeRequired": true,
"mobilePhoneNumberRequired": true,
"phoneNumberRequired": true,
"travelerRequirements": [
{
"travelerId": "1",
"travelerType": "ADULT",
"firstNameRequired": true,
"lastNameRequired": true,
"dateOfBirthRequired": true,
"genderRequired": true,
"documentRequired": true,
"documentTypeRequired": true,
"documentNumberRequired": true,
"documentExpiryDateRequired": true,
"documentIssuingCountryRequired": true,
"nationalityRequired": true
}
]
}
}

Check expiresAt before displaying the offer. Use the returned requirements to decide which fields to show on your reservation form:

Booking requirementReservation field
firstNameRequiredpassengers[].firstName
lastNameRequiredpassengers[].lastName
dateOfBirthRequiredpassengers[].dateOfBirth
genderRequiredpassengers[].gender
documentTypeRequiredpassengers[].documentType
documentNumberRequiredpassengers[].passportNumber
documentExpiryDateRequiredpassengers[].passportExpiryDate
documentIssuingCountryRequiredpassengers[].documentIssuingCountry
nationalityRequiredpassengers[].nationality
emailAddressRequiredpassengers[].email
phoneCountryCodeRequiredpassengers[].countryCode
mobilePhoneNumberRequired or phoneNumberRequiredpassengers[].phoneNumber
important

Use bookingRequirements as the traveler checklist for the selected offer. Read it after every offer review and collect every field marked true before reserving. Requirements can differ by itinerary, airline, and traveler, so do not hard-code one universal passenger form.

Each item in travelerRequirements applies to the traveler with the same travelerId in flightOffer.travelerPricings. Preserve the adult, child, and infant counts from the search when building passengers. You do not need to send travelerId or bookingRequirements in the reservation request; use them to decide which fields to collect.

Only the current requirements are returned. If a requirement is not present, you do not need that field at this stage. Requirements can change between offers, so check them every time.

Domestic and international requirements

For a domestic itinerary, adult travelers usually do not require date of birth, gender, or travel-document details. Children and infants still normally require date of birth.

International itineraries normally require date of birth, gender, nationality, and the complete travel-document details for every traveler. A journey is treated as international if any part of it crosses a country border. This means a journey can require international details even when its first and final airports are in the same country.

An airline may still require document details for a domestic journey. Always collect every field marked true in bookingRequirements.

The optional contact object lets you provide one email address and phone number for the whole booking. Safiri uses these shared values only when a passenger does not have their own contact details. Every passenger must have an email address and a valid international phone number, either in their passenger details or in contact.

4. Create the reservation

POST /api/flight/reserve/agent

curl --request POST \
--url https://booking-api-dev.safiri.app/api/flight/reserve/agent \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--header "X-External-User-Id: CUSTOMER-1042" \
--data '{
"offerReference": "9e57df84-b41a-4b4e-bdac-35aa9e55a3cb",
"passengers": [
{
"firstName": "Asha",
"lastName": "Mushi",
"email": "asha.mushi@example.com",
"countryCode": "255",
"phoneNumber": "712345678",
"dateOfBirth": "1990-05-12",
"gender": "FEMALE",
"ageCategory": "ADULT",
"documentType": "PASSPORT",
"passportNumber": "TZ1234567",
"passportExpiryDate": "2030-05-12",
"documentIssuingCountry": "TZ",
"nationality": "TZ"
}
],
"selectedOption": "NON_REFUNDABLE_FARE",
"notifications": {
"safiriAccountCreation": true,
"ticketPurchase": true,
"allOtherTripNotification": true
}
}'

selectedOption defaults to NON_REFUNDABLE_FARE. All notification settings default to true; include notifications only when you want to change them. Keep the selected option and notification settings unchanged when retrying the same offer.

For third-party agent reservations, Safiri never sends customer reservation, unpaid-reservation reminder, reservation-cancellation, or other non-confirmation notifications on any supported channel. These messages stay suppressed regardless of ticketPurchase or allOtherTripNotification. After purchase confirms the booking, final ticket notifications are sent by default; set ticketPurchase to false to suppress every supported final-ticket delivery channel.

Omit X-External-User-Id on every attempt or send the same value on every retry. Changing or adding it after the first reservation attempt returns FLIGHT_RESERVATION_REQUEST_CONFLICT.

Traveler checklist

Before sending the request, verify that:

  • Passenger counts and ADULT, CHILD, or INFANT types match the search.
  • Each infant has an accompanying adult.
  • Dates use YYYY-MM-DD.
  • Country and nationality values use valid two-letter ISO codes.
  • Travel documents remain valid through the final arrival date.
  • countryCode and phoneNumber together form a valid international number.
  • Each passenger has the fields required by bookingRequirements.
  • Names and document details match the travel document.

Use readable values such as ADULT, CHILD, INFANT, MALE, FEMALE, and PASSPORT.

note

Names must use 1–30 Latin letters, apostrophes, spaces, or hyphens. Document numbers must use 3–20 Latin letters or digits. The passenger's age on the travel date must match ADULT, CHILD, or INFANT. For an adult traveling with an infant, correct any combined-name validation error returned by the API.

Successful reservation

{
"reservation": {
"reservationId": "6f55d0ab-b65a-48e9-bcee-ecb702232b1f",
"bookingReference": "SF7K2P",
"paymentDeadlineAt": "2027-02-01T12:30:00.000Z",
"ticketIds": [
"6be55963-070f-4389-bc83-2a9300dbf9f4"
]
},
"tickets": [
{
"ticketId": "6be55963-070f-4389-bc83-2a9300dbf9f4",
"bookingReference": "SF7K2P",
"from": "JRO",
"to": "DAR",
"price": {
"total": "195.00",
"currency": "USD"
},
"passengerSummary": {
"fullName": "Asha Mushi"
},
"paymentDeadlineAt": "2027-02-01T12:30:00.000Z"
}
]
}

Retain reservationId, bookingReference, ticketId, and paymentDeadlineAt.

Reservation responses that need action

Reservation result decision tree showing success, missing details, changed price, and expiry

Missing or invalid traveler details

The API returns status 422 and identifies each field that must be corrected:

{
"errorCode": "FLIGHT_PASSENGER_DATA_INVALID",
"errorMessage": "Correct the passenger details before reserving this flight",
"status": 422,
"data": {
"fields": [
{
"travelerId": "1",
"field": "dateOfBirth",
"message": "dateOfBirth must be a valid YYYY-MM-DD date"
},
{
"travelerId": "1",
"field": "phoneNumber",
"message": "phoneNumber is required"
}
]
}
}

Use travelerId and field to show each data.fields[] message beside the correct traveler field. Correct every listed value, then send the reservation request again.

Airline requests additional details

An airline can request information that was not required during offer review. The API then returns status 422, updated bookingRequirements, and the fields that must be added:

{
"errorCode": "FLIGHT_ADDITIONAL_INFORMATION_REQUIRED",
"errorMessage": "The airline requires additional passenger information",
"status": 422,
"data": {
"fields": [
{
"travelerId": "1",
"field": "passportNumber",
"message": "passportNumber is required"
}
],
"bookingRequirements": {
"journeyType": "DOMESTIC",
"emailAddressRequired": true,
"phoneCountryCodeRequired": true,
"phoneNumberRequired": true,
"travelerRequirements": [
{
"travelerId": "1",
"travelerType": "ADULT",
"firstNameRequired": true,
"lastNameRequired": true,
"dateOfBirthRequired": true,
"genderRequired": true,
"documentRequired": true,
"documentTypeRequired": true,
"documentNumberRequired": true,
"documentExpiryDateRequired": true,
"documentIssuingCountryRequired": true,
"nationalityRequired": true
}
]
}
}
}

Use the new data.bookingRequirements, show every data.fields[] message beside the matching traveler, collect the additional values, and send the original reservation details again with the same offerReference. Do not create another reservation.

Price changed

If the price changes while reserving, the API returns status 409 with the latest price:

{
"errorCode": "FLIGHT_PRICE_CHANGED",
"errorMessage": "The airline price or fare conditions changed. Confirm the new price to continue.",
"status": 409,
"data": {
"reservationId": "6f55d0ab-b65a-48e9-bcee-ecb702232b1f",
"previousPrice": {
"grandTotal": "195.00",
"currency": "USD"
},
"currentPrice": {
"grandTotal": "204.50",
"currency": "USD"
},
"priceVersion": "price-v2",
"expiresAt": "2027-02-01T12:20:00.000Z"
}
}

Show the new currentPrice to the traveler and obtain their approval. If they approve before expiresAt, repeat the complete original reservation request and add:

{
"priceAcceptance": {
"priceVersion": "<latest priceVersion>",
"amount": "<currentPrice.grandTotal>",
"currency": "<currentPrice.currency>"
}
}

Do not send reservationId in this retry. Never accept a changed price automatically. If another price change is returned, present the latest price and obtain approval again.

Agent action table

ResponseWhat the agent integration should do
Invalid traveler fieldsCorrect every field listed in data.fields and retry.
Price changedPresent the latest price, obtain approval, and retry with the latest price acceptance.
FLIGHT_OFFER_EXPIREDThe selected offer is no longer available to reserve. Its cached quote has already been discarded, so start a new search rather than retrying the same offerReference. data.reason names the provider refusal that retired it.
FLIGHT_REPRICE_UNAVAILABLEThe reservation price could not be confirmed. Follow the response's retry guidance or contact Safiri support.
Insufficient float balanceTop up the agent wallet and retry before paymentDeadlineAt.
Expired offer or reservationBegin a new search.
Request is still processingWait briefly and retry the same request. Increase the wait between attempts.
Request conflicts with an earlier attemptUse the same request details as the first attempt or begin a new search.
Ticket issue is not confirmedPreserve all identifiers and contact Safiri support. Do not create another reservation.

Check both the response status and errorCode. A 202 response means the request is still being handled; wait briefly, then send the same request again.

An integration or gateway in front of Booking API must preserve the HTTP status, errorCode, errorMessage, and data.retryable values. It must not replace these with a generic detail.error: "booking_failed" response.

Do not create a second reservation while the first request is still being handled.

5. Purchase the reservation

Purchase before paymentDeadlineAt using only the saved ticketId.

POST /api/flight/purchase/agent

curl --request POST \
--url https://booking-api-dev.safiri.app/api/flight/purchase/agent \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"ticketId": "6be55963-070f-4389-bc83-2a9300dbf9f4"
}'

Safiri tries to issue every ticket in the reservation. A 200 response means the tickets were issued successfully. Safiri then deducts walletDebitTotal from the agent's float wallet and sends final customer ticket notifications unless the reservation set notifications.ticketPurchase to false:

{
"reservationId": "6f55d0ab-b65a-48e9-bcee-ecb702232b1f",
"status": "ISSUED",
"bookingReference": "SF7K2P",
"documents": [
{
"travelerId": "1",
"passengerReference": 1,
"ticketNumber": "1234567890123",
"status": "ISSUED"
}
]
}

If the request is still being handled, wait briefly and send the same purchase request again. If ticket issue is not confirmed, keep the reservationId, bookingReference, and ticketId and contact support.

6. Retrieve the issued tickets

GET /api/flight/tickets?ticketId=...

curl --request GET \
--url "https://booking-api-dev.safiri.app/api/flight/tickets?ticketId=6be55963-070f-4389-bc83-2a9300dbf9f4" \
--header "Authorization: Bearer $SAFIRI_TOKEN"
[
{
"ticketId": "6be55963-070f-4389-bc83-2a9300dbf9f4",
"reservationId": "6f55d0ab-b65a-48e9-bcee-ecb702232b1f",
"bookingReference": "SF7K2P",
"ticketNumber": "1234567890123",
"issuanceStatus": "ISSUED",
"passengerDetails": {
"fullName": "Asha Mushi"
},
"from": "JRO",
"to": "DAR",
"departureDate": "2027-02-15",
"departureTime": "08:15",
"price": {
"total": "195.00",
"currency": "USD"
}
}
]

Display the booking reference and issued ticket details to the traveler.

Protect traveler data

Passenger, contact, and travel-document values are sensitive. Do not save them in logs, analytics, or unprotected error reports. If you need records for support, save only offerReference, reservationId, bookingReference, and ticketId.

Next steps