Skip to main content

Parcel Delivery

Use the Booking API to discover delivery locations, select parcel contents, compare offers, and create a parcel booking through an authenticated Safiri agent account. Start with the step-by-step workflow for a connected JavaScript example, or use the endpoint reference below when building your own interface.

The integration sequence is:

  1. Authenticate and retain the token and agent user details.
  2. Resolve the actual pickup and destination addresses.
  3. Load the latest parcel-contents catalogue and collect parcel measurements.
  4. Search offers and let the sender choose an offer and service.
  5. Submit the selected offer with sender, recipient, and parcel details.
  6. Retrieve the saved parcel using its UUID or tracking number.

Selecting an offer is a client-side action. There is no separate acceptance endpoint between searching and submitting.

Environments and prerequisites​

EnvironmentBase URL
Developmenthttps://booking-api-dev.safiri.app
Productionhttps://booking-api.safiri.app

The base URL is the host only. Every endpoint on this page includes /api. Start in development with account and company identifiers supplied by Safiri during integration setup. An agent submission requires an existing user with permissionLevel: 222 (SAFIRI_AGENT) and enough available wallet credit under that account's balance and overdraft rules. Authenticate as that user; changing a request field does not grant agent permissions.

Safiri manages service availability and pricing setup. Integrators do not need to fetch or modify parcel configuration. If the API reports missing service setup, contact Safiri with the environment and company context used.

All names, phone numbers, catalogue types, UUIDs, coordinates, prices, and dates in response examples are illustrative. They are not live locations, current prices, or accounts to use. Obtain identifiers from your own environment. Responses shown as excerpts omit unrelated fields; retain the complete live offer when booking.

Authentication​

Call POST /api/logon with either email and password or calling code, phone number, and password. See Authentication for the full contract. Save the returned token for authorization and the returned user for the booking's createdBy object.

Save your actual login input in a local login.json file. Email form:

{
"email": "your-agent-email@example.com",
"password": "your-agent-password"
}

Phone form:

{
"countryCode": "255",
"phoneNumber": "your-agent-national-number",
"password": "your-agent-password"
}
export SAFIRI_BASE_URL='https://booking-api-dev.safiri.app'
curl --fail-with-body --request POST "$SAFIRI_BASE_URL/api/logon" \
--header 'Content-Type: application/json' \
--data-binary @login.json

Successful 200 response excerpt:

{
"token": "<JWT returned by login>",
"user": {
"uuid": "00000011-0000-4000-8000-000000000011",
"fullName": "Example Agent",
"phoneNumber": "700000003",
"countryCode": "255",
"countryCodeName": "TZ",
"permissionLevel": 222
}
}

For the remaining cURL examples, set SAFIRI_TOKEN to the token returned by login. Set SAFIRI_COMPANY_UUID only if Safiri supplied a company context. Keep credentials and tokens on your integration server; do not include them in client-side public code or commit the local files containing them.

Headers​

HeaderUsage and default
AuthorizationRequired on every endpoint below: Bearer <token>. Login is the exception.
Content-TypeSend application/json for login, offer search, and submission bodies.
Content-LanguageOptional; defaults to en where used for localized errors and labels. Catalogue titles remain translation maps.
Content-CurrencyOptional on offer search; defaults to TZS. Display the currency returned with the selected price. Submission derives its price and currency from the selected offer.
x-device-timezoneSend an IANA timezone such as Africa/Dar_es_Salaam consistently for search and submission. Search uses the origin's resolved timezone where possible and otherwise this header, defaulting to Africa/Dar_es_Salaam.
x-company-uuidOptional company context for discovery/search, supplied by Safiri. Omit it to use the Safiri default; do not send an empty header. Business, locker, and catalogue lookups do not implement company filtering through this header.

For offer search, existing integrations can also use these optional compatibility headers:

HeaderMeaning
x-companyCompany display name; defaults to an empty string.
x-website-owner-uuidCompany UUID fallback when x-company-uuid is absent.
x-website-ownerCompany display name; takes precedence over x-company.

For a new integration, use the x-company-uuid supplied by Safiri, or omit it to use Safiri's default. Company UUID precedence is x-company-uuid, then x-website-owner-uuid, then the Safiri default. Avoid empty-value company headers.

Authorization failures may be returned by authentication middleware before an endpoint runs. Handle non-JSON error bodies as well as the controller error format.

Endpoint inventory​

Method and pathPurposeWhen needed
GET /api/parcel/addressesDiscover supported location groups and countries.Populate pickup/destination choices.
GET /api/business?cityName=<city>Find businesses containing agent offices.User needs to choose an agent business.
GET /api/business/:uuidRetrieve a known business and offices.Resolve a specific business.
GET /api/locker/get-lockers?cityName=<city>List lockers and their slots.User chooses a locker.
GET /api/locker/:idRetrieve one locker by its UUID.Inspect a selected locker.
GET /api/parcel-contentGet the latest contents catalogue.Populate “What's in the parcel?”.
POST /api/parcel/search-offersQuote delivery using addresses and parcel details.Before selecting a delivery service.
POST /api/parcel/delivery-request/agentSave the booking using the selected offer.After the user confirms the selection.
GET /api/parcel?uuid=<parcelUuid>Retrieve a saved parcel.After creation and when checking its state.

A street-address delivery does not require a locker lookup. A known, complete agent office can be used without repeating business discovery. Lookup results do not reserve a service, agent, or locker slot.

Discover supported addresses​

GET /api/parcel/addresses

Use the shared authorization header, and the same optional x-company-uuid as your offer search. The response is an object with agents, lockers, stations, addresses, and supportedCountries, not a paginated data array.

Query parameterValuesBehavior
agentstrue or falseInclude agent discovery rows.
lockerstrue or falseInclude locker discovery rows.
stationstrue or falseInclude station/city discovery rows.
addressestrue or falseInclude cities supporting address delivery.

