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:
- Authenticate and retain the token and agent user details.
- Resolve the actual pickup and destination addresses.
- Load the latest parcel-contents catalogue and collect parcel measurements.
- Search offers and let the sender choose an offer and service.
- Submit the selected offer with sender, recipient, and parcel details.
- 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
| Environment | Base URL |
|---|---|
| Development | https://booking-api-dev.safiri.app |
| Production | https://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
| Header | Usage and default |
|---|---|
Authorization | Required on every endpoint below: Bearer <token>. Login is the exception. |
Content-Type | Send application/json for login, offer search, and submission bodies. |
Content-Language | Optional; defaults to en where used for localized errors and labels. Catalogue titles remain translation maps. |
Content-Currency | Optional 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-timezone | Send 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-uuid | Optional 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:
| Header | Meaning |
|---|---|
x-company | Company display name; defaults to an empty string. |
x-website-owner-uuid | Company UUID fallback when x-company-uuid is absent. |
x-website-owner | Company 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 path | Purpose | When needed |
|---|---|---|
GET /api/parcel/addresses | Discover 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/:uuid | Retrieve 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/:id | Retrieve one locker by its UUID. | Inspect a selected locker. |
GET /api/parcel-content | Get the latest contents catalogue. | Populate “What's in the parcel?”. |
POST /api/parcel/search-offers | Quote delivery using addresses and parcel details. | Before selecting a delivery service. |
POST /api/parcel/delivery-request/agent | Save 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 parameter | Values | Behavior |
|---|---|---|
agents | true or false | Include agent discovery rows. |
lockers | true or false | Include locker discovery rows. |
stations | true or false | Include station/city discovery rows. |
addresses | true or false | Include 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 field | Meaning |
|---|---|
agents, lockers, stations, addresses | Arrays of discovery rows. An empty array may mean disabled by the query filter or no matching data. |
Row city, country, description, type | Display and location context. |
Row uuid / id | Identity, if available. Interpret it according to location type; a generated city-row ID is not a business or office ID. |
Row info | May contain office or locker details associated with the row. |
Row availability | Discovery count, not a reservation or a guaranteed number of free locker slots. |
supportedCountries | Country 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
| Parameter | Required | Meaning |
|---|---|---|
cityName | No | Exact 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
| Field | Meaning |
|---|---|
Locker uuid | Put this in the request address's uuid. |
Slot uuid | Slot record identity; it is not the value of locker.slotId. |
Slot slotId | Compartment identifier, for example A-01; put it in nested locker.slotId. |
available / status | Slot availability indicators; statuses include AVAILABLE, RESERVED, and FULL. |
maxLength, maxWidth, maxHeight | Physical limits in centimetres. |
maxWeight | Weight limit in kilograms. |
categories | Supported parcel size categories. |
fee, currency | Slot fee metadata. Use the returned offer for the complete delivery price. |
metadata.booked | Booked-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
| Field | Integration requirement |
|---|---|
type | Send ADDRESS, AGENT, LOCKER, OFFICE, or STATION to identify the actual location type. |
latitude, longitude | Numeric coordinates of the physical point. Do not use city-discovery placeholders or swap latitude and longitude. |
city, country | Nonempty names used for routing and service availability. |
description | Useful address/entrance description; also used when saving pickup and drop-off points. |
name | Optional location name for display. |
uuid | Office ID for AGENT; locker UUID for LOCKER. Preserve identities supplied for other managed locations. A normal street address need not invent a UUID. |
locker.slotId | Supply the chosen compartment when using a locker. |
| Additional address fields | Preserve 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"
}
| Field | Meaning |
|---|---|
_id | Catalogue document ID; not an item ID for quote requests. |
items | Mixed array of standalone items and dictionary/group rows with children. |
Item uuid | Identity of the selected concrete item. |
Item type | Content identifier used as whatIsInTheParcel[].id during search and in category[] during submission. |
Group id / item groupId | Grouping identifiers linking items to dictionaries[].id. Do not submit a group in place of a selected child. |
title, description | Translation maps keyed by locale code. |
image | Optional image URL. |
weight, length, width, height | Optional prefills in kilograms and centimetres. Measure the actual packed parcel before searching. |
sortOrder | Display order. The endpoint returns ordered rows and children. |
Item tags | String references to tags[].id, not tag UUIDs. |
dictionaries | Group definitions and translated display information. |
tags | Tag definitions, titles, descriptions, and semantic kind. |
locales | Available 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.
| Field | Type | Integration requirement | Default / behavior |
|---|---|---|---|
origin | Address object | Send complete pickup location. | Includes coordinates, city, country, type, description, and managed-location identifiers where applicable. |
destination | Address object | Send complete destination. | Same shape as origin. |
weight | number or numeric string | Send positive measured weight in kilograms. | Used for parcel size and transport capability. |
height | number or numeric string | Send positive packed height in centimetres. | Used for size/capability checks. |
width | number or numeric string | Send positive packed width in centimetres. | Used for size/capability checks. |
length | number or numeric string | Send positive packed length in centimetres. | Used for size/capability checks. |
estimatedValue | number or numeric string | Send 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. |
properties | string[] | Optional handling properties. | Defaults to []; can affect location availability. These are not automatically all catalogue tags. |
insurance | string | Keep a deliberate coverage choice. | Defaults to BASIC. Normally use BASIC or COMPREHENSIVE in this flow. |
date | string | Optional requested date. | Defaults to today in the resolved timezone; the server normalizes supplied dates. |
time | string | Optional local time, HH:mm. | Defaults to the current time in the resolved timezone. |
distance | string | Optional 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[];
};
| Field | Meaning |
|---|---|
meta.count | Number of returned offers. |
meta.createdAt | Search-response creation timestamp. |
meta.expiresAt | Quote validity/display deadline. Re-query if it has passed before the user confirms. |
meta.paymentTimeInSeconds | Currently 1800 (30 minutes). Use the returned value; do not hardcode a countdown. |
meta.links.self | Search-reference metadata. Use the documented POST endpoint for a new search rather than assuming this link is a supported GET operation. |
data | Array 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
| Field | Shape / meaning |
|---|---|
uuid | Search offer UUID, distinct from the parcel UUID generated after submission. |
type | parcel-transportation-offer. |
origin, destination | Full pickup and destination addresses. Preserve them. |
price | Map of exact service keys to price details. |
duration | Duration information keyed by service bucket. |
distance | Distance description/value/unit. Read the unit instead of assuming metres. |
segments | Ordered delivery legs, including first/middle/last mile where applicable. |
carrier | Transportation company or freelance/rider carrier information. |
estimatedDepartureTime, estimatedArrivalTime | Estimated journey timing. |
self | May contain a search reference; it is not a booking or offer-acceptance endpoint. |
Segment fields
| Field | Meaning |
|---|---|
type | freelance-or-rider-offer or transportation-company-offer. |
uuid | Segment identity. |
origin, destination | Pickup/handoff addresses for the leg. |
price, optional basePrice | Service-keyed leg prices. |
duration, distance | Leg timing and distance, with units. |
carrier | Operator for this leg. |
estimatedDepartureTime, estimatedArrivalTime | Estimated leg timing. |
self | Optional/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 field | Meaning |
|---|---|
currency | Currency for that service price. |
total | Price component before other displayed amounts. |
safiriCommission | Safiri commission component. |
fees | Array of fee components with type and amount. |
grandTotal | Quoted total to display for selection. Do not recalculate a replacement booking price from fee labels. |
duration | May 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 type | Meaning |
|---|---|
TRANSPORT | Transportation-company charge. |
SAFIRI_SERVICE | Safiri service fee. |
FACILITATION | Agent/location facilitation. |
LOCKER | Locker usage. |
STORAGE | Storage handling. |
GOVERNMENT | Government fee/tax component. |
INSURANCE | Insurance 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:
- The complete selected object from the response's
dataarray. - The exact selected key from that object's
pricemap. - 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.
| Field | Type | Send / default | Description |
|---|---|---|---|
offer | object | Required | Complete selected item from search data; not the search envelope and not only its UUID. |
typeOfService | string | Required for correct price selection | Exact selected key of offer.price. The server needs this before later defaulting any stored service field. |
sender | ParcelUser | Required | One sender contact object. |
receiver | ParcelUser[] | Required, nonempty | Array even when there is a single recipient. |
createdBy | ParcelUser | Required | Authenticated agent's identity/contact details. Use the actual login user UUID. |
typeOfPayment | string | Required | One of the payment values below. |
description | string | Send | Human-readable packed contents and quantities; trimmed when stored. |
weight | numeric string | Send | Kilograms; match the searched parcel. Numeric inputs are normalized, but strings match the saved contract. |
height, width, length | numeric strings | Send | Centimetres; match the searched parcel. |
estimatedValue | numeric string | Send | Declared value used for the quote. Preserve its currency context. |
quantity | string | Defaults to "1" | Overall parcel quantity, distinct from item-level catalogue quantities. |
category | string[] | Send selected types | Concrete catalogue item types. |
property | string[] | Optional | Handling-property identifiers; note singular property at submission. |
insurance | string | Defaults to BASIC | Keep the coverage used for the selected offer, normally BASIC or COMPREHENSIVE. |
parcelSizeCategory | string | Defaults to CUSTOM_PARCEL | Optional: ENVELOPE, SMALL_PARCEL, MEDIUM_PARCEL, LARGE_PARCEL, or CUSTOM_PARCEL. Omit if you do not have an applicable category. |
trackingNumber | string | Optional, generated if omitted | Supply 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. |
customerParcelImage | string | Optional | Parcel image value supported by your integration. This is a JSON field; this endpoint is not a multipart upload. |
issuerDeviceInfo | object | Optional | Issuer device metadata; a server integration may omit it. Do not invent device values. |
senderCompany, receiverCompany | object | Optional | Existing 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
| Field | Sender / each recipient | createdBy |
|---|---|---|
fullName | Send a real full name, at least 2 characters. | Use the authenticated agent's name. |
phoneNumber | String 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. |
countryCode | Calling-code digits as a string, e.g. "255"; no +. | Use the agent's calling code. |
countryCodeName | Two-letter alphabetic country code, e.g. "TZ". | Use the agent's country code. |
uuid | Optional 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. |
email | Optional string. | Optional; preserve if available. |
physicalAddress | Optional 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
| Value | Meaning recorded on the parcel |
|---|---|
CASH | Cash payment. |
WALLET | Wallet payment. |
MNO | Mobile network/mobile-money payment. |
CARD | Card payment. |
ONLINE | Online payment. |
PAY_ON_ARRIVAL | Payment on arrival. |
INVOICE | Invoiced payment. |
MONTHLY | Monthly account settlement. |
CHEQUE | Cheque 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 input | Search | Submission |
|---|---|---|
Concrete catalogue item type | whatIsInTheParcel[].id | category[] |
Concrete catalogue item uuid | Optional whatIsInTheParcel[].uuid | No dedicated structured item-UUID field in this request. |
| Item titles and item-level quantities | Keep in your form | Include readable contents in description if needed. They are not persisted as a structured contents array. |
| Handling properties | properties[] | property[] |
| Pickup and destination | origin, destination | Retained within the complete selected offer. |
| Dimensions, weight, declared value | Search fields | Corresponding fields with the same values. |
| Selected result | response.data[n] | offer |
| Selected price key | A key in offer.price | typeOfService |
| Authenticated user | Login response user | Contact/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 field | How to use it |
|---|---|
uuid | Stable parcel reference for GET /api/parcel?uuid=.... It is different from the search offer UUID. |
trackingNumber | Tracking/mailer-bag identifier; retain for support and retrieval. |
parcelNumber | Server-generated parcel number. |
_id | Record identifier; different from the parcel UUID. |
price, priceCurrency | Selected booking price and currency. Monetary values may be strings. |
typeOfService, offer | Selected service and selected route/price details. |
pickUpAddress, dropOffAddress | Saved physical pickup and delivery locations. |
currentStatus, status | Current 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.
| Situation | Current response / meaning | Recovery |
|---|---|---|
| Missing/invalid bearer token | Normally 401 before the handler. | Check authentication and environment; obtain a valid token. |
| Caller is not a Safiri agent | 400, FAILED_TO_CREATE_PARCEL_BOOKING. | Use the onboarded agent account. Request fields cannot change eligibility. |
| No business/locker matches | 200 with [] from list endpoints. | Adjust city or use a known valid identifier. Observe the business-list limit. |
| Unknown business or locker | 400, NOT_FOUND. | Refresh lookup data and verify the correct kind of identifier. |
| No contents catalogue | 400, NOT_FOUND. | Contact Safiri for the environment's catalogue; do not fall back to fabricated categories. |
| No offers | 200, empty search data. | Let the user change locations/details or try later; do not attempt to submit. |
| Partial search results | 200, 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 search | No usable search response. Search does not save a parcel. | Retain the inputs and let the user retry the search. |
| Unavailable agent/locker during search | 400, generally NOT_FOUND. | Check office ID or locker UUID/slot, refresh choices, and re-quote. |
| Unsupported currency | 400, BAD_REQUEST from search. | Use an agreed supported currency and inspect the error message. |
| Missing offer | 400, BAD_REQUEST. | Send the complete original selected offer. |
| Missing sender/recipients | 400, NOT_FOUND. | Send one sender object and a nonempty receiver array. |
| Invalid contact/parcel fields | 400, VALIDATION_ERROR; error can contain field details. | Correct the indicated data before retrying. |
| Inactive carrier | 400, COMPANY_IS_NOT_ACTIVE. | Search again and select a current returned offer. |
| Insufficient credit / overdraft exceeded | Currently 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 number | 400, BAD_REQUEST. | Retrieve by that tracking number and reconcile the existing record before any new submission. |
| Missing pricing/service setup or unusable company context | Search 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 creation | Outcome 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.