Flight Booking Workflow for Third-Party Agents
This guide connects the flight endpoints into one safe booking flow. For every booking:
- Fetch the airport stops and let the agent select valid IATA codes.
- Search, handle an optional nearby-date suggestion, and save the selected
offerReferenceonly when the exact search returns an offer. - Review the offer, its
expiresAt, and itsbookingRequirements. - Collect each traveler's required identity and contact details, then reserve.
- Resolve any field errors or obtain approval for a changed price.
- Purchase with
ticketIdbeforepaymentDeadlineAt, then retrieve the issued tickets.
Use the development environment:
export SAFIRI_API="https://booking-api-dev.safiri.app"
export SAFIRI_FLIGHT_STOPS="https://gtfs-flight.safiri.app"
export SAFIRI_TOKEN="YOUR_AGENT_TOKEN"
Endpoint sequence
Fetch airport stops
curl --request GET \
--url "$SAFIRI_FLIGHT_STOPS/api/stops"
Use each airport's stop_code for originLocationCode and
destinationLocationCode. You can also extract the three-letter prefix from
stop_id if needed. For example, use JRO directly from stop_code or
extract it from JRO:FLT:0.
Search
POST /api/flight/search
curl --request POST \
--url "$SAFIRI_API/api/flight/search" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"originLocationCode": "JRO",
"destinationLocationCode": "DAR",
"departureDate": "2027-02-15",
"adults": 1,
"currencyCode": "USD",
"max": 10
}'
Save the offerReference from the selected result. If data is empty, do not
continue to offer review and do not manufacture or reuse an
offerReference.
Handle an empty search
To let the agent choose a nearby date, add a numeric lookAheadDays from 1
through 7 to the original search. The value is the exact number of later date
pairs checked. Omitting it preserves the existing response envelope.
{
"originLocationCode": "JRO",
"destinationLocationCode": "DAR",
"departureDate": "2027-02-15",
"returnDate": "2027-02-22",
"adults": 1,
"currencyCode": "USD",
"max": 10,
"lookAheadDays": 7
}
The requested dates still own meta.count and data. Look-ahead never embeds
candidate offers and never creates an offerReference:
availability.status | lookAhead.status | Action |
|---|---|---|
RESULTS_FOUND | NOT_NEEDED | Let the agent select one of the requested-date offers. firstChecked, confirmedThrough, and next are null. |
NO_RESULTS | FOUND | Explain that no matching flights were found for the requested dates, show next, and ask whether to run a new search. |
NO_RESULTS | NO_RESULTS | Explain that no matching flights were found in the dates checked. next is null. |
NO_RESULTS | INCOMPLETE | Explain that no matching flights were found for the requested dates and nearby-date checking could not be completed. next is null. |
For FOUND, wait for explicit confirmation. Then clone the complete original
body, replace the dates, and submit another exact search:
async function searchSuggestedDates({ token, originalSearch, availability }) {
if (
availability?.lookAhead?.status !== "FOUND" ||
!availability.next?.departureDate
) {
return null;
}
// Spreading the original body preserves passengers, cabin, airline, price,
// nonstop, result-limit, currency, and lookAheadDays settings.
const followUpSearch = {
...originalSearch,
departureDate: availability.next.departureDate,
...(availability.next.returnDate
? { returnDate: availability.next.returnDate }
: {})
};
return apiRequest("/api/flight/search", token, {
method: "POST",
body: JSON.stringify(followUpSearch)
});
}
For a round trip, next shifts departure and return by the same number of
calendar days. One-way date objects omit returnDate. Treat these
YYYY-MM-DD strings as provider-local calendar values; do not run them through
UTC conversion. Only label one “Today” or “Tomorrow” when the origin airport’s
timezone has been verified.
Use “matching flights” in empty-state wording because the selected airline, cabin, nonstop, price, and ticketing filters all affect the result. Do not say the route is impossible, sold out, or guaranteed to remain bookable.
Review
curl --request POST \
--url "$SAFIRI_API/api/flight/offer" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"offerReference": "9e57df84-b41a-4b4e-bdac-35aa9e55a3cb"
}'
Use bookingRequirements to build the traveler form and stop if expiresAt
has passed.
For domestic adults, date of birth, gender, and travel-document details are not
normally required. Domestic children and infants still normally require date
of birth. International journeys normally require date of birth, gender,
nationality, and complete travel-document details. Always collect every field
marked true in bookingRequirements.
Requirements vary by offer and traveler. Match every travelerRequirements
item to the correct traveler, collect every field marked true, and do not
send bookingRequirements back in the reservation request.
Reserve
curl --request POST \
--url "$SAFIRI_API/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"
}
],
"notifications": {
"ticketPurchase": true
}
}'
contact is optional. Use it when one email address and phone number apply to
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,
country calling code, and phone number from one of these two places.
If you use X-External-User-Id, keep it unchanged on every retry for the
selected offer. Keep the selected fare option and notification preferences
unchanged as well.
Third-party agent reservations never send customer reservation,
unpaid-reservation reminder, reservation-cancellation, or other
non-confirmation notifications through email, SMS, WhatsApp, push, or any other
supported customer channel. After the purchase confirms the booking, final
ticket notifications are enabled by default. Set
notifications.ticketPurchase to false during reservation if Safiri must not
send those final ticket notifications either.
Save reservationId for support, bookingReference for the traveler, and
ticketId for the next two calls.
Purchase
Purchase before paymentDeadlineAt. Safiri tries to issue every ticket in the
reservation. When it succeeds, the API returns success, deducts
walletDebitTotal from the agent's float wallet, and sends final customer
ticket notifications unless the reservation set notifications.ticketPurchase
to false.
curl --request POST \
--url "$SAFIRI_API/api/flight/purchase/agent" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"ticketId": "6be55963-070f-4389-bc83-2a9300dbf9f4"
}'
Retrieve issued tickets
curl --request GET \
--url "$SAFIRI_API/api/flight/tickets?ticketId=6be55963-070f-4389-bc83-2a9300dbf9f4" \
--header "Authorization: Bearer $SAFIRI_TOKEN"
Complete JavaScript example
The example below handles nearby-date confirmation, missing traveler details, changed prices, requests that are still being handled, reservation retries, purchase, and ticket retrieval. You provide three functions:
confirmSuggestedDatesshows the requested and suggested date pairs and returnstrueonly after the traveler approves the change.correctTravelerDetailsshows every returned field message and returns the correctedpassengersand optional sharedcontact.confirmPriceChangeshows the new price and returnstrueonly after the traveler approves it.
const SAFIRI_API = "https://booking-api-dev.safiri.app";
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function apiRequest(path, token, options = {}) {
const response = await fetch(`${SAFIRI_API}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...options.headers
}
});
const body = await response.json();
return { response, body };
}
async function retryWhileProcessing(operation, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const result = await operation();
if (result.response.status !== 202) {
return result;
}
await wait(500 * 2 ** (attempt - 1));
}
throw new Error("The request is still processing; contact Safiri support");
}
async function createReservation({
token,
reservationBody,
bookingRequirements,
externalUserId,
correctTravelerDetails,
confirmPriceChange
}) {
let currentReservationBody = { ...reservationBody };
let requestBody = { ...currentReservationBody };
let currentBookingRequirements = bookingRequirements;
for (let attempt = 1; attempt <= 5; attempt += 1) {
const result = await retryWhileProcessing(() =>
apiRequest("/api/flight/reserve/agent", token, {
method: "POST",
headers: externalUserId
? { "X-External-User-Id": externalUserId }
: {},
body: JSON.stringify(requestBody)
})
);
if (result.response.ok) {
return result.body;
}
if (
result.response.status === 422 &&
[
"FLIGHT_PASSENGER_DATA_INVALID",
"FLIGHT_ADDITIONAL_INFORMATION_REQUIRED"
].includes(result.body.errorCode)
) {
const fields = result.body.data?.fields ?? [];
currentBookingRequirements =
result.body.data?.bookingRequirements ?? currentBookingRequirements;
const corrected = await correctTravelerDetails({
fields,
bookingRequirements: currentBookingRequirements,
passengers: currentReservationBody.passengers,
...(currentReservationBody.contact
? { contact: currentReservationBody.contact }
: {})
});
if (!corrected) {
throw new Error("The traveler details were not corrected");
}
currentReservationBody = {
...currentReservationBody,
passengers: corrected.passengers,
...(corrected.contact ? { contact: corrected.contact } : {})
};
requestBody = {
...currentReservationBody,
...(requestBody.priceAcceptance
? { priceAcceptance: requestBody.priceAcceptance }
: {})
};
continue;
}
if (
result.response.status === 409 &&
result.body.errorCode === "FLIGHT_PRICE_CHANGED"
) {
const { currentPrice, priceVersion, expiresAt } = result.body.data;
const approved = await confirmPriceChange({
amount: currentPrice.grandTotal,
currency: currentPrice.currency,
expiresAt
});
if (!approved) {
throw new Error("The traveler did not approve the changed price");
}
requestBody = {
...currentReservationBody,
priceAcceptance: {
priceVersion,
amount: currentPrice.grandTotal,
currency: currentPrice.currency
}
};
continue;
}
throw new Error(
`${result.body.errorCode ?? "RESERVATION_FAILED"}: ${
result.body.errorMessage ?? "The reservation could not be created"
}`
);
}
throw new Error("The reservation could not be completed after retrying");
}
async function bookFlight({
token,
search,
passengers,
contact,
externalUserId,
selectOffer,
confirmSuggestedDates,
correctTravelerDetails,
confirmPriceChange
}) {
let currentSearch = { ...search };
let offers;
while (!offers) {
const searchResult = await apiRequest("/api/flight/search", token, {
method: "POST",
body: JSON.stringify(currentSearch)
});
if (!searchResult.response.ok) {
throw new Error("Flight search failed");
}
const requestedDateOffers = Array.isArray(searchResult.body.data)
? searchResult.body.data
: [];
if (requestedDateOffers.length > 0) {
offers = requestedDateOffers;
break;
}
const availability = searchResult.body.meta?.availability;
if (
availability?.lookAhead?.status !== "FOUND" ||
!availability.next?.departureDate
) {
if (availability?.lookAhead?.status === "INCOMPLETE") {
throw new Error(
"No matching flights were found and nearby dates could not be fully checked"
);
}
throw new Error("No matching flights were found in the dates checked");
}
const approved = await confirmSuggestedDates({
requested: availability.requested,
suggested: availability.next
});
if (!approved) {
throw new Error("The suggested dates were not selected");
}
// Preserve all filters and lookAheadDays; replace only the calendar dates
// explicitly returned by the API after the agent confirms the change.
currentSearch = {
...currentSearch,
departureDate: availability.next.departureDate,
...(availability.next.returnDate
? { returnDate: availability.next.returnDate }
: {})
};
}
const selectedOffer = await selectOffer(offers);
if (!selectedOffer?.offerReference) {
throw new Error("No flight offer was selected");
}
const reviewResult = await apiRequest("/api/flight/offer", token, {
method: "POST",
body: JSON.stringify({
offerReference: selectedOffer.offerReference
})
});
if (!reviewResult.response.ok) {
throw new Error("The selected offer is no longer available");
}
const reviewedOffer = reviewResult.body;
if (new Date(reviewedOffer.expiresAt) <= new Date()) {
throw new Error("The selected offer has expired; search again");
}
const reservationBody = {
offerReference: reviewedOffer.offerReference,
passengers,
...(contact ? { contact } : {})
};
const reserved = await createReservation({
token,
reservationBody,
bookingRequirements: reviewedOffer.bookingRequirements,
externalUserId,
correctTravelerDetails,
confirmPriceChange
});
const ticketId =
reserved.reservation.ticketIds?.[0] ?? reserved.tickets?.[0]?.ticketId;
if (!ticketId) {
throw new Error("The reservation did not return a ticketId");
}
if (new Date(reserved.reservation.paymentDeadlineAt) <= new Date()) {
throw new Error("The purchase deadline has passed; search again");
}
const purchaseResult = await retryWhileProcessing(() =>
apiRequest("/api/flight/purchase/agent", token, {
method: "POST",
body: JSON.stringify({ ticketId })
})
);
if (!purchaseResult.response.ok) {
throw new Error(
`${purchaseResult.body.errorCode ?? "PURCHASE_FAILED"}: ${
purchaseResult.body.errorMessage ?? "The tickets could not be issued"
}`
);
}
const ticketsResult = await apiRequest(
`/api/flight/tickets?ticketId=${encodeURIComponent(ticketId)}`,
token,
{ method: "GET" }
);
if (!ticketsResult.response.ok) {
throw new Error("The issued tickets could not be retrieved");
}
return {
reservationId: reserved.reservation.reservationId,
bookingReference: reserved.reservation.bookingReference,
purchase: purchaseResult.body,
tickets: ticketsResult.body
};
}
Do not save passengers or contact in logs. If you need records for support,
save only offerReference, reservationId, bookingReference, and ticketId.
Recovery checklist
- For a
422validation response, show everydata.fields[]message beside the matching traveler field. If the response includesdata.bookingRequirements, replace the earlier requirements with it, collect every newly required field, and then retry the original reservation. - For
FLIGHT_PRICE_CHANGED, show the latest total and ask for approval. Repeat the original reservation details with the latestpriceAcceptance; do not addreservationId. - For
FLIGHT_OFFER_EXPIREDwithdata.refreshSearch, begin a new search. The offer's cached quote has already been discarded, so retrying the sameofferReferencewill keep failing;data.reasonnames the provider refusal. - For
FLIGHT_REPRICE_UNAVAILABLE, follow the response's retry guidance or contact Safiri support. - For insufficient float, top up and retry before
paymentDeadlineAt. - A
202response means the request is still being handled. Wait briefly, then send the same request again. Increase the wait between attempts. - Do not start a new search or create a second reservation while the original request is still processing.
- For an expired offer or reservation, begin a new search.
- If ticket issue is not confirmed, keep all identifiers and contact Safiri support instead of creating another reservation.
See the Flight Booking API reference for all search filters, request fields, and response examples.