With no filters, all four collections are enabled. If any filter is present, omitted collections are disabled. For example, ?agents=true returns agent rows and empty arrays for the other collections. ?agents=false by itself disables all four. Use explicit true/false; the API also accepts case-insensitive true, 1, or yes as enabled.

All locations:

curl --fail-with-body "$SAFIRI_BASE_URL/api/parcel/addresses" \
--header "Authorization: Bearer $SAFIRI_TOKEN"

Only agents:

curl --fail-with-body "$SAFIRI_BASE_URL/api/parcel/addresses?agents=true" \
--header "Authorization: Bearer $SAFIRI_TOKEN"

If using an assigned company context, add --header "x-company-uuid: $SAFIRI_COMPANY_UUID" to discovery and offer search.

Example 200 response excerpt for agent-only discovery:

{
"agents": [
{
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"uuid": "00000002-0000-4000-8000-000000000002",
"type": "AGENT",
"availability": 1
}
],
"lockers": [],
"stations": [],
"addresses": [],
"supportedCountries": {
"agents": [
"TZ"
],
"lockers": [],
"stations": [],
"addresses": []
}
}
Response fieldMeaning
agents, lockers, stations, addressesArrays of discovery rows. An empty array may mean disabled by the query filter or no matching data.
Row city, country, description, typeDisplay and location context.
Row uuid / idIdentity, if available. Interpret it according to location type; a generated city-row ID is not a business or office ID.
Row infoMay contain office or locker details associated with the row.
Row availabilityDiscovery count, not a reservation or a guaranteed number of free locker slots.
supportedCountriesCountry information per location collection; ordinarily two-letter codes such as TZ for named countries. The current resolver can return a country object for stored country-code inputs, so do not treat these lists as the sole address schema.

Agent rows can be grouped by locality and represent one office in that locality. The addresses and stations lists can contain city-level rows with latitude and longitude 0. They describe coverage; do not submit those placeholder coordinates as a real pickup/delivery point. Resolve a street address, office, or station with meaningful coordinates first. Discovery is cached and is not a live availability guarantee.

Resolve agent businesses and offices​

List businesses​

GET /api/business?cityName=Dar%20es%20Salaam
ParameterRequiredMeaning
cityNameNoExact city filter on businessAddress.city. It does not search each entry in offices.
curl --fail-with-body --get "$SAFIRI_BASE_URL/api/business" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--data-urlencode 'cityName=Dar es Salaam'

A successful 200 response is a plain array. Example excerpt:

[
{
"uuid": "00000001-0000-4000-8000-000000000001",
"name": "Example Parcel Agent",
"businessAddress": {
"name": "Example business address",
"city": "Dar es Salaam",
"country": "Tanzania",
"latitude": -6.772,
"longitude": 39.229
},
"offices": [
{
"id": "00000002-0000-4000-8000-000000000002",
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania"
}
]
}
]

No matches returns []. Omitting cityName, or sending it empty, returns the latest 50 businesses ordered by joinedDate. There is no pagination parameter on this endpoint. Do not interpret that result as the complete agent network. A business may have offices outside its business-address city; inspect offices before showing a pickup choice.

Get a specific business​

GET /api/business/:uuid

Set BUSINESS_UUID to a returned business UUID or one supplied during onboarding.

curl --fail-with-body "$SAFIRI_BASE_URL/api/business/$BUSINESS_UUID" \
--header "Authorization: Bearer $SAFIRI_TOKEN"

Returns 200 with a single business object (the same object shape as an element in the list), or 400 with NOT_FOUND when the business does not exist. Successful response excerpt:

{
"uuid": "00000001-0000-4000-8000-000000000001",
"name": "Example Parcel Agent",
"businessAddress": {
"name": "Example business address",
"city": "Dar es Salaam",
"country": "Tanzania",
"latitude": -6.772,
"longitude": 39.229
},
"offices": [
{
"id": "00000002-0000-4000-8000-000000000002",
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania"
}
]
}

Convert an office to an address​

Use a user-selected entry in business.offices. The business uuid identifies the business; the office id identifies the location. An AGENT request address must use office.id as address.uuid.

function addressFromAgentOffice(office) {
return {
uuid: office.id,
type: 'AGENT',
latitude: Number(office.latitude),
longitude: Number(office.longitude),
name: office.name,
description: office.description ||
[office.name, office.city, office.country].filter(Boolean).join(', '),
city: office.city,
country: office.country,
};
}

The example office above produces:

{
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"uuid": "00000002-0000-4000-8000-000000000002",
"type": "AGENT"
}

Preserve other useful address fields when available. Do not substitute the business UUID, a catalogue UUID, or a display label for the office ID.

Find and select a locker​

List lockers​

GET /api/locker/get-lockers?cityName=Dar%20es%20Salaam

cityName is optional and filters address.city. Omitting it or leaving it empty lists lockers without a city filter. The response is a plain array; [] means no matches.

curl --fail-with-body --get "$SAFIRI_BASE_URL/api/locker/get-lockers" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--data-urlencode 'cityName=Dar es Salaam'

Example 200 response excerpt:

[
{
"uuid": "00000003-0000-4000-8000-000000000003",
"name": "Example Locker",
"address": {
"latitude": -6.781,
"longitude": 39.239,
"name": "Example Locker",
"description": "Example locker entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "LOCKER"
},
"slots": [
{
"uuid": "00000004-0000-4000-8000-000000000004",
"slotId": "A-01",
"label": "Small compartment",
"status": "AVAILABLE",
"available": true,
"maxLength": 40,
"maxWidth": 30,
"maxHeight": 20,
"maxWeight": 5,
"categories": [
"SMALL_PARCEL"
],
"fee": {
"type": "fixed",
"value": 500
},
"currency": "TZS"
}
],
"metadata": {
"booked": 0
}
}
]

Get one locker​

GET /api/locker/:id

Despite the parameter name id, this endpoint looks up the locker UUID, not the response _id or a slot ID. Set LOCKER_UUID from the list response.

