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.
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.
| Header | Required | Purpose |
|---|---|---|
Authorization: Bearer <token> | Yes | Authenticates the third-party agent account. |
Content-Type: application/json | Yes for requests with a body | Identifies the request as JSON. |
X-External-User-Id | No | Stores your own customer reference with the booking. When supplied, send the same value on every retry for that offer. |
Content-Language | No | Requests localized content when available. The default is en. |
Content-Currency | No | Sets 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:
| Value | How to use it |
|---|---|
offerReference | Review and reserve the selected offer. |
reservationId | Keep for support and error tracking. Do not send it in a price-acceptance retry. |
bookingReference | Give this booking reference to the traveler. |
ticketId | Purchase the reservation and retrieve its issued tickets. |
There are two separate deadlines:
| Deadline | Meaning |
|---|---|
Offer expiresAt | The latest time you may use the selected offer to reserve. |
Reservation paymentDeadlineAt | The 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:
| Field | Meaning |
|---|---|
grandTotal | The selling total for the booking. |
walletDebitTotal | The 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
| Field | Required | Default | Description |
|---|---|---|---|
originLocationCode | Yes | — | Origin airport or city IATA code. |
destinationLocationCode | Yes | — | Destination airport or city IATA code. |
departureDate | Yes | — | Departure date in YYYY-MM-DD format. |
returnDate | No | — | Return date in YYYY-MM-DD format. Omit for a one-way trip. |
adults | No | 1 | Number of travelers aged 12 or older, from 1 to 9. |
children | No | 0 | Number of travelers aged 2–11, from 0 to 9. |
infants | No | 0 | Number of travelers under 2, from 0 to 9. This cannot exceed the number of adults. |
travelClass | No | Any | ECONOMY, PREMIUM_ECONOMY, BUSINESS, or FIRST. |
includedAirlineCodes | No | All | Array of airline IATA codes to include. |
excludedAirlineCodes | No | None | Array of airline IATA codes to exclude. |
nonStop | No | false | Set to true to return only non-stop flights. |
currencyCode | No | Header or USD | Display currency. Overrides Content-Currency. |
maxPrice | No | No limit | Maximum total in the selected display currency. |
max | No | 200 | Maximum results to return, from 1 to 200. |
lookAheadDays | No | — | Opt 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 result | availability.status | lookAhead.status | firstChecked | confirmedThrough | next |
|---|---|---|---|---|---|
| Matching offers returned | RESULTS_FOUND | NOT_NEEDED | null | null | null |
| Earliest chronologically confirmed candidate found | NO_RESULTS | FOUND | First candidate date pair | Same date pair as next | Suggested date pair |
| Every requested candidate confirmed empty | NO_RESULTS | NO_RESULTS | First candidate date pair | Final candidate date pair | null |
| An earlier required candidate failed or timed out | NO_RESULTS | INCOMPLETE | First candidate date pair | Last consecutive confirmed-empty pair, or null | null |
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 requirement | Reservation field |
|---|---|
firstNameRequired | passengers[].firstName |
lastNameRequired | passengers[].lastName |
dateOfBirthRequired | passengers[].dateOfBirth |
genderRequired | passengers[].gender |
documentTypeRequired | passengers[].documentType |
documentNumberRequired | passengers[].passportNumber |
documentExpiryDateRequired | passengers[].passportExpiryDate |
documentIssuingCountryRequired | passengers[].documentIssuingCountry |
nationalityRequired | passengers[].nationality |
emailAddressRequired | passengers[].email |
phoneCountryCodeRequired | passengers[].countryCode |
mobilePhoneNumberRequired or phoneNumberRequired | passengers[].phoneNumber |
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, orINFANTtypes 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.
countryCodeandphoneNumbertogether 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.
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
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
| Response | What the agent integration should do |
|---|---|
| Invalid traveler fields | Correct every field listed in data.fields and retry. |
| Price changed | Present the latest price, obtain approval, and retry with the latest price acceptance. |
FLIGHT_OFFER_EXPIRED | The 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_UNAVAILABLE | The reservation price could not be confirmed. Follow the response's retry guidance or contact Safiri support. |
| Insufficient float balance | Top up the agent wallet and retry before paymentDeadlineAt. |
| Expired offer or reservation | Begin a new search. |
| Request is still processing | Wait briefly and retry the same request. Increase the wait between attempts. |
| Request conflicts with an earlier attempt | Use the same request details as the first attempt or begin a new search. |
| Ticket issue is not confirmed | Preserve 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
- Follow the complete flight booking workflow.
- Download the flight Postman collection.
- Review authentication.