Parcel Transportation Offers
POST /parcel/search-offers returns parcel delivery offers for a requested pickup and drop-off. The endpoint can return a single freelance rider journey, a transportation-company journey, or a stitched journey that combines first-mile, middle-mile, and last-mile segments.
Use this endpoint before creating a customer parcel delivery request. The selected offer is later persisted on the parcel so operations, billing, rider assignment, and customer tracking all know which delivery path was purchased.
Endpoint
POST /parcel/search-offers
The controller is searchParcelTransportationOffers in src/controller/parcel/index.ts. The route is registered in src/controller/parcel/router.ts.
When To Use It
Call this endpoint when a web or mobile client needs to:
- quote parcel delivery from one address/location to another;
- check whether lockers, agents, offices, or stations can participate in the journey;
- find a route that can carry the parcel by size, weight, estimated value, and distance;
- compare
ECONOMY,EXPRESS, or carrier-specific price buckets; - receive an offer object that can be selected during parcel checkout.
Authentication
Send the same bearer token used by other Safiri Booking API endpoints.
Authorization: Bearer <JWT_TOKEN>
The endpoint also forwards this token to child parcel-trip APIs when it searches for middle-mile carrier segments.
Headers
| Header | Required | Default | Purpose |
|---|---|---|---|
Authorization | Yes | None | Authenticates the caller and is forwarded to child APIs. |
Content-Type | Yes for JSON body | application/json | Required when sending a JSON body. |
Content-Language | No | en | Localizes errors and duration labels. |
Content-Currency | No | TZS | Currency used when calculating and returning rider prices. Must exist in CURRENCY. |
x-company-uuid | No | Safiri company UUID | Company configuration used for parcel pricing and supported transport rules. |
x-website-owner-uuid | No | Safiri company UUID | Fallback company UUID when x-company-uuid is absent. |
x-company | No | Empty string | Company display name forwarded to child APIs. |
x-website-owner | No | Empty string | Fallback company display name. |
x-device-timezone | No | Africa/Dar_es_Salaam | Fallback timezone when the origin city/country cannot be mapped to a timezone. |
Company UUID precedence is:
x-company-uuidx-website-owner-uuidconfig.SAFIRI_UUID
Company name precedence is:
x-website-ownerx-company- empty string
Request Body
The endpoint accepts a JSON body. If the body is empty, it falls back to req.query; this supports older clients that still send query-string payloads.
Only the fields listed below are accepted. Extra fields are ignored by lodash/pick before any pricing or route logic runs.
| Field | Type | Required | Description |
|---|---|---|---|
origin | Address or JSON string | Yes | Pickup address/location. String values are parsed as JSON. |
destination | Address or JSON string | Yes | Drop-off address/location. String values are parsed as JSON. |
weight | number/string | Yes | Parcel weight used for size category and transport capability checks. |
height | number/string | Yes | Parcel height used for size category and transport capability checks. |
width | number/string | Yes | Parcel width used for size category and transport capability checks. |
length | number/string | Yes | Parcel length used for size category and transport capability checks. |
estimatedValue | number/string | No | Parcel value used for insurance and threshold checks. Defaults to 0. |
properties | string[] | No | Parcel property tags used by locker/agent availability checks. Defaults to []. |
whatIsInTheParcel | string[] | No | Content categories forwarded to child parcel-trip APIs. Defaults to []. |
distance | string | No | JSON-encoded Google Distance Matrix response. If absent or invalid, the backend calculates haversine distance. |
insurance | string | No | Insurance coverage. Defaults to BASIC. |
date | string | No | Requested delivery date. Defaults to today in the resolved timezone. |
time | string | No | Requested local time. Defaults to current time in the resolved timezone. |
Address Shape
origin and destination use the parcel Address shape. The endpoint mostly needs coordinates, city, country, location type, and optional locker/office metadata.
{
"uuid": "pickup-location-uuid",
"latitude": -15.4161431,
"longitude": 28.2815028,
"name": "Arcades pickup point",
"description": "Arcades Shopping Mall, Lusaka",
"city": "Lusaka",
"country": "Zambia",
"type": "ADDRESS",
"locker": {
"slotId": "A-01"
}
}
Supported location types are:
| Type | Meaning |
|---|---|
ADDRESS | A customer pickup or delivery address. |
LOCKER | A Safiri locker and optional slot. Requires availability checks. |
AGENT | A Safiri agent location. Requires availability and capacity checks. |
OFFICE | A company office or handoff office. |
STATION | A carrier station. Stations are treated as middle-mile handoff points. |
Example Request
curl -X POST "https://booking-api.safiri.app/parcel/search-offers" \
-H "Authorization: Bearer $SAFIRI_TOKEN" \
-H "Content-Type: application/json" \
-H "Content-Language: en" \
-H "Content-Currency: ZMW" \
-H "x-company-uuid: <company-uuid>" \
-H "x-device-timezone: Africa/Lusaka" \
-d '{
"origin": {
"latitude": -15.4161431,
"longitude": 28.2815028,
"description": "Arcades Shopping Mall, Lusaka",
"city": "Lusaka",
"country": "Zambia",
"type": "ADDRESS"
},
"destination": {
"latitude": -15.3308,
"longitude": 28.4526,
"description": "Kenneth Kaunda International Airport",
"city": "Lusaka",
"country": "Zambia",
"type": "OFFICE"
},
"weight": 2,
"height": 12,
"width": 18,
"length": 30,
"estimatedValue": 300,
"properties": ["FRAGILE"],
"whatIsInTheParcel": ["DOCUMENTS"],
"insurance": "BASIC",
"date": "2026-05-20",
"time": "10:30"
}'
Response
type TransportationOffersResponse = {
meta: {
count: number;
links: {
self: string;
};
createdAt: string;
expiresAt: string;
paymentTimeInSeconds: number;
};
data: IParcelOffer[];
};
paymentTimeInSeconds is currently 1800 because OFFER_EXPIRATION_TIME is 30 minutes.
Offer Shape
Each item in data is an IParcelOffer.
| Field | Type | Description |
|---|---|---|
type | string | Always parcel-transportation-offer for top-level offers. |
uuid | string | Generated offer UUID. |
segments | Segment[] | Ordered delivery legs. One offer may contain first-mile, middle-mile, and last-mile segments. |
price | PriceKey | Aggregated price by service bucket. |
distance | DistanceOffer | Aggregated distance for the offer. |
duration | DurationKey | Aggregated duration by service bucket. |
origin | Address | First segment origin. |
destination | Address | Last segment destination. |
estimatedDepartureTime | string | Earliest segment departure time. |
estimatedArrivalTime | string | Latest segment arrival time. |
carrier | Carrier | Middle-mile carrier when present, otherwise rider/freelance carrier. |
self | string | URL that recreates the search. |
Segment Shape
Segments are ordered exactly as the parcel will move through the network.
| Field | Type | Description |
|---|---|---|
type | string | freelance-or-rider-offer or transportation-company-offer. |
uuid | string | Segment UUID. |
distance | DistanceOffer | Distance for this leg. |
duration | DurationKey | Duration for this leg by service bucket. |
estimatedDepartureTime | string | Segment departure time. |
estimatedArrivalTime | string | Segment arrival time. |
origin | Address | Segment pickup/handoff location. |
destination | Address | Segment drop-off/handoff location. |
price | PriceKey | Segment price by service bucket. |
basePrice | PriceKey | Optional raw/base price before final presentation. |
carrier | Carrier | Rider or transportation company operating this segment. |
self | string | Segment-specific search URL. |
Price Shape
PriceKey is keyed by service bucket. Freelance-only offers commonly return ECONOMY and EXPRESS. Transportation company offers may also produce composed keys such as ECONOMY-24_HOURS after aggregation.
type PriceDetail = {
currency: string;
total: string;
safiriCommission: string;
fees: Array<{
amount: string;
type: string;
}>;
grandTotal: string;
duration?: DurationOffer;
};
Common fee types include:
| Fee | Meaning |
|---|---|
TRANSPORT | Middle-mile carrier transport cost. |
SAFIRI_SERVICE | Safiri service fee or commission bucket. |
FACILITATION | Agent or location facilitation fee. |
LOCKER | Locker usage fee. |
STORAGE | Storage fee when location handling requires storage. |
GOVERNMENT | Government or congestion/tax-like fee normalized into the government bucket. |
INSURANCE | Insurance fee. |
Example Response
{
"meta": {
"count": 1,
"links": {
"self": "https://booking-api.safiri.app/parcel/search-offers?origin=%7B...%7D&destination=%7B...%7D&weight=2"
},
"createdAt": "2026-05-19T11:04:11.000+02:00",
"expiresAt": "2026-05-19T11:34:11.000+02:00",
"paymentTimeInSeconds": 1800
},
"data": [
{
"type": "parcel-transportation-offer",
"uuid": "8631df93-a295-4408-918c-c40ede5a0590",
"segments": [
{
"type": "freelance-or-rider-offer",
"uuid": "first-mile",
"distance": {
"text": "10.4 km",
"value": 10.4,
"unit": "kilometer"
},
"duration": {
"ECONOMY": {
"text": "2-4 hours",
"value": 14400,
"unit": "seconds"
},
"EXPRESS": {
"text": "2-3 hours",
"value": 10800,
"unit": "seconds"
}
},
"estimatedDepartureTime": "2026-05-20T10:30:00",
"estimatedArrivalTime": "2026-05-20T13:30:00",
"origin": {
"city": "Lusaka",
"country": "Zambia",
"type": "ADDRESS"
},
"destination": {
"city": "Lusaka",
"country": "Zambia",
"type": "OFFICE"
},
"price": {
"ECONOMY": {
"currency": "ZMW",
"total": "70",
"safiriCommission": "14",
"fees": [],
"grandTotal": "84"
}
},
"carrier": {
"type": "MOTORBIKE",
"category": "FREELANCER_OR_RIDER"
},
"self": "https://booking-api.safiri.app/parcel/search-offers?..."
}
],
"origin": {
"city": "Lusaka",
"country": "Zambia",
"type": "ADDRESS"
},
"destination": {
"city": "Lusaka",
"country": "Zambia",
"type": "OFFICE"
},
"price": {
"ECONOMY": {
"currency": "ZMW",
"total": "70",
"safiriCommission": "14",
"fees": [],
"grandTotal": "84"
}
},
"distance": {
"text": "10.4 km",
"value": 10.4,
"unit": "kilometer"
},
"duration": {
"ECONOMY": {
"text": "2-4 hours",
"value": 14400,
"unit": "seconds"
}
},
"estimatedDepartureTime": "2026-05-20T10:30:00",
"estimatedArrivalTime": "2026-05-20T13:30:00",
"carrier": {
"type": "MOTORBIKE",
"category": "FREELANCER_OR_RIDER"
},
"self": "https://booking-api.safiri.app/parcel/search-offers?..."
}
]
}
Error Responses
Errors follow the controller's standard error envelope.
{
"error": {
"name": "modal"
},
"errorCode": "BAD_REQUEST",
"errorMessage": "currencyNotSupported",
"status": 400
}
Common failures:
| Status | Cause | Notes |
|---|---|---|
400 | Unsupported Content-Currency | The currency must exist in the CURRENCY enum. |
400 | Company not found | Applies when a non-Safiri company UUID is provided but no active company is found. |
400 | Parcel configuration not found | The company must have parcel delivery configuration. |
400 | Origin locker or agent unavailable | Pickup LOCKER or AGENT cannot accept the parcel. |
400 | Destination locker or agent unavailable | Destination LOCKER or AGENT cannot accept the parcel in freelance-only fallback. |
Middle-mile API failures are logged and do not immediately fail the whole request. The endpoint can still return freelance-only offers when supported by configuration.
Internal Child APIs
The endpoint can call these child APIs to find middle-mile transportation-company segments:
| Child API | Triggering mode | Purpose |
|---|---|---|
POST /api/parcel-trip/flight | VEHICLE_TYPE.AIR | Searches flight-based parcel trips. |
POST /api/parcel-trip/intercity | VEHICLE_TYPE.UPCOUNTRY_CARGO | Searches intercity/upcountry parcel trips. |
POST /api/parcel-trip/cargo | VEHICLE_TYPE.CARGO | Searches cargo parcel trips. |
Each child request receives the normalized origin, destination, dimensions, value, insurance, parcel content, travel class, date, time, timezone, localization headers, company headers, and authorization token.