curl --fail-with-body "$SAFIRI_BASE_URL/api/locker/$LOCKER_UUID" \
--header "Authorization: Bearer $SAFIRI_TOKEN"

Returns 200 with the locker object shown below, or 400 with NOT_FOUND and Locker not found when it does not exist. Response excerpt:

{
"uuid": "00000003-0000-4000-8000-000000000003",
"name": "Example Locker",
"address": {
"latitude": -6.781,
"longitude": 39.239,
"name": "Example Locker",
"description": "Example locker entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "LOCKER"
},
"slots": [
{
"uuid": "00000004-0000-4000-8000-000000000004",
"slotId": "A-01",
"label": "Small compartment",
"status": "AVAILABLE",
"available": true,
"maxLength": 40,
"maxWidth": 30,
"maxHeight": 20,
"maxWeight": 5,
"categories": [
"SMALL_PARCEL"
],
"fee": {
"type": "fixed",
"value": 500
},
"currency": "TZS"
}
],
"metadata": {
"booked": 0
}
}

Choose a slot and build the request address​

FieldMeaning
Locker uuidPut this in the request address's uuid.
Slot uuidSlot record identity; it is not the value of locker.slotId.
Slot slotIdCompartment identifier, for example A-01; put it in nested locker.slotId.
available / statusSlot availability indicators; statuses include AVAILABLE, RESERVED, and FULL.
maxLength, maxWidth, maxHeightPhysical limits in centimetres.
maxWeightWeight limit in kilograms.
categoriesSupported parcel size categories.
fee, currencySlot fee metadata. Use the returned offer for the complete delivery price.
metadata.bookedBooked-slot count; it does not reserve anything for the caller.

Select a suitable slot with the user, retain both the locker UUID and slotId, and submit a location shaped like:

{
"latitude": -6.781,
"longitude": 39.239,
"name": "Example Locker",
"description": "Example locker entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "LOCKER",
"uuid": "00000003-0000-4000-8000-000000000003",
"locker": {
"slotId": "A-01"
}
}

Lookup data may be cached and availability can change. A lookup or successful offer search does not guarantee a reservation. If a locker becomes unavailable, refresh its details, let the user choose again, and request a new offer before submission. Do not invent slot access codes or call locker-management operations as part of quoting.

Shared pickup and destination address​

FieldIntegration requirement
typeSend ADDRESS, AGENT, LOCKER, OFFICE, or STATION to identify the actual location type.
latitude, longitudeNumeric coordinates of the physical point. Do not use city-discovery placeholders or swap latitude and longitude.
city, countryNonempty names used for routing and service availability.
descriptionUseful address/entrance description; also used when saving pickup and drop-off points.
nameOptional location name for display.
uuidOffice ID for AGENT; locker UUID for LOCKER. Preserve identities supplied for other managed locations. A normal street address need not invent a UUID.
locker.slotIdSupply the chosen compartment when using a locker.
Additional address fieldsPreserve useful houseNumber, streetName, postCode, and landMark details when available.

For a street address, obtain geocoded coordinates and collect the city, country, and a useful description. Safiri's mobile app uses Google Places/geocoding; your integration may use its own provider. The discovery endpoint is not a general street-address autocomplete service.

Example street address:

{
"latitude": -6.804,
"longitude": 39.253,
"name": "Example Recipient",
"description": "Example recipient entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "ADDRESS"
}

OFFICE is a company handoff office, while AGENT uses the agent-office availability path. STATION is a carrier handoff point, not an arbitrary street address. Resolve actual station coordinates before quoting; city-level discovery does not give a complete station record.

Latest parcel-contents catalogue​

GET /api/parcel-content

This endpoint has no required query parameters. Call it without a catalogue ID to retrieve the catalogue with the newest createdAt. The route is not versioned as /v2; “latest” means the newest catalogue returned by this endpoint. Refresh it when starting a new parcel form rather than hardcoding the old app's category chips.

curl --fail-with-body "$SAFIRI_BASE_URL/api/parcel-content" \
--header "Authorization: Bearer $SAFIRI_TOKEN"

Returns 200 with a catalogue object, or 400 with NOT_FOUND if no catalogue exists. Example:

{
"_id": "507f1f77bcf86cd799439011",
"items": [
{
"uuid": "00000005-0000-4000-8000-000000000005",
"type": "documents",
"title": {
"en": "Documents",
"sw": "Nyaraka"
},
"description": {
"en": "Paper documents"
},
"weight": 0.5,
"length": 30,
"width": 22,
"height": 2,
"tags": [],
"sortOrder": 1
},
{
"uuid": "00000006-0000-4000-8000-000000000006",
"id": "electronics",
"title": {
"en": "Electronics"
},
"description": {
"en": "Select the electronic item"
},
"sortOrder": 2,
"children": [
{
"uuid": "00000007-0000-4000-8000-000000000007",
"groupId": "electronics",
"type": "smartphone",
"title": {
"en": "Smartphone"
},
"weight": 0.4,
"length": 18,
"width": 10,
"height": 5,
"tags": [
"fragile"
],
"sortOrder": 1
}
]
}
],
"dictionaries": [
{
"uuid": "00000006-0000-4000-8000-000000000006",
"id": "electronics",
"title": {
"en": "Electronics"
},
"description": {
"en": "Select the electronic item"
},
"sortOrder": 2
}
],
"tags": [
{
"uuid": "00000008-0000-4000-8000-000000000008",
"id": "fragile",
"title": {
"en": "Fragile"
},
"kind": "HANDLING",
"sortOrder": 1
}
],
"locales": [
{
"code": "en",
"name": "English",
"direction": "ltr",
"isDefault": true,
"sortOrder": 1
},
{
"code": "sw",
"name": "Swahili",
"direction": "ltr",
"isDefault": false,
"sortOrder": 2
}
],
"createdAt": "2026-09-28T06:00:00.000Z",
"updatedAt": "2026-09-28T06:00:00.000Z"
}
FieldMeaning
_idCatalogue document ID; not an item ID for quote requests.
itemsMixed array of standalone items and dictionary/group rows with children.
Item uuidIdentity of the selected concrete item.
Item typeContent identifier used as whatIsInTheParcel[].id during search and in category[] during submission.
Group id / item groupIdGrouping identifiers linking items to dictionaries[].id. Do not submit a group in place of a selected child.
title, descriptionTranslation maps keyed by locale code.
imageOptional image URL.
weight, length, width, heightOptional prefills in kilograms and centimetres. Measure the actual packed parcel before searching.
sortOrderDisplay order. The endpoint returns ordered rows and children.
Item tagsString references to tags[].id, not tag UUIDs.
dictionariesGroup definitions and translated display information.
tagsTag definitions, titles, descriptions, and semantic kind.
localesAvailable languages, text direction, default locale, and ordering.

