Skip to main content

Flight Booking Workflow for Third-Party Agents

This guide connects the flight endpoints into one safe booking flow. For every booking:

  1. Fetch the airport stops and let the agent select valid IATA codes.
  2. Search, handle an optional nearby-date suggestion, and save the selected offerReference only when the exact search returns an offer.
  3. Review the offer, its expiresAt, and its bookingRequirements.
  4. Collect each traveler's required identity and contact details, then reserve.
  5. Resolve any field errors or obtain approval for a changed price.
  6. Purchase with ticketId before paymentDeadlineAt, then retrieve the issued tickets.

Flight booking flow from search through issued ticket retrieval

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.

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.

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.statuslookAhead.statusAction
RESULTS_FOUNDNOT_NEEDEDLet the agent select one of the requested-date offers. firstChecked, confirmedThrough, and next are null.
NO_RESULTSFOUNDExplain that no matching flights were found for the requested dates, show next, and ask whether to run a new search.
NO_RESULTSNO_RESULTSExplain that no matching flights were found in the dates checked. next is null.
NO_RESULTSINCOMPLETEExplain 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.

important

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:

  • confirmSuggestedDates shows the requested and suggested date pairs and returns true only after the traveler approves the change.
  • correctTravelerDetails shows every returned field message and returns the corrected passengers and optional shared contact.
  • confirmPriceChange shows the new price and returns true only 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

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

  • For a 422 validation response, show every data.fields[] message beside the matching traveler field. If the response includes data.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 latest priceAcceptance; do not add reservationId.
  • For FLIGHT_OFFER_EXPIRED with data.refreshSearch, begin a new search. The offer's cached quote has already been discarded, so retrying the same offerReference will keep failing; data.reason names 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 202 response 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.