Parcel Delivery Workflow
This guide builds a third-party integration that sends a parcel through Safiri using an agent account. It implements the Parcel Delivery API reference, carrying returned identifiers and the complete selected offer from lookups through search, submission, and retrieval.
The main example sends one packed parcel from a selected agent office to a street address. It retrieves current businesses and catalogue items, asks you to select an office and contents, requests offers, and requires an explicit offer/service selection before creating a booking. Street-address and locker variations follow the main example.
Before you start
Obtain a development agent account from Safiri, with usable credit under its wallet/overdraft rules. A successfully authenticated ordinary customer account is not sufficient for POST /api/parcel/delivery-request/agent. Safiri supplies any company context your integration should use and manages service/pricing setup; there is no parcel-configuration call in this workflow.
Use development while integrating:
export SAFIRI_BASE_URL='https://booking-api-dev.safiri.app'
For production the host is https://booking-api.safiri.app. Endpoint paths below already contain /api.
Read Authentication and the agent prerequisites. Credentials and tokens belong on your integration server. Use your own development contact details and physical locations; the values printed in this guide are fictitious examples.
Sequence and data to retain
| Step | Request or client action | Retain for the next step |
|---|---|---|
| 1 | POST /api/logon | Token and authenticated user details. |
| 2 | GET /api/parcel/addresses | Supported pickup/destination choices, countries, and location types. |
| 3 | GET /api/business?cityName=..., then GET /api/business/:uuid when needed | Selected business's office, including its id and coordinates. |
| 4 | GET /api/parcel-content | Selected concrete item types and UUIDs from the newest catalogue. |
| 5 | Collect sender, recipients, actual packed measurements, value, and destination | Validated parcel draft. |
| 6 | POST /api/parcel/search-offers | Full data array, expiry metadata, and the original search inputs. |
| 7 | User selects an offer and exact service key | Full selected offer and a key from offer.price. No separate acceptance API call. |
| 8 | POST /api/parcel/delivery-request/agent | Saved parcel object, especially uuid, trackingNumber, and initial status. |
| 9 | GET /api/parcel?uuid=... | Latest stored parcel state. |
Not all lookup calls are mandatory for every shipment. Known, complete locations can be reused; lockers need their own lookup and slot choice. Discovery can return city-level rows with zero coordinates, so resolve actual pickup/delivery coordinates before step 6. The business list's city filter matches the business address, and its unfiltered result is limited to 50 businesses; see business lookup limitations.
Prepare the parcel input
The example below is a server-side Node.js 22 or newer script with no third-party dependencies. Save the JSON as parcel-input.json next to the script. Replace the example contacts and destination with your own development test data, and make sure the description and measurements match the catalogue items you will select. Amounts use the requested quote currency; measurements use kg and cm.
{
"originCity": "Dar es Salaam",
"currency": "TZS",
"timezone": "Africa/Dar_es_Salaam",
"language": "en",
"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"
},
"sender": {
"fullName": "Example Sender",
"phoneNumber": "700000001",
"countryCode": "255",
"countryCodeName": "TZ"
},
"receiver": [
{
"fullName": "Example Recipient",
"phoneNumber": "700000002",
"countryCode": "255",
"countryCodeName": "TZ"
}
],
"parcel": {
"description": "1 document envelope",
"weight": 0.5,
"height": 2,
"width": 22,
"length": 30,
"estimatedValue": 10000,
"quantity": 1,
"properties": [],
"insurance": "BASIC",
"typeOfPayment": "CASH"
}
}
sender is an object and receiver is an array. The creator is deliberately absent from this input: the script derives it from the authenticated user returned by login. Sender/recipient UUIDs may be omitted for new contacts; do not generate a new user UUID for the creator.
The primary example omits parcelSizeCategory and tracking number; the server defaults the former to CUSTOM_PARCEL and generates the latter. It also omits scheduling and client-provided distance, allowing the search defaults. Consult the offer search fields if your integration needs a scheduled date/time or distance-matrix input.
Catalogue item-level quantities and tags are not persisted as a structured contents array by agent submission. Keep item-level descriptions in your own draft and a readable description on the parcel. The script maps concrete item types to category[] and optional handling properties to property[].
Connected JavaScript example
Save the following code as parcel-workflow.mjs. It exports reusable functions and runs the interactive example only when invoked directly. The example validates common form errors locally, handles JSON and non-JSON HTTP failures, preserves the full offer, and performs no automatic booking retry.
The helper validation is an integration example, not a replacement for server validation. It expects national phone numbers and uppercase two-letter country codes. Your production interface can provide richer phone parsing and field-level feedback.
import { readFile, writeFile } from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function validateAddress(address) {
if (!address || !['ADDRESS', 'AGENT', 'LOCKER', 'OFFICE', 'STATION'].includes(address.type)) {
throw new Error('Supply a valid address type.');
}
const { latitude, longitude } = address;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) ||
Math.abs(latitude) > 90 || Math.abs(longitude) > 180 ||
(latitude === 0 && longitude === 0)) {
throw new Error('Resolve real numeric coordinates before quoting.');
}
for (const field of ['city', 'country', 'description']) {
if (!String(address[field] ?? '').trim()) throw new Error(`Address needs ${field}.`);
}
if (['AGENT', 'LOCKER'].includes(address.type) && !address.uuid) {
throw new Error('Agent/locker address needs its location identifier.');
}
if (address.type === 'LOCKER' && !address.locker?.slotId) {
throw new Error('Select a locker slotId.');
}
return address;
}
export function addressFromOffice(office) {
return validateAddress({
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,
});
}
export function contact(person, creator = false) {
if (!person || String(person.fullName ?? '').trim().length < 2 ||
!/^\d{8,13}$/.test(person.phoneNumber ?? '') ||
!/^\d{1,5}$/.test(person.countryCode ?? '') ||
!/^[A-Z]{2}$/.test(person.countryCodeName ?? '')) {
throw new Error('Complete name, national phone number, calling code, and country code.');
}
if ((creator || person.uuid) && !UUID_V4.test(person.uuid ?? '')) {
throw new Error('Use a valid existing UUID v4 for the creator or known contact.');
}
return {
fullName: person.fullName.trim(),
phoneNumber: String(person.phoneNumber),
countryCode: String(person.countryCode),
countryCodeName: person.countryCodeName,
...(person.uuid ? { uuid: person.uuid } : {}),
...(person.email ? { email: person.email } : {}),
...(person.physicalAddress ? { physicalAddress: validateAddress(person.physicalAddress) } : {}),
};
}
export function catalogueItems(catalogue, language = 'en') {
const defaultLocale = catalogue.locales?.find((locale) => locale.isDefault)?.code || 'en';
const label = (row) => row.title?.[language] || row.title?.[defaultLocale] || row.type || row.id;
return (catalogue.items ?? []).flatMap((row) =>
Array.isArray(row.children)
? row.children.map((child) => ({ ...child, displayName: `${label(row)} / ${label(child)}` }))
: [{ ...row, displayName: label(row) }],
).filter((item) => item.type && item.uuid);
}
export function createClient({ baseUrl, currency, timezone, language = 'en', companyUuid }) {
let token;
async function request(path, { method = 'GET', body } = {}) {
const response = await fetch(`${baseUrl.replace(/\/$/, '')}${path}`, {
method,
headers: {
Accept: 'application/json',
'Content-Language': language,
'Content-Currency': currency,
'x-device-timezone': timezone,
...(companyUuid ? { 'x-company-uuid': companyUuid } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const text = await response.text();
let result;
try { result = JSON.parse(text); } catch { result = text; }
if (!response.ok) {
const error = new Error(`HTTP ${response.status}: ${result?.errorMessage || response.statusText}`);
error.status = response.status;
error.body = result;
throw error;
}
if (!result || typeof result !== 'object') throw new Error('Expected a JSON API response.');
return result;
}
return {
request,
async login(credentials) {
const result = await request('/api/logon', { method: 'POST', body: credentials });
if (!result.token || !result.user?.uuid) throw new Error('Login did not return token/user.');
if (Number(result.user.permissionLevel) !== 222) throw new Error('Use the onboarded agent account.');
token = result.token;
return result.user;
},
};
}
export async function prepareQuote(client, { origin, destination, selectedItems, parcel, sender, receiver, user }) {
validateAddress(origin);
validateAddress(destination);
contact(sender);
contact(user, true);
if (!Array.isArray(receiver) || receiver.length === 0) throw new Error('Provide recipients.');
receiver.forEach((person) => contact(person));
if (!selectedItems.length || selectedItems.some((item) => !item.type || !item.uuid)) {
throw new Error('Choose concrete catalogue items.');
}
for (const field of ['weight', 'height', 'width', 'length']) {
if (!(Number(parcel[field]) > 0) || !Number.isFinite(Number(parcel[field]))) {
throw new Error(`Enter a positive measured ${field}.`);
}
}
if (!Number.isFinite(Number(parcel.estimatedValue)) || Number(parcel.estimatedValue) < 0) {
throw new Error('Enter the declared parcel value.');
}
if (!String(parcel.description ?? '').trim()) throw new Error('Describe the packed contents.');
const payments = ['CASH', 'INVOICE', 'CHEQUE', 'CARD', 'MNO', 'PAY_ON_ARRIVAL', 'MONTHLY', 'ONLINE', 'WALLET'];
if (!payments.includes(parcel.typeOfPayment)) throw new Error('Select an accepted payment type.');
const quantity = Number(parcel.quantity ?? 1);
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Enter a positive whole parcel quantity.');
if (!Array.isArray(parcel.properties ?? []) || (parcel.properties ?? []).some((p) => typeof p !== 'string')) {
throw new Error('Handling properties must be an array of strings.');
}
const draft = structuredClone({ origin, destination, selectedItems, parcel, sender, receiver, user });
const query = {
origin: draft.origin,
destination: draft.destination,
weight: Number(parcel.weight),
height: Number(parcel.height),
width: Number(parcel.width),
length: Number(parcel.length),
estimatedValue: Number(parcel.estimatedValue),
properties: [...(parcel.properties ?? [])],
whatIsInTheParcel: selectedItems.map((item) => ({ id: item.type, uuid: item.uuid })),
insurance: parcel.insurance ?? 'BASIC',
};
const response = await client.request('/api/parcel/search-offers', { method: 'POST', body: query });
if (!Array.isArray(response.data) || !response.meta) throw new Error('Unexpected offer envelope.');
return { draft, query, response };
}
export function chooseOffer(session, offerUuid, serviceKey) {
const expiry = Date.parse(session.response.meta.expiresAt);
if (!Number.isFinite(expiry) || Date.now() >= expiry) throw new Error('Refresh expired offers.');
const offer = session.response.data.find((entry) => entry.uuid === offerUuid);
if (!offer || !Object.hasOwn(offer.price ?? {}, serviceKey) || !offer.price[serviceKey]) {
throw new Error('Choose an offer UUID and exact service key printed for this search.');
}
return offer;
}
export function buildBooking(session, offerUuid, serviceKey) {
const offer = chooseOffer(session, offerUuid, serviceKey);
const { draft } = session;
const { parcel } = draft;
return {
offer,
typeOfService: serviceKey,
sender: contact(draft.sender),
receiver: draft.receiver.map((person) => contact(person)),
createdBy: contact(draft.user, true),
description: parcel.description.trim(),
typeOfPayment: parcel.typeOfPayment,
estimatedValue: String(parcel.estimatedValue),
weight: String(parcel.weight),
height: String(parcel.height),
width: String(parcel.width),
length: String(parcel.length),
quantity: String(parcel.quantity ?? 1),
property: parcel.properties ?? [],
category: draft.selectedItems.map((item) => item.type),
insurance: parcel.insurance ?? 'BASIC',
};
}
export async function submitSelectedOffer(client, session, offerUuid, serviceKey) {
if (session.submissionAttempted) throw new Error('Reconcile the previous submission before trying again.');
const body = buildBooking(session, offerUuid, serviceKey);
// A lost HTTP response must not cause this example to automatically repeat a booking POST.
session.submissionAttempted = true;
return client.request('/api/parcel/delivery-request/agent', { method: 'POST', body });
}
async function main() {
const input = JSON.parse(await readFile('parcel-input.json', 'utf8'));
const baseUrl = process.env.SAFIRI_BASE_URL || 'https://booking-api-dev.safiri.app';
const client = createClient({
baseUrl, currency: input.currency, timezone: input.timezone,
language: input.language || 'en', companyUuid: process.env.SAFIRI_COMPANY_UUID,
});
const password = process.env.SAFIRI_PASSWORD;
const credentials = process.env.SAFIRI_EMAIL
? { email: process.env.SAFIRI_EMAIL, password }
: { countryCode: process.env.SAFIRI_COUNTRY_CODE, phoneNumber: process.env.SAFIRI_PHONE_NUMBER, password };
if (!password || (!credentials.email && (!credentials.countryCode || !credentials.phoneNumber))) {
throw new Error('Provide login credentials through the environment.');
}
const user = await client.login(credentials);
const [locations, businesses, catalogue] = await Promise.all([
client.request('/api/parcel/addresses'),
client.request(`/api/business?cityName=${encodeURIComponent(input.originCity)}`),
client.request('/api/parcel-content'),
]);
console.log('Agent discovery countries:', locations.supportedCountries?.agents);
console.table(businesses.map((business) => ({ uuid: business.uuid, name: business.name })));
const terminal = createInterface({ input: stdin, output: stdout });
try {
const businessUuid = (await terminal.question('Business UUID from the list or onboarding: ')).trim();
const business = await client.request(`/api/business/${encodeURIComponent(businessUuid)}`);
console.table((business.offices ?? []).map(({ id, name, city, country }) => ({ id, name, city, country })));
const officeId = (await terminal.question('Selected office ID: ')).trim();
const office = business.offices?.find((entry) => entry.id === officeId);
if (!office) throw new Error('Select an office returned by the business.');
const items = catalogueItems(catalogue, input.language || 'en');
console.table(items.map(({ uuid, type, displayName }) => ({ uuid, type, displayName })));
const ids = [...new Set((await terminal.question('Selected item UUIDs, comma separated: '))
.split(',').map((value) => value.trim()).filter(Boolean))];
const selectedItems = ids.map((id) => {
const item = items.find((entry) => entry.uuid === id);
if (!item) throw new Error('Select concrete items from the catalogue.');
return item;
});
const session = await prepareQuote(client, {
origin: addressFromOffice(office), destination: input.destination,
selectedItems, parcel: input.parcel, sender: input.sender, receiver: input.receiver, user,
});
const saveJson = (name, value) => writeFile(name, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
await saveJson('search-request.json', session.query);
await saveJson('offer-response.json', session.response);
const choices = session.response.data.flatMap((offer) => Object.entries(offer.price ?? {})
.filter(([, price]) => price)
.map(([service, price]) => ({ offerUuid: offer.uuid, service, total: price.grandTotal, currency: price.currency })));
if (!choices.length) {
console.log('No selectable offers. Change the request or try another search. No booking was made.');
return;
}
console.table(choices);
const offerUuid = (await terminal.question('Selected offer UUID: ')).trim();
const serviceKey = (await terminal.question('Exact selected service key: ')).trim();
const booking = buildBooking(session, offerUuid, serviceKey);
await saveJson('selected-offer.json', booking.offer);
await saveJson('booking-request.json', booking);
console.log('Pickup:', booking.offer.origin.description);
console.log('Destination:', booking.offer.destination.description);
console.log('Service:', serviceKey, 'Price:', booking.offer.price[serviceKey].grandTotal, booking.offer.price[serviceKey].currency);
console.log('Request saved to booking-request.json for review.');
const confirmation = (await terminal.question(`Type SEND to create this booking at ${baseUrl}, or press Enter to stop: `)).trim();
if (confirmation !== 'SEND') return;
let saved;
try {
saved = await submitSelectedOffer(client, session, offerUuid, serviceKey);
} catch (error) {
console.error('Submission did not return a saved parcel. No automatic retry was made.');
console.error('For an unknown outcome, reconcile the booking before another POST.');
throw error;
}
await saveJson('saved-parcel.json', saved);
console.log('Saved parcel:', saved.uuid, 'Tracking:', saved.trackingNumber, 'Status:', saved.currentStatus);
if (!saved.uuid) throw new Error('Review saved-parcel.json: response has no parcel UUID. Do not resubmit.');
try {
const latest = await client.request(`/api/parcel?uuid=${encodeURIComponent(saved.uuid)}`);
console.log('Latest stored delivery status:', latest.currentStatus);
} catch (error) {
console.error('The booking was saved; only the follow-up lookup failed. Use saved-parcel.json to retrieve it later.');
throw error;
}
} finally {
terminal.close();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(error.message);
if (error.body?.errorCode) console.error('API error code:', error.body.errorCode);
if (Array.isArray(error.body?.error)) console.error('Validation details:', JSON.stringify(error.body.error));
process.exitCode = 1;
});
}
Run the example
Supply credentials through environment variables from your local secret-management mechanism. Use either SAFIRI_EMAIL or the pair SAFIRI_COUNTRY_CODE and SAFIRI_PHONE_NUMBER, and supply SAFIRI_PASSWORD. If Safiri assigned a company context, set SAFIRI_COMPANY_UUID; otherwise leave it unset.
node parcel-workflow.mjs
Follow the interactive prompts:
- Choose a business UUID from the displayed results or an existing UUID supplied during onboarding. The script retrieves that business directly, then lists its offices.
- Choose the actual office
id. The script maps it toorigin.uuidwithtype: 'AGENT'. - Select concrete catalogue item UUIDs. Group rows are expanded into children; the script prints translated display names alongside the actual identifiers.
- Review the offers. Each row shows an offer UUID, exact service key, total, and currency. An empty result ends without submitting.
- Enter your selected offer UUID and service key. The example refuses a key not present on that offer and requires a refreshed search after the returned expiry.
- Review the pickup, destination, service, price, and generated
booking-request.json. - Type
SENDonly to create the booking; pressing Enter stops before submission. After a successful save, the example retains the response and retrieves the parcel once.
A simple service might be ECONOMY; a composed service might be ECONOMY-24_HOURS. Enter the exact value returned for the selected offer. Do not split it, rename it, or assume every offer provides every service.
Files produced by the example
| File | Contents and use |
|---|---|
search-request.json | Resolved locations and parcel data actually sent to search. |
offer-response.json | Full response including expiry metadata and all returned offers. |
selected-offer.json | Original selected offer with service price maps intact. |
booking-request.json | Complete agent request constructed from the retained quote, parcel draft, and login user. |
saved-parcel.json | Successful saved-parcel response, written before the follow-up lookup. |
The example requests restrictive permissions for newly created files. These files can contain contact information and should remain local to the integration test; do not commit them. The saved response file is the recovery reference if a later lookup fails. For a production integration, persist successful booking references in your application's durable storage.
Submit with cURL instead
To inspect and submit the generated request with cURL, press Enter instead of SEND at the script's final prompt. Use a valid token from the login request, then submit once, before the quote expires:
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
Use either the script's submission or cURL for this booking, not both. Review the actual values if your input uses another language or timezone. A cURL call does not perform the script's expiry check; search again and rebuild the request if necessary.
The Parcel Delivery reference provides cURL examples for all supporting endpoints, including the offer search example.
Variation: street-address pickup
To collect from a street address instead of an agent, skip business/office lookup and build the same address shape used for the destination:
{
"name": "Example Sender Address",
"description": "Example sender entrance, Dar es Salaam, Tanzania",
"latitude": -6.772,
"longitude": 39.229,
"city": "Dar es Salaam",
"country": "Tanzania",
"type": "ADDRESS"
}
Pass the geocoded street address as origin to prepareQuote. Keep type: 'ADDRESS'; do not invent an office UUID or reuse a city-discovery row with placeholder coordinates. Keep all other quote and submission steps unchanged. Re-query if either address changes.
Variation: locker pickup or delivery
Discover lockers and inspect the chosen one using the locker endpoints. The script's client.request helper can call both:
async function loadLockerChoices(client, cityName, selectedLockerUuid) {
const lockers = await client.request(
`/api/locker/get-lockers?cityName=${encodeURIComponent(cityName)}`,
);
if (!selectedLockerUuid) return { lockers };
const locker = await client.request(`/api/locker/${encodeURIComponent(selectedLockerUuid)}`);
return { lockers, locker };
}
Let the user choose the locker and a suitable slot. A locker UUID identifies the locker, a slot UUID identifies a slot record, and slotId identifies the compartment to put in the request. This helper builds the address after the user chooses the slot; it does not reserve it:
function addressFromLocker(locker, selectedSlotId, parcel) {
const slot = locker.slots?.find((entry) => entry.slotId === selectedSlotId);
if (!slot || !slot.available || slot.status !== 'AVAILABLE') {
throw new Error('Refresh locker details and select an available slot.');
}
for (const [measurement, limit] of [
['length', 'maxLength'], ['width', 'maxWidth'],
['height', 'maxHeight'], ['weight', 'maxWeight'],
]) {
const value = Number(parcel[measurement]);
const maximum = Number(slot[limit]);
if (!(value > 0) || !(maximum > 0) || value > maximum) {
throw new Error(`Verify ${measurement} fits the selected slot.`);
}
}
return {
...locker.address,
uuid: locker.uuid,
type: 'LOCKER',
latitude: Number(locker.address.latitude),
longitude: Number(locker.address.longitude),
name: locker.name || locker.address.name,
description: locker.address.description ||
[locker.name, locker.address.city, locker.address.country].filter(Boolean).join(', '),
locker: { slotId: slot.slotId },
};
}
Supply this address as origin or destination to prepareQuote along with the same actual measurements. Also review returned supported categories and other location restrictions. This example checks dimensions in the entered orientation; it does not guarantee fit or perform packing/rotation calculations. Lookup data can be stale, so server quoting and booking outcomes still need to be handled.
A successful lookup or quote is not proof that a slot has been reserved. If the selection changes or becomes unavailable, refresh and obtain a new offer. Operational locker access and management are outside this sending workflow.
Understand completion and recover from errors
The submission response is a parcel object directly. A successful result includes a new parcel UUID, tracking number, parcel number, selected service, price/currency, and initial ORDER_CREATED status. Save these references; the offer UUID is not the saved parcel UUID. See the response example and field meanings.
Payment and delivery processing continue after creation. Do not display “paid”, “rider assigned”, or “delivered” based solely on the successful POST. A failed follow-up GET does not undo the saved booking or justify creating it again.
To retrieve later, set PARCEL_UUID from saved-parcel.json:
curl --fail-with-body --get "$SAFIRI_BASE_URL/api/parcel" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--data-urlencode "uuid=$PARCEL_UUID"
Or use an assigned/returned tracking number:
curl --fail-with-body --get "$SAFIRI_BASE_URL/api/parcel" \
--header "Authorization: Bearer $SAFIRI_TOKEN" \
--data-urlencode "trackingNumber=$TRACKING_NUMBER"
Use the query form: /api/parcel/:id expects the response _id, not the saved uuid.
| Outcome | Integration action |
|---|---|
| Login or authorization failure | Verify token, environment, and the provisioned agent account. |
| Empty lookup | Change city/location or resolve a known business; do not fabricate a location. |
| Empty offers | Return to parcel details and allow another search. |
| Invalid fields | Show actionable field errors, fix the draft, and re-quote if quote inputs changed. |
| Unavailable location / inactive carrier | Refresh lookup/search results and obtain a new selection. |
| Balance/overdraft failure | Resolve the agent account balance. This agent route currently returns 400, not a guaranteed 402. |
| Duplicate assigned tracking number | Retrieve that parcel and compare it with the intended shipment before doing anything else. |
| Submission timeout or disconnected response | Treat the outcome as unknown. Do not repeat the POST automatically. |
| Creation succeeded, retrieval failed | Retain the saved references and retry the GET later. Do not re-create the parcel. |
For an unknown submission outcome, retrieve using a known returned parcel UUID or an assigned tracking number when available. If the server generated the tracking number and no response reached you, the offer UUID cannot retrieve the parcel; reconcile with Safiri before another POST. An immediate not-found lookup after an ambiguous result is not proof that a concurrent request cannot still complete.
There is no documented idempotency-key guarantee on this endpoint. A client reference or repeated offer UUID alone does not prevent duplicate bookings. The example allows only one submission attempt per prepared session; your application should keep uncertain submissions pending and reconcile them rather than clearing the state and silently retrying.
See the full error table for response details. Non-agent customers use POST /api/parcel/delivery-request and a different payment flow; this guide's agent settlement behavior is specific to the /agent route.
Integration acceptance checklist
Before releasing your integration, verify in your assigned development environment that you can:
- Authenticate as an agent and retain both token and creator identity.
- Resolve a real office or street address instead of using zero-coordinate discovery rows.
- Select standalone and grouped catalogue items using live identifiers.
- Request offers with measured kilograms/centimetres and consistent currency/timezone context.
- Handle empty offers and allow the user to change inputs.
- Display the selected route, exact service key, price, and currency before submission.
- Submit the full original offer with sender object, recipient array, and creator object.
- Persist the saved parcel UUID and tracking number and retrieve the record afterward.
- Keep creation, payment completion, and delivery status distinct.
- Prevent double submission and reconcile ambiguous creation outcomes without blind retries.
The reference documents observed request/response behavior. Complete environment-specific onboarding and development verification with Safiri before using production accounts.