Use the requested translation when present, then the catalogue's default locale. A group row such as Electronics requires choosing its Smartphone child; a standalone Documents item can be selected directly. Some legacy items with unmatched grouping may remain flat, so support both returned shapes.

The two example selections would produce this search field:

{
"whatIsInTheParcel": [
{
"id": "documents",
"uuid": "00000005-0000-4000-8000-000000000005"
},
{
"id": "smartphone",
"uuid": "00000007-0000-4000-8000-000000000007"
}
]
}

Retain actual item types and UUIDs returned in your environment. The supported search shape is { id: string, uuid?: string }[], not a string array and not the entire catalogue row. Tags are catalogue metadata: do not automatically copy every tag into the optional search properties array. Only send handling-property values supported for your integration, otherwise leave that optional array empty.

The current agent-save endpoint uses category and property arrays. It does not persist a top-level structured whatIsInTheParcel array. See the field mapping; item-level quantities, parent labels, and tags are not promised as structured saved data.

Search and select an offer​

POST /api/parcel/search-offers

Both customer and agent flows use this endpoint to quote the resolved pickup, destination, and parcel details. An offer can describe a single delivery leg or a connected journey with collection, intercity transport, and final delivery. Use the shared authentication and headers; search does not create a parcel booking.

Offer search request​

Send a JSON object with the resolved addresses and selected catalogue items. Integration requirement describes the inputs to collect for a useful quote; the last column distinguishes optional fields and server defaults. Validate the form before searching and always send the intended address type. A missing type defaults to ADDRESS.

FieldTypeIntegration requirementDefault / behavior
originAddress objectSend complete pickup location.Includes coordinates, city, country, type, description, and managed-location identifiers where applicable.
destinationAddress objectSend complete destination.Same shape as origin.
weightnumber or numeric stringSend positive measured weight in kilograms.Used for parcel size and transport capability.
heightnumber or numeric stringSend positive packed height in centimetres.Used for size/capability checks.
widthnumber or numeric stringSend positive packed width in centimetres.Used for size/capability checks.
lengthnumber or numeric stringSend positive packed length in centimetres.Used for size/capability checks.
estimatedValuenumber or numeric stringSend the declared value.Defaults to 0. Used for insurance and transport thresholds; keep value/currency context consistent through booking.
whatIsInTheParcel{ id: string, uuid?: string }[]Send selected concrete catalogue items.Defaults to []. id is the selected item's type.
propertiesstring[]Optional handling properties.Defaults to []; can affect location availability. These are not automatically all catalogue tags.
insurancestringKeep a deliberate coverage choice.Defaults to BASIC. Normally use BASIC or COMPREHENSIVE in this flow.
datestringOptional requested date.Defaults to today in the resolved timezone; the server normalizes supplied dates.
timestringOptional local time, HH:mm.Defaults to the current time in the resolved timezone.
distancestringOptional serialized distance-matrix response.When missing, invalid JSON, or not successful, the server falls back to coordinate-based haversine distance.

When scheduling, supply date and time together and retain the intended timezone. The origin's timezone takes precedence over the timezone header, which acts as a fallback. Do not assume an unrelated device timezone overrides a known origin timezone. The primary example omits date/time so copying it does not schedule a parcel in the past.

Only supported top-level fields are used. A quantity field is not a search parameter; if sending several items together, quote the actual combined package weight and outer dimensions. Catalogue prefills alone do not guarantee that a packed parcel fits a slot or vehicle.

Compatibility note: older clients may send query parameters on this POST and JSON-encoded address strings. New integrations should use the JSON body above, with address objects and real arrays. A nonempty body takes precedence over query parameters; they are not merged. There is no documented GET /api/parcel/search-offers route.

Optional distance input​

Most integrators can omit distance. If supplied, it is a JSON string, not a bare number or a nested object. A successful distance-matrix value is read from rows[0].elements[0].distance.value in metres and converted to kilometres. For example:

const distance = JSON.stringify({
status: 'OK',
origin_addresses: ['Actual pickup'],
destination_addresses: ['Actual destination'],
rows: [{ elements: [{
status: 'OK',
distance: { text: '5 km', value: 5000 },
duration: { text: '15 minutes', value: 900 },
}] }],
});

Supply a complete, valid matrix if using this option. The fallback is straight-line haversine distance, not a guarantee of road distance or travel time. Do not mark an incomplete matrix as OK.

Offer search example​

The JSON below illustrates a documents parcel from an agent office to a street address. Replace all illustrative IDs with live lookup selections. Save the resolved object as search-request.json; the connected workflow generates this file from returned data.

{
"origin": {
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"uuid": "00000002-0000-4000-8000-000000000002",
"type": "AGENT"
},
"destination": {
"latitude": -6.804,
"longitude": 39.253,
"name": "Example Recipient",
"description": "Example recipient entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "ADDRESS"
},
"weight": 0.5,
"height": 2,
"width": 22,
"length": 30,
"estimatedValue": 10000,
"properties": [],
"whatIsInTheParcel": [
{
"id": "documents",
"uuid": "00000005-0000-4000-8000-000000000005"
}
],
"insurance": "BASIC"
}
export SAFIRI_BASE_URL='https://booking-api-dev.safiri.app'
curl --fail-with-body --request POST "$SAFIRI_BASE_URL/api/parcel/search-offers" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header 'Content-Type: application/json' \
--header 'Content-Language: en' \
--header 'Content-Currency: TZS' \
--header 'x-device-timezone: Africa/Dar_es_Salaam' \
--data-binary @search-request.json

If using an assigned company context, add --header "x-company-uuid: $SAFIRI_COMPANY_UUID". Do not send placeholder IDs from this page to production.

Offer search response​

Returns 200 with this envelope:

type TransportationOffersResponse = {
meta: {
count: number;
links: { self: string };
createdAt: string;
expiresAt: string;
paymentTimeInSeconds: number;
};
data: ParcelOffer[];
};
FieldMeaning
meta.countNumber of returned offers.
meta.createdAtSearch-response creation timestamp.
meta.expiresAtQuote validity/display deadline. Re-query if it has passed before the user confirms.
meta.paymentTimeInSecondsCurrently 1800 (30 minutes). Use the returned value; do not hardcode a countdown.
meta.links.selfSearch-reference metadata. Use the documented POST endpoint for a new search rather than assuming this link is a supported GET operation.
dataArray of selectable offers; can be empty.

Creation currently does not accept or independently enforce the search envelope's expiry timestamp. Treat expiry as a client refresh rule, not a promise that stale submissions will be rejected. If addresses, contents, measurements, value, insurance, or scheduling change, search again instead of editing a previously returned offer.

A transport provider can fail or have no eligible journey while another provider or transport mode still returns offers. Display the returned results; do not assume that every possible carrier was available. Search and lookup responses do not reserve capacity or a locker slot.

Offer fields​

FieldShape / meaning
uuidSearch offer UUID, distinct from the parcel UUID generated after submission.
typeparcel-transportation-offer.
origin, destinationFull pickup and destination addresses. Preserve them.
priceMap of exact service keys to price details.
durationDuration information keyed by service bucket.
distanceDistance description/value/unit. Read the unit instead of assuming metres.
segmentsOrdered delivery legs, including first/middle/last mile where applicable.
carrierTransportation company or freelance/rider carrier information.
estimatedDepartureTime, estimatedArrivalTimeEstimated journey timing.
selfMay contain a search reference; it is not a booking or offer-acceptance endpoint.

Segment fields​

FieldMeaning
typefreelance-or-rider-offer or transportation-company-offer.
uuidSegment identity.
origin, destinationPickup/handoff addresses for the leg.
price, optional basePriceService-keyed leg prices.
duration, distanceLeg timing and distance, with units.
carrierOperator for this leg.
estimatedDepartureTime, estimatedArrivalTimeEstimated leg timing.
selfOptional/reference search URL for the leg.

Retain every segment. A quoted journey can combine a rider collecting from an agent, a carrier transporting between cities, and a rider making the final delivery. Submit the complete returned journey with its original price maps.

Service keys and prices​

Common simple keys are ECONOMY and EXPRESS. A combined journey may return keys such as ECONOMY-24_HOURS. These are examples, not a complete enum to generate locally. Enumerate Object.keys(offer.price) and use an exact returned key.

Price fieldMeaning
currencyCurrency for that service price.
totalPrice component before other displayed amounts.
safiriCommissionSafiri commission component.
feesArray of fee components with type and amount.
grandTotalQuoted total to display for selection. Do not recalculate a replacement booking price from fee labels.
durationMay include service-specific duration information.

Amounts commonly arrive as strings, for example "6000". Preserve the original offer representation when submitting; use decimal-aware formatting in your UI. Do not add the displayed fees to grandTotal again.

Fee typeMeaning
TRANSPORTTransportation-company charge.
SAFIRI_SERVICESafiri service fee.
FACILITATIONAgent/location facilitation.
LOCKERLocker usage.
STORAGEStorage handling.
GOVERNMENTGovernment fee/tax component.
INSURANCEInsurance component.

Offer response example​

The following is an illustrative one-offer response showing the fields needed to understand a selectable service and its segment. Prices and timestamps are examples; do not submit this historical fixture. The actual response may include additional carrier and address metadata, which should be retained.

{
"meta": {
"count": 1,
"links": {
"self": "https://booking-api-dev.safiri.app/api/parcel/search-offers"
},
"createdAt": "2026-09-28T07:00:00.000Z",
"expiresAt": "2026-09-28T07:30:00.000Z",
"paymentTimeInSeconds": 1800
},
"data": [
{
"type": "parcel-transportation-offer",
"uuid": "00000009-0000-4000-8000-000000000009",
"origin": {
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"uuid": "00000002-0000-4000-8000-000000000002",
"type": "AGENT"
},
"destination": {
"latitude": -6.804,
"longitude": 39.253,
"name": "Example Recipient",
"description": "Example recipient entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "ADDRESS"
},
"price": {
"ECONOMY": {
"currency": "TZS",
"total": "4000",
"safiriCommission": "1000",
"fees": [
{
"amount": "500",
"type": "FACILITATION"
},
{
"amount": "500",
"type": "STORAGE"
}
],
"grandTotal": "6000"
}
},
"duration": {
"ECONOMY": {
"text": "2 hours",
"value": 7200,
"unit": "seconds"
}
},
"distance": {
"text": "4.4 km",
"value": 4.4,
"unit": "kilometer"
},
"carrier": {
"type": "MOTORBIKE",
"category": "FREELANCER_OR_RIDER"
},
"segments": [
{
"type": "freelance-or-rider-offer",
"uuid": "00000010-0000-4000-8000-000000000010",
"origin": {
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"uuid": "00000002-0000-4000-8000-000000000002",
"type": "AGENT"
},
"destination": {
"latitude": -6.804,
"longitude": 39.253,
"name": "Example Recipient",
"description": "Example recipient entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "ADDRESS"
},
"price": {
"ECONOMY": {
"currency": "TZS",
"total": "4000",
"safiriCommission": "1000",
"fees": [
{
"amount": "500",
"type": "FACILITATION"
},
{
"amount": "500",
"type": "STORAGE"
}
],
"grandTotal": "6000"
}
},
"duration": {
"ECONOMY": {
"text": "2 hours",
"value": 7200,
"unit": "seconds"
}
},
"distance": {
"text": "4.4 km",
"value": 4.4,
"unit": "kilometer"
},
"carrier": {
"type": "MOTORBIKE",
"category": "FREELANCER_OR_RIDER"
},
"estimatedDepartureTime": "2026-09-28T10:00:00+03:00",
"estimatedArrivalTime": "2026-09-28T12:00:00+03:00"
}
],
"estimatedDepartureTime": "2026-09-28T10:00:00+03:00",
"estimatedArrivalTime": "2026-09-28T12:00:00+03:00"
}
]
}

A valid empty result looks like:

{
"meta": {
"count": 0,
"links": {
"self": "https://booking-api-dev.safiri.app/api/parcel/search-offers"
},
"createdAt": "2026-09-28T07:00:00.000Z",
"expiresAt": "2026-09-28T07:30:00.000Z",
"paymentTimeInSeconds": 1800
},
"data": []
}

An empty result means no offer was returned for those inputs at that time. Keep the form editable, explain the outcome, and allow another search. Do not construct an offer yourself or call submission with data[0] when the list is empty.

Select an offer and service​

Selection happens in your application; there is no separate offer-acceptance request. Retain:

  1. The complete selected object from the response's data array.
  2. The exact selected key from that object's price map.
  3. The original parcel details and search inputs, together with the response's expiry metadata.

For the response above, ECONOMY is the only available service. If a different live offer returns price['ECONOMY-24_HOURS'], use that full key as typeOfService. Only use keys returned for the selected offer; do not apply a composed example key to an offer that does not contain it.

Submit the original offer with its segments, price, and duration maps intact, and use typeOfService to specify the service being booked. Do not flatten the maps, replace offer.price with a scalar, or reconstruct an offer from displayed fields.

Before submission, display the selected route, service, grandTotal, and currency for confirmation. Refresh expired quotes or changed inputs before asking for confirmation again. The connected workflow shows selection checks and a deliberate submission function. Continue below to submit the selected offer and save the parcel.

Submit an agent parcel​

POST /api/parcel/delivery-request/agent

Send the bearer token, Content-Type: application/json, Content-Language, and x-device-timezone. The authenticated account must be a Safiri agent. The handler checks account eligibility and available balance, chooses prices from the full offer using typeOfService, saves the parcel, and starts asynchronous payment/delivery processing.

Request body​

“Send” identifies inputs needed for a complete integration request, even where the server tolerates missing optional details. Do not rely on missing measurements or content silently becoming valid defaults.

FieldTypeSend / defaultDescription
offerobjectRequiredComplete selected item from search data; not the search envelope and not only its UUID.
typeOfServicestringRequired for correct price selectionExact selected key of offer.price. The server needs this before later defaulting any stored service field.
senderParcelUserRequiredOne sender contact object.
receiverParcelUser[]Required, nonemptyArray even when there is a single recipient.
createdByParcelUserRequiredAuthenticated agent's identity/contact details. Use the actual login user UUID.
typeOfPaymentstringRequiredOne of the payment values below.
descriptionstringSendHuman-readable packed contents and quantities; trimmed when stored.
weightnumeric stringSendKilograms; match the searched parcel. Numeric inputs are normalized, but strings match the saved contract.
height, width, lengthnumeric stringsSendCentimetres; match the searched parcel.
estimatedValuenumeric stringSendDeclared value used for the quote. Preserve its currency context.
quantitystringDefaults to "1"Overall parcel quantity, distinct from item-level catalogue quantities.
categorystring[]Send selected typesConcrete catalogue item types.
propertystring[]OptionalHandling-property identifiers; note singular property at submission.
insurancestringDefaults to BASICKeep the coverage used for the selected offer, normally BASIC or COMPREHENSIVE.
parcelSizeCategorystringDefaults to CUSTOM_PARCELOptional: ENVELOPE, SMALL_PARCEL, MEDIUM_PARCEL, LARGE_PARCEL, or CUSTOM_PARCEL. Omit if you do not have an applicable category.
trackingNumberstringOptional, generated if omittedSupply only an assigned tracking number, such as the identifier on a mailer bag. An existing number is rejected. Omit the field rather than sending an empty string.
customerParcelImagestringOptionalParcel image value supported by your integration. This is a JSON field; this endpoint is not a multipart upload.
issuerDeviceInfoobjectOptionalIssuer device metadata; a server integration may omit it. Do not invent device values.
senderCompany, receiverCompanyobjectOptionalExisting business-customer details when sending for a company. They are separate from agent business/office identities.

Optional device metadata uses fields such as deviceId, deviceBrand, deviceModel, operatingSystem, operatingSystemVersion, deviceManufacturer, appName, appVersion, appBuildNumber, appBundleId, batteryLevel, macAddress, carrier, phoneNumber, latitude, and longitude. Omit this object when those observations are not available.

Company-party objects represent a business customer and can include uuid, name, tinNumber, companyType, calling-code/phone fields, and address. Preserve an existing company-party record if your integration uses one; the normal person-to-person example omits these objects.

The endpoint accepts only its defined request fields. Do not send separate top-level origin/destination or client-calculated prices: the selected offer supplies those. Do not rely on fields such as businessUuid, requestSavedTime, insuranceFee, or whatIsInTheParcel being stored by this endpoint. The server derives insurance fees from the selected price, and generates booking timestamps, the parcel UUID, parcel number, and an omitted tracking number.

Sender, recipient, and creator objects​

FieldSender / each recipientcreatedBy
fullNameSend a real full name, at least 2 characters.Use the authenticated agent's name.
phoneNumberString of digits; supply a valid national number with the separate calling code. Normalized values must meet the 8–13 digit validation.Use the authenticated agent's phone number.
countryCodeCalling-code digits as a string, e.g. "255"; no +.Use the agent's calling code.
countryCodeNameTwo-letter alphabetic country code, e.g. "TZ".Use the agent's country code.
uuidOptional for a new contact; a UUID v4 is generated if absent. Preserve valid existing contact UUIDs.Required for the integration: use the existing login user UUID, not a newly generated contact UUID.
emailOptional string.Optional; preserve if available.
physicalAddressOptional shared address object with numeric coordinates.Optional.

physicalAddress is contact metadata. Pickup and delivery locations come from offer.origin and offer.destination, not the contacts' addresses. Although the server can default a missing name to No Name and normalize phone inputs, integrations should collect complete contact information before booking.

Example sender:

{
"fullName": "Example Sender",
"phoneNumber": "700000001",
"countryCode": "255",
"countryCodeName": "TZ"
}

Example one-person receiver array:

[
{
"fullName": "Example Recipient",
"phoneNumber": "700000002",
"countryCode": "255",
"countryCodeName": "TZ"
}
]

Example creator object, using fictitious values:

{
"uuid": "00000011-0000-4000-8000-000000000011",
"fullName": "Example Agent",
"phoneNumber": "700000003",
"countryCode": "255",
"countryCodeName": "TZ"
}

Payment values​

ValueMeaning recorded on the parcel
CASHCash payment.
WALLETWallet payment.
MNOMobile network/mobile-money payment.
CARDCard payment.
ONLINEOnline payment.
PAY_ON_ARRIVALPayment on arrival.
INVOICEInvoiced payment.
MONTHLYMonthly account settlement.
CHEQUECheque payment.

These are accepted payment classifications, not promises that this request initiates each external payment method. The agent route still checks available agent credit and starts its agent payment processing. Choosing CASH, ONLINE, or PAY_ON_ARRIVAL does not bypass that check. Agree settlement arrangements with Safiri; the example records CASH.

See Float Account Management for account context. For this parcel endpoint specifically, insufficient balance/overdraft errors currently return 400 through the handler's fallback, not a guaranteed 402.

Map search data to submission​

Retained inputSearchSubmission
Concrete catalogue item typewhatIsInTheParcel[].idcategory[]
Concrete catalogue item uuidOptional whatIsInTheParcel[].uuidNo dedicated structured item-UUID field in this request.
Item titles and item-level quantitiesKeep in your formInclude readable contents in description if needed. They are not persisted as a structured contents array.
Handling propertiesproperties[]property[]
Pickup and destinationorigin, destinationRetained within the complete selected offer.
Dimensions, weight, declared valueSearch fieldsCorresponding fields with the same values.
Selected resultresponse.data[n]offer
Selected price keyA key in offer.pricetypeOfService
Authenticated userLogin response userContact/identity projection in createdBy

Build the request from the live offer​

The following pure function shows the complete normal request. Its arguments come from your validated form, login response, catalogue selection, and live search response; it does not send anything. The workflow shows these inputs being obtained and used together.

function buildAgentBooking({ offer, serviceKey, user, sender, receivers,
selectedItems, parcel }) {
if (!offer?.price?.[serviceKey]) {
throw new Error('Choose a price key present in this offer.');
}
return {
offer,
typeOfService: serviceKey,
sender,
receiver: receivers,
createdBy: {
uuid: user.uuid,
fullName: user.fullName,
phoneNumber: user.phoneNumber,
countryCode: user.countryCode,
countryCodeName: user.countryCodeName,
...(user.email ? { email: user.email } : {}),
},
description: parcel.description,
typeOfPayment: parcel.typeOfPayment,
weight: String(parcel.weight),
height: String(parcel.height),
width: String(parcel.width),
length: String(parcel.length),
estimatedValue: String(parcel.estimatedValue),
quantity: String(parcel.quantity ?? 1),
category: selectedItems.map((item) => item.type),
property: parcel.properties ?? [],
insurance: parcel.insurance ?? 'BASIC',
};
}

Generate booking-request.json from that object with your live, complete selected offer, as the workflow example does. Sending only { "offer": { "uuid": "..." } } is not supported. Do not use the abbreviated saved-response excerpt below as a new booking request.

curl --fail-with-body --request POST \
"$SAFIRI_BASE_URL/api/parcel/delivery-request/agent" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--header 'Content-Type: application/json' \
--header 'Content-Language: en' \
--header 'x-device-timezone: Africa/Dar_es_Salaam' \
--data-binary @booking-request.json

Choose either this cURL request or the workflow's JavaScript submission for a selection; running both can create two bookings.

Successful submission​

Returns 200 OK with the saved parcel object directly, not { data: parcel }. Representative response excerpt:

{
"_id": "507f1f77bcf86cd799439012",
"uuid": "00000012-0000-4000-8000-000000000012",
"trackingNumber": "EXAMPLE000123",
"parcelNumber": "260928000123",
"type": "B2C_PARCEL",
"typeOfService": "ECONOMY",
"typeOfPayment": "CASH",
"price": "6000",
"priceCurrency": "TZS",
"quantity": "1",
"category": [
"documents"
],
"property": [],
"description": "1 document envelope",
"currentStatus": "ORDER_CREATED",
"status": [
{
"id": "00000013-0000-4000-8000-000000000013",
"name": "ORDER_CREATED",
"date": "2026-09-28T07:05:00.000Z"
}
],
"pickUpAddress": {
"name": "Example Agent Office",
"description": "Example Agent Office, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"uuid": "00000002-0000-4000-8000-000000000002",
"type": "AGENT"
},
"dropOffAddress": {
"latitude": -6.804,
"longitude": 39.253,
"name": "Example Recipient",
"description": "Example recipient entrance, Dar es Salaam, Tanzania",
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "ADDRESS"
}
}

The full response also includes the sender, recipients, creator, insurance, and the selected offer. In the saved offer, the server has selected a single price/duration for the requested service and for its segments; it is not the original multi-service search offer.

Returned fieldHow to use it
uuidStable parcel reference for GET /api/parcel?uuid=.... It is different from the search offer UUID.
trackingNumberTracking/mailer-bag identifier; retain for support and retrieval.
parcelNumberServer-generated parcel number.
_idRecord identifier; different from the parcel UUID.
price, priceCurrencySelected booking price and currency. Monetary values may be strings.
typeOfService, offerSelected service and selected route/price details.
pickUpAddress, dropOffAddressSaved physical pickup and delivery locations.
currentStatus, statusCurrent delivery status and history, initially ORDER_CREATED.

A 200 confirms that the parcel has been saved. Payment processing and delivery orchestration continue asynchronously; workflow-start failures can be logged after saving without changing this response to an error. Do not display “paid”, “rider assigned”, or “delivered” solely because creation returned 200.

Retrieve a saved parcel​

GET /api/parcel?uuid=<parcelUuid>
GET /api/parcel?trackingNumber=<trackingNumber>

Use one identifier per request and the shared authorization header. Set these shell variables from the saved response, not the offer response.

curl --fail-with-body --get "$SAFIRI_BASE_URL/api/parcel" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--data-urlencode "uuid=$PARCEL_UUID"

Or:

curl --fail-with-body --get "$SAFIRI_BASE_URL/api/parcel" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--data-urlencode "trackingNumber=$TRACKING_NUMBER"

Returns 200 with the parcel object directly, using the same shape as the saved-parcel response, with its latest stored status and any additional tracking/evidence details. Immediately after creation it may still be ORDER_CREATED. A missing identifier returns 400/BAD_REQUEST; a parcel not found returns 400/NOT_FOUND.

Do not call /api/parcel/<parcelUuid>: the separate /api/parcel/:id route expects the response _id. If several query identifiers are sent, trackingNumber overrides uuid, which overrides id; avoid ambiguity by supplying just one.

Errors and recovery​

Controller errors generally have this shape:

{
"error": { "name": "modal" },
"errorCode": "BAD_REQUEST",
"errorMessage": "Transportation offer is required",
"status": 400
}

error can instead be an array of validation errors. Error messages may be localized. Authentication middleware and infrastructure can return a different body, so first inspect HTTP status and then parse the body safely. Do not depend on English message text for every branch.

SituationCurrent response / meaningRecovery
Missing/invalid bearer tokenNormally 401 before the handler.Check authentication and environment; obtain a valid token.
Caller is not a Safiri agent400, FAILED_TO_CREATE_PARCEL_BOOKING.Use the onboarded agent account. Request fields cannot change eligibility.
No business/locker matches200 with [] from list endpoints.Adjust city or use a known valid identifier. Observe the business-list limit.
Unknown business or locker400, NOT_FOUND.Refresh lookup data and verify the correct kind of identifier.
No contents catalogue400, NOT_FOUND.Contact Safiri for the environment's catalogue; do not fall back to fabricated categories.
No offers200, empty search data.Let the user change locations/details or try later; do not attempt to submit.
Partial search results200, offers may still be returned when another provider fails or has no eligible journey.Display actual returned offers; do not assume the list covers every carrier.
Network or unexpected server error during searchNo usable search response. Search does not save a parcel.Retain the inputs and let the user retry the search.
Unavailable agent/locker during search400, generally NOT_FOUND.Check office ID or locker UUID/slot, refresh choices, and re-quote.
Unsupported currency400, BAD_REQUEST from search.Use an agreed supported currency and inspect the error message.
Missing offer400, BAD_REQUEST.Send the complete original selected offer.
Missing sender/recipients400, NOT_FOUND.Send one sender object and a nonempty receiver array.
Invalid contact/parcel fields400, VALIDATION_ERROR; error can contain field details.Correct the indicated data before retrying.
Inactive carrier400, COMPANY_IS_NOT_ACTIVE.Search again and select a current returned offer.
Insufficient credit / overdraft exceededCurrently 400; errorCode may be the generated parcel tracking reference rather than a stable symbolic code.Resolve the account balance with Safiri; do not repeatedly submit or assume 402.
Duplicate supplied tracking number400, BAD_REQUEST.Retrieve by that tracking number and reconcile the existing record before any new submission.
Missing pricing/service setup or unusable company contextSearch returns 400 with a configuration/company error.Verify the company identifier supplied during onboarding and contact Safiri if needed. There is no configuration-management step in this integration.
Timeout, disconnected response, or server error during creationOutcome may be unknown; saving may have happened before the error.Do not blindly repeat the POST. Retrieve by a known parcel UUID or assigned tracking number if available; otherwise reconcile with Safiri.

This endpoint has no documented idempotency-key contract. Reusing an offer UUID or inventing an Idempotency-Key header does not guarantee deduplication. Supplying an existing tracking number triggers a duplicate check, but is not a general concurrent-retry guarantee. Persist a successful response promptly, prevent double submissions in your interface, and keep an unresolved submission pending until reconciled.

For non-agent customer bookings, the sibling route is POST /api/parcel/delivery-request. It has a different payment flow. This guide's agent wallet and eligibility behavior must not be assumed for that route.

Continue with the complete workflow, or return to offer search and selection.

Partner Agent Networks​

Partners can expose their agent businesses and parcel locations through the Partner Agent Feed. This specification describes the endpoint you host and the complete JSON response Safiri will pull to onboard new agents, skip unchanged agents and update changed partner information.

The partner feed contract is documented now; Safiri's background importer will be implemented separately.