Shipping Service API
Base URL: https://api.nexgate.co/api/v1
Short Description: The shipping service decides how a shop's order reaches the buyer — pickup at the shop or delivery by a shipper — and runs it from the price on a product card to the money being paid out. It holds the shippers (delivery businesses that register themselves, and providers like Bolt that NexGate plugs in), their coverage, prices, hours and stations; the engine that picks and ranks delivery options; the orders' pickup and delivery steps with their codes; the money per leg; reliability, reviews and the admin tools.
docs/location/location_api_doc.md— pins, wards, areas, shop locations, saved addresses, the drop-off object and place snapshots used everywhere below.docs/shipping/campaigns/campaigns_api_doc.md— shipping campaigns (free / discounted delivery paid by a shipper, the seller or NexGate).commerce_logistics_changes.md— what changed in existing endpoints (cart, checkout, orders, payments) — the numbered §-sections referenced below.
Hints:
- Every endpoint needs a logged-in user (Bearer token) except the Bolt webhook.
- The buyer, the seller and every shipper only talk to NexGate. Nobody books a shipper directly.
- Money never moves on trust. It sits in escrow and moves on proof: a 4-digit code at each handover and the buyer's "✓ It's what I ordered".
- Shippers see a parcel, never the order — no items, prices or order number; exact places and phones only after they accept.
- Coded refusals are HTTP 409 with
data.code(some withdata.details) — switch on the code. Full list in Part 4. - Money is whole TZS; times are East Africa Time.
Standard Response Format
Success
{
"success": true,
"httpStatus": "OK",
"message": "Delivery options",
"action_time": "2026-10-24T10:30:45",
"data": { }
}
Error (coded refusal)
{
"success": false,
"httpStatus": "CONFLICT",
"message": "A shipper has taken this order; it can no longer switch to pickup",
"action_time": "2026-10-24T10:30:45",
"data": { "code": "SWITCH_NOT_ALLOWED", "reason": "A shipper has taken this order; it can no longer switch to pickup" }
}
| Field | Type | Description |
|---|---|---|
success |
boolean | true for success, false for errors |
httpStatus |
string | HTTP status name |
message |
string | Human-readable result, safe to show |
action_time |
string | When the response was made |
data |
object/string | Payload, or { code, reason, details? } for a coded refusal |
Other errors: 400 validation (field messages), 401 not logged in, 403 not allowed (e.g. not the owner, not an admin), 404 not found.
HTTP Method Badge Standards
- GET - GET - read only
- POST - POST - create / act
- PUT - PUT - replace
- DELETE - DELETE - remove
Contents
- Part 1 — How shipping works (read this first) 1.1 The people · 1.2 Pickup or delivery · 1.3 From a product card to a paid order · 1.4 The engine · 1.5 The money · 1.6 Pickup order, step by step · 1.7 Door delivery, step by step · 1.8 Station routes · 1.9 Bolt · 1.10 Codes and the parcel number · 1.11 When a shipper says no · 1.12 What the buyer can change · 1.13 Refusing a parcel at the shop · 1.14 Finishing: confirm or report · 1.15 Shippers: register, set up, go live · 1.16 Reliability, reviews, ratings · 1.17 Group purchase and installments · 1.18 Timers · 1.19 Notifications · 1.20 Admin
- Part 2 — Endpoints for buyers and sellers (estimation, quotations, cart, order actions, favourites, reviews)
- Part 3 — Endpoints for shippers (registration, setup, stations, offers, jobs) and admins
- Part 4 — Statuses, values, error codes, settings
Part 1 — How shipping works
1.1 The people
| Who | Does | Uses |
|---|---|---|
| Buyer | chooses pickup or delivery, pays, gives the code, confirms "✓ It's what I ordered" | the normal app |
| Seller (shop owner) | accepts the order, packs, taps Ready, hands over by typing the code | shop orders screen |
| Native shipper ("delivery business") | registers itself, sets coverage / prices / hours, accepts job offers, carries the parcel | the same app, in "delivery business" mode (owner only for now) |
| Integrated provider (Bolt) | NexGate books it by API; never registers through the app | nothing — the backend talks to Bolt |
| Station business (e.g. a bus company) | a shipper with stations: carries between its own stations | same app |
| Admin | unsticks orders, settles money, suspends shippers, reports | admin endpoints |
Talking to a shipper is by phone only — no chat. Once a shipper accepts, the order shows its name and phone
(fulfillment.shippers); Bolt shows its driver's phone (fulfillment.rider).
1.2 Pickup or delivery
- Every shop's order is one parcel. A cart with three shops = three parcels, each with its own choice.
- PICKUP — the buyer collects at the shop with a code. Free.
- DELIVERY — a shipper brings it: to the door, or to a station where the buyer collects (e.g. Mbeya bus stand). One parcel may travel through up to 3 shippers (legs).
- The choice belongs to the shop, not the item. The cart has one destination for all shops.
- Digital products need neither.
Every order's fulfillment keeps a frozen copy of the shop (pickup) and the destination (dropoff) — see
location doc §1.5 — plus the receiver (name + phone; default = the buyer) and a note for this order only.
1.3 From a product card to a paid order — estimation → quotation → agreement
Product card "🚚 3,500 – 7,000 · 30 min – 2 h" ESTIMATION — a hint; nothing saved
│
Add-to-cart sheet options: ⭐ Mbezi Boda 3,500 · ⚡ … QUOTATION — real prices, held 5 days
Cart one choice per shop, editable (the buyer may change shipper, address, pickup/delivery)
│
Checkout the quotation becomes the deal AGREEMENT — editable until paid
│
Paid price locked for the order's life; seller and shippers still have to sign (accept)
| Stage | Endpoint | Rules |
|---|---|---|
| Estimation | POST /shipping/estimations |
one call per list screen with the header "Deliver to"; cached ~1 h per pair of wards; cost = range of the sensible options |
| Quotation | POST /shipping/quotations |
per shop; covers the shop's whole parcel (cart items + the new one with includeCart: true); prices held 5 days while the parcel (items, quantities, destination) is unchanged — any change = a new quotation at today's prices |
| Cart | PUT /e-commerce/cart/destination, PUT /e-commerce/cart/shops/{shopId}/fulfillment |
defaults below |
| Agreement | checkout session (commerce_logistics_changes.md §11c, §33) |
made when the session is created; expired held price → re-quoted; same shipper same price → silent, else refused DELIVERY_PRICE_CHANGED / DELIVERY_OPTION_GONE — never a silent new price |
Cart defaults for a shop (until the buyer picks):
| The buyer has… | The shop's parcel is |
|---|---|
| a destination and a shipper covers it | Delivery with the pre-selected option |
| a destination, nobody covers it | Pickup + notice "Delivery is not available to X yet" |
| no destination | Pickup + notice "Add a delivery address to see delivery options" |
A pickup the buyer chose never changes by itself; a default pickup turns into delivery once a shipper covers.
On an address change the buyer's shipper is kept while it can carry, else a favourite, else ⭐. Every cart change
answers with a delivery change (deliveryBefore → deliveryAfter, "1 shop switched to pickup", canChangeBack).
Pre-selected option = the shipper the buyer chose for this shop in the cart (if it still can carry) → else the
cheapest favourite that can → else ⭐ Recommended. An earlier pick outside the cart only gets the USED_BEFORE label.
What the app may keep locally: the header "Deliver to" for cards, last map position, recently searched places, a sheet in progress. What must stay on the server: the cart's destination and each shop's quotation (a 5-day price promise), agreements, saved addresses and recents.
1.4 The engine — who can deliver, at what price, in what order
- Where: both places' ward / district / region.
- Who: native shippers whose coverage holds both ends (trip rule
WITHIN_EACH= both in the same ticked area,WITHIN_AND_ACROSS= both in any ticked areas), plus integrated providers (Bolt), plus station routes. - Filter (with a reason): too far (
TOO_FAR), parcel value above its maximum (VALUE_TOO_HIGH), bulky and not accepted (BULKY_NOT_ACCEPTED), no prices (NO_PRICING), never open soon (NOT_OPEN_SOON); paused / suspended shippers are left out. - Cost = base (distance band or flat) + value add-on (insurance ladder) + bulky extra; add-ons rounded up to 100. Distance = straight line × 1.3 (cards: ward centre to ward centre, so every card for the same two wards agrees).
- Time = always a range, from when the shop is ready: the first moment both the shop and the shipper are
open (and before the shipper's same-day cut-off), plus the band's minutes. Closed hours move the time
(
shiftReason:SHOP_PAUSED,SHOP_CLOSED,SHIPPER_CLOSED,CUT_OFF), they never drop the option. - Clean-up (moved under "See all", never deleted): dominated (another option is cheaper-or-equal and earlier-or-equal), outlier (> 3× the cheapest), beyond the top 5. Favourites are never hidden.
- Labels: 💰
CHEAPEST, ⚡FASTEST(by latest arrival), ⭐RECOMMENDED(price + speed; cheap parcels lean on price; a well-rated shipper with ≥ 5 reviews gets a small nudge),USED_BEFORE. One option can carry two. With campaigns, "price" means what the buyer pays. - Order: ⭐, 💰, ⚡, then favourites, then the rest by cost; 5 shown, the others in
more. - Ties rotate daily so equal shippers share the top. New shippers show a "New" badge for 30 days.
1.5 The money
The buyer pays once. Each shop's order holds its money in escrow parts:
Order ORD-… (one shop's parcel)
├ ITEMS part → the seller platform fee on items + tax only
├ LEG part 1 → shipper 1's owner fee = that shipper's delivery commission (0% today)
├ LEG part 2 → shipper 2's owner (station routes: one part per leg)
└ (pickup or a free delivery: ITEMS only)
- Bolt's leg is paid into the ledger account
PROVIDER-BOLT(money NexGate owes Bolt, settled monthly), with no commission — NexGate's margin is a 2% price buffer. - Everything stays held until the buyer taps "✓ It's what I ordered" → all parts released together in one transaction. "⚠ Report a problem" freezes every part until support settles it. No automatic release for now.
- Campaign shares (shipper discount, seller share, NexGate share) are worked out at the quotation, taken at payment
and frozen on the order →
docs/shipping/campaigns/campaigns_api_doc.md.
| Event | Money |
|---|---|
| Buyer cancels before the seller accepts · seller cancels · not collected in time · shop never accepted | everything refunded, stock back |
| Switch to pickup (only until a shipper accepts) | the delivery parts refunded in full |
| Pickup → delivery after payment | the buyer pays the delivery from the wallet into new leg part(s) |
| New shipper, same or cheaper | the leg part moves to the new shipper, the difference is refunded |
| New shipper, dearer | the buyer decides; "use it" tops up the leg part from the wallet |
| Address change before a shipper accepts | the difference is paid from / refunded to the wallet |
| Admin cancel while the shipper is heading to the shop | the shipper keeps 50% (wasted trip), the rest is refunded |
The order's shippingFee / totalAmount follow these changes (they can change after payment).
1.6 Pickup order, step by step
PLACED ──seller accepts──► ACCEPTED ──"Packed & ready"──► READY ──seller types buyer's code──► COLLECTED ──buyer confirms──► COMPLETED
│ buyer may cancel │ seller may cancel │ seller may cancel
└── not accepted in 24 opening hours → CANCELLED └── not collected in 14 days → EXPIRED (refund)
- Accept (
POST /shipping/orders/{id}/accept, seller) — within 24 opening hours of the shop, else the order is cancelled and refunded. The order gets its parcel number (NX-4K7P-2Q). - Ready (
/ready) — one tap "Packed & ready" (packedWelltick recorded). The buyer gets the place (shop, landmark, phone), a collect-by date (14 days) and the 4-digit code by SMS. - Pickup time (optional,
/pickup-time) — "When will you come?" inside the shop's opening hours. Not binding, does not move the 14 days; the seller sees it. Reminders on day 7, day 12 and the last day (SMS). - Collect (
/collect) — at the counter the seller types the buyer's code. - Confirm (
/confirm) — the buyer taps "✓ It's what I ordered" → money released. Or/report-problem.
The buyer can turn a pickup into a delivery any time before collecting (§1.12).
1.7 Door delivery, step by step
PLACED → ACCEPTED → READY → SHIPPER_ACCEPTED → HEADING_TO_SHOP → ON_THE_WAY → DELIVERED → COMPLETED
seller seller shipper accepts shipper taps seller types shipper types buyer
accepts Ready (offer) "heading" rider's code buyer's code confirms
- Paid — the shipper already sees the parcel under Upcoming (areas only, value band, size, its cost): "Waiting for the seller" → "Seller is packing". Not a commitment; cards can disappear.
- Seller accepts — parcel number given; the seller's app can print a QR label (parcel number only) or the seller writes the number on the box. A non-binding heads-up goes to the shipper.
- Seller taps Ready — the pop-up needs both ticks: "I have packed it well" and "the parcel number / label is
on the package" (
READY_CONFIRM_REQUIRED). The job offer goes to the shipper with a countdown (its response window, default 30 min, counted only while it is open; closed for more than 4 h → the next shipper at once). - Shipper accepts = signs the deal. From here the buyer's delivery is locked (no address change, no switch to pickup). The shipper gets the pickup code (shown once + SMS) and the exact places and phones.
- Heading to the shop — the shipper taps it when it sets out; the seller is told (SMS too).
- At the shop — the rider scans the QR or types the parcel number ("✓ this parcel is yours" / "✗ wrong
parcel"); the seller types the rider's pickup code (
/hand-over) →ON_THE_WAY; the order becomesSHIPPEDand the delivery code goes by SMS to the receiver. - At the door — the rider types the receiver's code (
/jobs/{id}/deliver) →DELIVERED. The buyer is asked to confirm; the seller is told. - Confirm →
COMPLETED, seller and shipper paid. Wallet history: "Sale Earnings" / "Delivery Earnings".
After accepting, a job is binding: the shipper must finish it. Failing it is a heavy reliability hit.
1.8 Station routes — collect at a station, or several shippers
A station business carries between its own stations (pair price table, e.g. Dar Magufuli Terminal → Mbeya Bus Stand). The parcel always starts with a collection at the shop:
| Route kind | How leg 1 starts |
|---|---|
STATION_COLLECTS |
the station business fetches it from the shop itself (it ticks where it collects + a flat fee, 0 = free) |
DOOR_TO_STATION |
a door shipper takes it shop → origin station, then the station business carries it on |
The route can end at a station (route.collectAt set — the buyer collects there, within the station's reach,
default 30 km) or at the door (collectAt null — a last door leg from the destination station). Up to 3 legs.
A station within 1 km of the buyer's pin (atPin) is never hidden.
Leg 1 🚪 Kibuti Express shop (Ubungo) → Magufuli Terminal 3,500
Leg 2 🚌 Shabiby Magufuli Terminal → Mbeya Bus Stand 21,000 → buyer collects (AT_STATION), or
Leg 3 🚪 Mbeya Riders Mbeya Bus Stand → buyer's door 2,500
- Offers go to every leg at once; the parcel moves only when all legs have accepted (
SHIPPER_ACCEPTED), then only the leg collecting at the shop gets the pickup code. - Handovers between legs follow one rule: whoever receives holds the code, whoever hands over types it. A station counter gets its code on the station's phone, a rider on its owner's phone.
- The bus company taps Arrived at its destination station →
AT_STATION; that sends the next code (the buyer's collection code, or the last rider's handover code).collectBy= +7 days. - Station staff type the buyer's code with the last leg's
deliver. - In the quotation the buyer can swap a door leg's shipper (
alternatives→swap); another bus company or another meeting point is a separate option. - Not yet: switching to a station route or changing a station route's destination after payment
(
STATION_ROUTE_NOT_HERE).
1.9 Bolt
NexGate resells Bolt rides (boda): single leg, one city, door to door, non-bulky parcels only, and only in the areas NexGate turned on for Bolt. To the buyer it is just another option card named "Bolt".
- Price: Bolt's highest estimate + 2%, rounded up. NexGate absorbs differences within that.
- Ready books the ride. No rider / price went up / daily spend limit reached → handled like a shipper saying no.
- Rider assigned →
HEADING_TO_SHOP; the order gets aridercard (name, photo, phone, plate, model, tracking link, minutes to the shop). - No codes with Bolt: the seller checks the plate and taps
/hand-over-rider(USE_PLATE_CHECKif the code hand-over is used;PLATE_MISMATCHif wrong). Bolt starting the ride also counts as the hand-over. Bolt "completed" →DELIVERED; the buyer confirms with the button. - The shop did not hand over (Bolt "client did not show") → back to
ACCEPTED(Ready again = a new ride); Bolt's call-out charge is taken from the seller's items money at completion. - Followed by a webhook and a poller every 15 s. Admins have a kill-switch, a daily spend limit, a status view and the monthly invoice settlement (Part 3).
1.10 Codes and the parcel number
Parcel number NX-4K7P-2Q |
4-digit codes | |
|---|---|---|
| Purpose | identifies the parcel (on the box, QR) | proves a handover |
| Secret | no | yes — hashed, never logged |
| How many | one per parcel, for life (kept when switching pickup ↔ delivery) | one per handover |
| Who sees it | buyer, seller, every shipper on the route, station staff | only the holder |
Rule: whoever RECEIVES the parcel holds the code; whoever HANDS IT OVER types it in.
| Handover | Holder (gets the SMS) | Types it | Endpoint |
|---|---|---|---|
| Pickup at the shop | buyer (receiver) | seller | POST /shipping/orders/{id}/collect |
| Shop → shipper | shipper (owner's phone) | seller | POST /shipping/orders/{id}/hand-over |
| Shipper → shipper / station | the receiving rider / station counter | the giver | POST /shipping/jobs/{id}/hand-over-next |
| Door delivery | receiver | rider | POST /shipping/jobs/{id}/deliver |
| Collect at a station | buyer | station staff (last leg) | POST /shipping/jobs/{id}/deliver |
- Codes live 24 h (
CODE_EXPIRED); 5 wrong tries lock it (CODE_LOCKED; each wrong try saystriesLeft). - Lost it? Only the holder has a button "I lost my code": a fresh code shown once on screen + SMS; the old one
dies. Max 5 per handover per 24 h (
CODE_LIMIT). Holder endpoints: buyerPOST /shipping/orders/{id}/code, shipper at the shopPOST /shipping/jobs/{id}/code, receiving rider / stationPOST /shipping/jobs/{id}/handover-code. - The bell list only says "code sent" — the code itself is only in the SMS (and the one-time screen).
- The shipper must check the parcel number before the seller can hand over (
PARCEL_NOT_CHECKED).
1.11 When a shipper says no — reassignment and the buyer's decision
Before accepting, a shipper may reject (with a reason) or let the offer run out. Either way that shipper is left out of this order and the system looks for the next one, the way the buyer chose (⭐ / 💰 / ⚡):
| Found | What happens |
|---|---|
| same price or cheaper, and not later | takes over by itself; the difference is refunded; buyer notified (SHIPPER_CHANGED) |
| dearer, slower, nobody, or already 3 changes on this order | the buyer decides (fulfillment.decision) |
fulfillment.decision = { reason, legId, askedAt, answerBy, proposal, difference, choices, ifNoAnswer, routeProposal }:
reason |
Means |
|---|---|
NO_SHIPPER |
nobody can take it |
COSTS_MORE / SLOWER |
the replacement is dearer / later |
TOO_MANY_CHANGES |
3 replacements already on this order |
WAITING_TOO_LONG |
still no shipper 24 h after Ready |
PARCEL_TOO_BIG |
a shipper found it bulky at the shop (§1.13) |
choices |
Does |
|---|---|
USE_NEW_SHIPPER |
pay the difference from the wallet (WALLET_INSUFFICIENT if short; negative = money back) |
KEEP_WAITING |
only for WAITING_TOO_LONG; not asked again for 24 h |
PICKUP |
delivery refunded, the order becomes a pickup order (READY, code by SMS) |
END_AT_STATION |
station route whose last door leg failed: collect at that station, the leg refunded |
CANCEL |
everything refunded |
No answer in 24 h → ifNoAnswer (pickup). Station routes: a failing bus leg is first replaced by another station
business within 1 km at both ends (neighbours unchanged), else in the same district (neighbours re-routed and
re-costed; an already-accepted neighbour whose point or time moved gets a re-confirm offer with
reconfirmNote). routeProposal lists every leg that would change. Any accepted leg whose arrival moves more than
4 h later must re-confirm.
1.12 What the buyer can change after paying
| Action | Allowed while | Money |
|---|---|---|
| Cancel the whole order | PLACED (before the seller accepts), or the delivery is stuck (no shipper / a decision waiting) |
everything refunded |
| Switch to pickup | delivery, until a shipper accepts (PLACED / ACCEPTED / READY) |
delivery refunded in full |
| Switch to delivery | pickup, until collected (PLACED / ACCEPTED / READY) |
delivery paid from the wallet |
| Change the delivery address | until a shipper accepts; max 3 changes per order | difference paid / refunded |
Both switches and the address change are two calls: options (POST /shipping/orders/{id}/delivery-options with
a drop-off → a normal quotation) then apply (switch-to-delivery / change-destination with the
quotationId + optionId). Once a shipper accepts → CHANGE_NOT_ALLOWED / SWITCH_NOT_ALLOWED — like AliExpress
once an order ships. Switching to delivery on a parcel already Ready with both ticks sends the offer at once;
otherwise the seller taps Ready again.
The seller can cancel (pop-up "I have already informed the buyer", informedBuyer: true): a pickup order until
collected; a delivery order only before Ready. After Ready → support (admin cancel).
Installment orders are cancelled only through their installment agreement (INSTALLMENT_ORDER).
1.13 Refusing a parcel at the shop
After accepting and before taking the parcel, a shipper may refuse it (POST /shipping/jobs/{id}/refuse-parcel):
| Reason | Then |
|---|---|
TOO_BIG |
the parcel is treated as bulky; a shipper that carries bulky is found. Same or cheaper → automatic; dearer → buyer decides (PARCEL_TOO_BIG); nobody → pickup or cancel. Two different shippers reporting the same single-product parcel → the product is marked bulky |
BADLY_PACKED |
back to ACCEPTED; the seller repacks and taps Ready again → the same shipper gets a fresh offer |
Nobody pays for the trip (like a driver turning down a ride) and it never counts against the shipper. The order
shows fulfillment.refusal. Admins can mark a "too big" claim false (a strike on the shipper).
1.14 Finishing — confirm or report a problem
When the parcel is COLLECTED (pickup) or DELIVERED (delivery), the buyer gets two buttons:
- "✓ It's what I ordered" (
/confirm) →COMPLETED; every escrow part (seller, every shipper, instalments held) released together. - "⚠ Report a problem" (
/report-problemwith a note) → every part frozen; confirm refused (AGREEMENT_FROZEN) until an admin releases or refunds.
1.15 Shippers — register, set up, go live
Register (KYC, auto-approved) ──► KYC_APPROVED ──service type + coverage + prices (+ stations)──► LIVE ⇄ PAUSED (owner)
└─► SUSPENDED (admin / automatic)
- KYC —
POST /shipping/businesses: name, contact phone, office pin, and three private documents (file + number, + expiry where it has one): business licence (number unique), director's NIDA (20 digits), LATRA licence. Upload files first to File Thunder as business documents. No manual approval. - Service setup (any order, any time;
GET …/setup= checklist withcanGoLiveand what is missing):PROFILElogo, description, WhatsApp, email (optional)SERVICE_TYPEdoor and / or station (required)HOURSworking hours (default Mon–Sat 08:00–18:00; several ranges a day, special days, same-day cut-off)COVERAGEticked areas + trip rule (required)PRICINGdoor prices: distance bands or flat, optional value add-on ladder (required for door)LIMITSmax parcel value, bulky yes / no + extra, response window (5–120 min), heads-up on/off, station reachSTATIONS+STATION_PRICES(station businesses: a station in every ticked area, at least one priced pair) and optional collection from shops (areas + fee + extra minutes)
- Live automatically once the required items are done. The owner can pause (with a reason) and resume.
Documents close to expiry send
SHIPPER_DOCUMENT_EXPIRING(14 and 3 days before).
A user may own several businesses. Only the owner manages one; admins can read everything.
1.16 Reliability, reviews, private ratings, favourites
Reliability (automatic, internal):
| Signal | ⚠ Warning | ⏸ Paused (owner resumes) | ⛔ Suspended (admin lifts) |
|---|---|---|---|
| Offers missed in a row | 2 | 3 | — |
| Automatic pauses | — | — | 3rd within 7 days |
| Acceptance rate (last 20 offers) | < 50% | < 30% | — |
| Broke an accepted job (admin finding) | — | 1st | 2nd within 30 days |
| Found at fault in a problem (admin finding) | 1st | — | 2 within 30 days |
| False "too big" claims | — | — | 3 |
| On time (last 20 jobs) | < 60% | — | — |
Rates count only after the first 10 offers; misses while closed or paused, and withdrawn offers, don't count;
rejecting with a reason only counts through the rate. Counting restarts after an automatic pause is resumed or a
suspension lifted. Every step sends SHIPPER_STANDING with the reason ("Paused: 3 offers missed in a row — 10:02,
10:41, 11:15"). Accepted jobs must still be finished.
Reviews — like shop reviews: rating 1–5 and / or text (10–1000), one per person per shipper, anyone except the
owner, owner reply, admin moderation. verifiedDelivery when the reviewer got a delivery from that shipper.
Private ratings — the shipper rates the seller (after hand-over) and the buyer (after delivery): 1–5 + an optional issue. Only admins read them.
Favourites — a buyer keeps up to 10 favourite shippers: ❤ badge, pre-selected when they can carry, never hidden.
1.17 Group purchase and installments
- Group purchase: each member chooses their own pickup / delivery and pays their delivery with their payment; the choice is frozen while the group fills; leaving or a failed group refunds items and delivery.
- Installment, ships now: the whole delivery is paid with the down payment.
- Layaway (ships after the last instalment): delivery refused at checkout (
LAYAWAY_DELIVERY_LATER); the order is created as pickup when paid off and the buyer may choose delivery then (switch to delivery).
1.18 Timers (run by the backend)
| Timer | Rule |
|---|---|
| Seller accept | 24 opening hours, reminder 2 h before; then cancelled + refunded |
| Offer | the shipper's response window (default 30 min) while it is open |
| Chain safety net | no shipper 24 h after Ready → buyer asked (WAITING_TOO_LONG) |
| Buyer decision | 24 h, then pickup |
| Pickup collection | 14 days from Ready; reminders day 7, 12, last day (SMS); then EXPIRED + refund |
| Station collection | 7 days (collectBy) |
| Codes | 24 h |
| Quotation prices | held 5 days |
| Bolt | poller every 15 s; a ride searching over 10 min is cancelled and reassigned |
| Admin digest | 07:30 every morning when something is stuck |
1.19 Notifications
Each one opens the screen named by its targetType / targetId.
| Type | To | When |
|---|---|---|
ORDER_ACCEPTED |
buyer | seller accepted (parcel number) |
ORDER_ACCEPT_REMINDER |
seller | 2 h before the accept deadline |
PICKUP_READY · PICKUP_REMINDER · PICKUP_LAST_DAY (+SMS) |
buyer | pickup ready / not collected yet / last day |
SHIPPER_FOUND · SHIPPER_CHANGED |
buyer | every leg accepted (or Bolt rider) / automatic swap |
DELIVERY_DECISION_NEEDED |
buyer | fulfillment.decision set |
PARCEL_AT_STATION |
buyer | the parcel reached the buyer's station |
HANDOVER_CODE (SMS) |
the holder | a code for a handover |
ORDER_AWAITING_CONFIRM |
buyer | delivered / collected: tap "Received" |
RIDER_COMING (+SMS) |
seller | shipper heading to the shop / Bolt rider (plate) |
PARCEL_REFUSED |
seller | badly packed / Bolt left without the parcel |
ORDER_DELIVERED |
seller | delivered; paid when the buyer confirms |
JOB_CANCELLED |
shipper | an offer or job taken away |
DELIVERY_EARNINGS |
shipper | its money released |
SHIPPER_STANDING · SHIPPER_DOCUMENT_EXPIRING |
shipper | warning / pause / suspension · document expiring |
SHIPPER_NEW_REVIEW · SHIPPER_REVIEW_REPLY |
shipper · reviewer | review / reply |
SHIPPING_ADMIN_DIGEST |
staff + super admins | something stuck, 07:30 |
Campaign notifications: docs/shipping/campaigns/campaigns_api_doc.md.
1.20 Admin
| Role | Can |
|---|---|
STAFF_ADMIN |
view everything (shippers, overview, operations board, reports, campaigns, Bolt status, audit log), suspend / lift, force a reassignment, mark a false bulky claim, moderate reviews, notes |
SUPER_ADMIN |
all of that + anything with money or settings: escrow release / refund, order cancel, commission changes, Bolt on/off, coverage, invoices, ride state (test modes) |
Every admin action needs a reason and is written to the audit log (GET /admin/audit) with the state before
and after. shipperAtFault: true on release / refund / cancel / reassign records a strike against the shipper.
Part 2 — Endpoints for buyers and sellers
A. Estimation and quotations
A1. Card estimates
Purpose: The delivery hint on product cards and lists: cost range, time range, or "Pickup only". One call per screen.
Endpoint: POST {base_url}/shipping/estimations
Access Level: 🔒 Protected (any logged-in user) · Bearer token
Request JSON Sample:
{
"dropoff": { "addressId": "9b1d0c2e-…" },
"productIds": ["5f0e…", "77a1…"]
}
| Parameter | Type | Required | Description |
|---|---|---|---|
| dropoff | object | Yes | the header "Deliver to" — a drop-off (location doc §1.6) |
| productIds | array | Yes | products on screen (TOO_MANY_PRODUCTS above the limit) |
Success Response JSON Sample:
{
"success": true, "httpStatus": "OK", "message": "Delivery estimates", "action_time": "2026-10-24T10:30:45",
"data": {
"destinationArea": "Sinza, Ubungo, Dar es Salaam",
"items": [
{ "productId": "5f0e…", "shopId": "aaaa…", "status": "DELIVERY",
"cost": { "from": 3500, "to": 7000 }, "eta": { "fromMinutes": 30, "toMinutes": 120 },
"freeDelivery": false, "pickupArea": null,
"campaign": { "listFrom": 7000, "paidBy": ["SELLER", "NEXGATE"] } },
{ "productId": "77a1…", "shopId": "bbbb…", "status": "PICKUP_ONLY", "cost": null, "eta": null,
"freeDelivery": false, "pickupArea": "Kariakoo, Ilala, Dar es Salaam" }
]
}
}
| Field | Description |
|---|---|
status |
DELIVERY (cost + eta) · PICKUP_ONLY (no shipper; show pickupArea) · DIGITAL · UNAVAILABLE |
cost |
what the buyer pays (after campaigns), cheapest → dearest of the sensible options |
freeDelivery |
the cheapest shown option is free |
campaign |
badge "Delivery from |
Nothing is saved; estimates ignore opening hours (durations only).
A2. Get delivery options (quotation)
Purpose: The real options for one shop's parcel: the add-to-cart / buy-now sheet, the cart's "change shipper", the product page preview.
Endpoint: POST {base_url}/shipping/quotations
Access Level: 🔒 Protected (buyer) · Bearer token
Request JSON Sample (sheet):
{
"items": [ { "productId": "5f0e…", "quantity": 2 } ],
"includeCart": true,
"dropoff": { "lat": -6.7735, "lng": 39.2187 },
"preview": false
}
| Parameter | Type | Required | Description |
|---|---|---|---|
| items | array | one of items / shopId | products of one shop (PRODUCTS_FROM_DIFFERENT_SHOPS), quantity 1–1000, max 50 lines |
| shopId | UUID | without items | the cart page: quote what is in the cart from this shop |
| includeCart | boolean | No | add what the buyer already has in the cart from this shop (needed so the quotation fits the cart) |
| dropoff | object | Yes | where to (location doc §1.6) |
| preview | boolean | No | product page: compute only, nothing saved, no quotationId |
Success Response JSON Sample:
{
"success": true, "httpStatus": "OK", "message": "Delivery options", "action_time": "2026-10-24T10:30:45",
"data": {
"quotationId": "3c1f…", "shopId": "aaaa…", "shopName": "Shoe Shop",
"pickup": { "areaLabel": "Kariakoo, Ilala, Dar es Salaam", "wardId": 1146, "lat": -6.819, "lng": 39.2768, "source": "SHOP_LOCATION" },
"dropoff": { "areaLabel": "Sinza, Ubungo, Dar es Salaam", "wardId": 1212, "lat": -6.7735, "lng": 39.2187, "source": "NEW_PIN" },
"parcel": { "value": 120000, "bulky": false, "itemCount": 2,
"items": [ { "productId": "5f0e…", "productName": "Sneakers", "quantity": 2, "unitPrice": 60000, "bulky": false } ] },
"roadKm": 6.2,
"deliveryAvailable": true, "unavailableReason": null, "unavailableMessage": null,
"options": [
{ "optionId": "o-1", "businessId": "c4bc…", "name": "Mbezi Boda", "logoUrl": "https://…", "isNew": false, "favourite": false,
"labels": ["RECOMMENDED", "CHEAPEST", "FASTEST"],
"cost": { "base": 3500, "valueAddon": 0, "bulkyExtra": 0, "total": 3500 },
"eta": { "minMinutes": 30, "maxMinutes": 60, "startsAt": "2026-10-24T10:31:00",
"arrivalFrom": "2026-10-24T11:01:00", "arrivalTo": "2026-10-24T11:31:00", "shiftReason": null },
"hiddenReason": null, "selected": true, "rating": { "average": 4.6, "count": 120 },
"route": null, "offer": null }
],
"more": [ { "optionId": "o-2", "name": "Dar Cargo", "hiddenReason": "DOMINATED", "…": "…" } ],
"selectedOptionId": "o-1", "selectedBusinessId": "c4bc…", "selectionReason": "RECOMMENDED",
"pricesHeldUntil": "2026-10-29T10:30:45", "expired": false,
"cartBefore": { "businessId": "c4bc…", "businessName": "Mbezi Boda", "total": 3000 }, "difference": 500,
"createdAt": "2026-10-24T10:30:45"
}
}
Success Response Fields:
| Field | Description |
|---|---|
quotationId |
null for a preview or when no delivery is possible |
deliveryAvailable / unavailableMessage |
false → show the message and offer pickup |
options |
up to 5 shown; more = under "See all" with hiddenReason (DOMINATED, OUTLIER, BEYOND_TOP) |
labels |
RECOMMENDED ⭐ · CHEAPEST 💰 · FASTEST ⚡ · USED_BEFORE |
cost.total |
the shipper's table price; with a campaign show offer.buyerPays (campaigns doc) |
eta |
startsAt = first moment both shop and shipper are open; shiftReason says why it is later |
rating |
null = no ratings yet |
route |
station route (legs, collectAt, atPin) — §1.8; null = door delivery by one shipper |
selected / selectionReason |
the pre-selected option: BUYER_CHOICE, FAVOURITE, RECOMMENDED |
pricesHeldUntil |
prices held 5 days for this exact parcel and destination |
cartBefore / difference |
this shop's delivery in the cart now → after, for "3,000 → 3,500 (+500)" |
Errors: SHOP_REQUIRED, PRODUCTS_FROM_DIFFERENT_SHOPS, PRODUCT_NOT_IN_SHOP, PRODUCT_NOT_AVAILABLE,
DIGITAL_NO_DELIVERY, ITEMS_REQUIRED, SHOP_LOCATION_REQUIRED, DROPOFF_INVALID.
A3. Read a quotation
Endpoint: GET {base_url}/shipping/quotations/{quotationId}
Access Level: 🔒 Protected (the buyer it belongs to)
Same response as A2. expired: true once the 5 days are over — ask again for today's prices.
A4. Pick an option
Purpose: The buyer taps another option in the sheet / cart list.
Endpoint: PUT {base_url}/shipping/quotations/{quotationId}/selection
Access Level: 🔒 Protected (the buyer)
Request: { "optionId": "o-2" } → the quotation with that option selected.
Errors: OPTION_NOT_FOUND, QUOTATION_EXPIRED, QUOTATION_REPLACED.
A5. Other shippers for one leg of a route
Purpose: On a station route, list who else can do a door leg (e.g. leg 1 shop → terminal).
Endpoint: GET {base_url}/shipping/quotations/{quotationId}/options/{optionId}/legs/{position}/alternatives
Access Level: 🔒 Protected (the buyer)
Path Parameters: position = leg number, starting at 1.
Success Response data (message "Other shippers for this leg"):
[ { "businessId": "d1…", "name": "Kibuti Express", "logoUrl": "…", "total": 3500, "minMinutes": 60, "maxMinutes": 120,
"current": true, "rating": { "average": 4.2, "count": 15 } } ]
Errors: LEG_NOT_FOUND, NOT_A_DOOR_LEG (the bus leg: pick another option instead).
A6. Swap one leg's shipper
Endpoint: POST {base_url}/shipping/quotations/{quotationId}/options/{optionId}/swap
Access Level: 🔒 Protected (the buyer)
Request: { "position": 1, "businessId": "d2…" } → the quotation with the edited route added as a new option
and selected (the original start is kept; the arrival is the sum of the legs).
Errors: LEG_NOT_FOUND, NOT_A_DOOR_LEG, SHIPPER_CANNOT_DO_LEG.
B. Cart
The cart's delivery side also shows in GET /e-commerce/cart (destination, shops[], deliveryTotal, nudges) and
in the answers of add / update / remove item — see commerce_logistics_changes.md §32, §48, §49, §68.
B1. Set the cart's delivery address
Purpose: One destination for the whole cart; every shop is re-quoted.
Endpoint: PUT {base_url}/e-commerce/cart/destination
Access Level: 🔒 Protected (buyer)
Request JSON Sample:
{ "dropoff": { "recentId": "a71e…" } }
Success Response JSON Sample (the delivery change):
{
"success": true, "httpStatus": "OK", "message": "Delivery address set", "action_time": "2026-10-24T10:30:45",
"data": {
"deliveryBefore": 10500, "deliveryAfter": 13000, "switchedToPickup": 1, "canChangeBack": true,
"message": "Delivery 10,500 → 13,000, 1 shop switched to pickup",
"changes": [
{ "shopId": "bbbb…", "shopName": "Phone Shop",
"before": { "type": "DELIVERY", "businessName": "Dar Cargo", "total": 7000 },
"after": { "type": "PICKUP", "businessName": null, "total": 0 },
"message": "Delivery is not available to Tabata yet" }
]
}
}
App: show message; offer "Change back" when canChangeBack. An unsaved new pin is held on the cart and becomes
a recent pin only after payment.
B2. Change back to the previous address
Endpoint: POST {base_url}/e-commerce/cart/destination/change-back
Access Level: 🔒 Protected (buyer)
Same response as B1. Errors: NOTHING_TO_CHANGE_BACK.
B3. Pickup or delivery for one shop
Endpoint: PUT {base_url}/e-commerce/cart/shops/{shopId}/fulfillment
Access Level: 🔒 Protected (buyer)
Request JSON Sample:
{ "type": "DELIVERY", "quotationId": "3c1f…", "optionId": "o-2" }
| Parameter | Type | Required | Description |
|---|---|---|---|
| type | enum | Yes | PICKUP or DELIVERY |
| quotationId / optionId | UUID | No | the option picked in the list; DELIVERY alone = the default shipper |
Success Response: the delivery change (as B1), message "Delivery choice saved".
Errors: SHOP_NOT_IN_CART, DESTINATION_REQUIRED, DELIVERY_NOT_AVAILABLE, OPTION_REQUIRED,
QUOTATION_OUTDATED, QUOTATION_EXPIRED.
C. Order actions — {base_url}/shipping/orders/{orderId}/…
One set of endpoints for pickup and delivery orders. Each answers with the order's fulfillment
(FulfillmentResponse, also inside GET /orders/{id} as fulfillment):
{
"type": "DELIVERY",
"status": "SHIPPER_ACCEPTED",
"parcelNumber": "NX-4K7P-2Q",
"pickup": { "source": "SHOP_LOCATION", "label": "Shoe Shop", "areaLabel": "Kariakoo, Ilala, Dar es Salaam", "phone": "+2557…", "…": "…" },
"dropoff": { "source": "SAVED_ADDRESS", "label": "Home", "areaLabel": "Sinza, Ubungo, Dar es Salaam", "directions": "Blue gate", "…": "…" },
"receiver": { "name": "Mama Json", "phone": "+255712345678" },
"note": "Call when you arrive",
"delivery": { "quotationId": "…", "optionId": "…", "businessId": "c4bc…", "businessName": "Mbezi Boda", "roadKm": 6.2,
"base": 3500, "valueAddon": 0, "bulkyExtra": 0, "total": 3500, "minMinutes": 30, "maxMinutes": 60,
"arrivalFrom": "…", "arrivalTo": "…", "pricesHeldUntil": "…", "route": null, "offer": null },
"placedAt": "…", "acceptedAt": "…", "readyAt": "…", "packedConfirmedAt": "…", "labelConfirmedAt": "…",
"acceptBy": "…", "collectBy": null,
"code": { "status": "ACTIVE", "expiresAt": "…" },
"shippers": [ { "leg": 1, "businessId": "c4bc…", "name": "Mbezi Boda", "phone": "+255745051250" } ]
}
| Field | Shown when |
|---|---|
status |
always — Part 4 |
parcelNumber |
from the seller's accept |
delivery |
delivery orders: the shipper, price breakdown, window, route, campaign offer |
pickupTime |
pickup: the buyer's planned time |
collectBy / acceptBy |
collection deadline / seller's accept deadline |
needsShipperSince |
a shipper said no; "We're finding a new shipper" |
decision |
the buyer must choose (§1.11) |
refusal |
a shipper refused the parcel at the shop (§1.13) |
code |
the code the viewer holds: ACTIVE / USED / LOCKED / REPLACED / EXPIRED (never the digits) |
rider |
Bolt: driver card (§1.9) |
shippers |
name + phone per leg once its shipper accepted (not for Bolt) |
headingAt, pickedUpAt, deliveredAt, collectedAt, completedAt, closedAt + closeReason, problemReportedAt |
each step's time |
C1. Accept the order (seller)
Endpoint: POST {base_url}/shipping/orders/{orderId}/accept
Access Level: 🔒 Protected (the shop's seller)
No body. → fulfillment ACCEPTED with parcelNumber (message "Order accepted"). Idempotent. Errors: NOT_YOUR_ORDER,
FULFILLMENT_STATE.
C2. Packed & ready (seller)
Endpoint: POST {base_url}/shipping/orders/{orderId}/ready
Access Level: 🔒 Protected (the shop's seller)
Request JSON Sample:
{ "packedWell": true, "labelOnPackage": true }
| Parameter | Type | Required | Description |
|---|---|---|---|
| packedWell | boolean | delivery: Yes | "I have packed it well" |
| labelOnPackage | boolean | delivery: Yes | "the parcel number / label is on the package" |
Pickup: the body may be left out. Delivery: sends the job offer(s) (or books Bolt). → fulfillment READY.
Errors: READY_CONFIRM_REQUIRED, NOT_YOUR_ORDER, FULFILLMENT_STATE.
C3. Planned pickup time (buyer, pickup)
Endpoint: PUT {base_url}/shipping/orders/{orderId}/pickup-time
Access Level: 🔒 Protected (the buyer)
Request: { "pickupTime": "2026-10-25T15:00:00+03:00" } (null clears it) → fulfillment with pickupTime.
Errors: PICKUP_TIME_INVALID (shop closed then, or outside the collection window), NOT_PICKUP.
C4. "I lost my code" (buyer / receiver)
Purpose: A fresh code for the code the buyer holds: the pickup code (pickup order) or the delivery / station collection code (delivery order).
Endpoint: POST {base_url}/shipping/orders/{orderId}/code
Access Level: 🔒 Protected (the buyer)
Success Response JSON Sample:
{
"success": true, "httpStatus": "OK", "message": "Show this code at the counter. It was also sent by SMS",
"action_time": "2026-10-24T10:30:45",
"data": { "code": "4821", "expiresAt": "2026-10-25T10:30:45", "sentTo": "+255712****78", "maxWrongTries": 5 }
}
Delivery orders answer "Give this code to the rider at the door. It was also sent by SMS". Show the code once; it is never returned again.
Errors: CODE_LIMIT, CODE_NOT_YET (station route: the code comes when the parcel arrives), FULFILLMENT_STATE.
C5. Hand over to the shipper (seller)
Endpoint: POST {base_url}/shipping/orders/{orderId}/hand-over
Access Level: 🔒 Protected (the shop's seller)
Request: { "code": "1234" } — the 4-digit code the rider shows.
→ fulfillment ON_THE_WAY (message "Handed to the rider"); the order is SHIPPED; the receiver gets the delivery code.
Errors: PARCEL_NOT_CHECKED (rider must scan / type the parcel number first), CODE_WRONG (details.triesLeft),
CODE_LOCKED, CODE_EXPIRED, CODE_NOT_ACTIVE, USE_PLATE_CHECK (Bolt), USE_PICKUP_FLOW.
C6. Hand over to the Bolt rider (seller)
Endpoint: POST {base_url}/shipping/orders/{orderId}/hand-over-rider
Access Level: 🔒 Protected (the shop's seller)
Request: { "plate": "MC 123 ABC" } (spaces and case ignored) → fulfillment ON_THE_WAY.
Errors: PLATE_MISMATCH (the message names the expected plate), BOLT_NOT_ASSIGNED, NO_BOLT_RIDE.
C7. Buyer collects at the counter (seller, pickup)
Endpoint: POST {base_url}/shipping/orders/{orderId}/collect
Access Level: 🔒 Protected (the shop's seller)
Request: { "code": "4821" } — the buyer's code. → fulfillment COLLECTED; the buyer is asked to confirm.
Errors: CODE_WRONG, CODE_LOCKED, CODE_EXPIRED, CODE_NOT_ACTIVE, NOT_PICKUP.
C8. "✓ It's what I ordered" (buyer)
Endpoint: POST {base_url}/shipping/orders/{orderId}/confirm
Access Level: 🔒 Protected (the buyer)
No body; only at COLLECTED / DELIVERED. → COMPLETED; every escrow part released (message "Order completed").
Idempotent.
Errors: AGREEMENT_FROZEN (a problem is open), FULFILLMENT_STATE.
C9. Report a problem (buyer)
Endpoint: POST {base_url}/shipping/orders/{orderId}/report-problem
Access Level: 🔒 Protected (the buyer)
Request: { "note": "The box was crushed and one shoe is missing" } (required, max 500).
→ fulfillment with problemReportedAt; every part frozen (message "Problem reported; payment is on hold"). An admin
settles it with escrow release / refund.
C10. Delivery options for this order (buyer)
Purpose: Step 1 of switching a pickup order to delivery, or changing a delivery order's address.
Endpoint: POST {base_url}/shipping/orders/{orderId}/delivery-options
Access Level: 🔒 Protected (the buyer)
Request: a drop-off — { "addressId": "…" } / { "recentId": "…" } / { "lat": …, "lng": …, "directions": "…" }.
Success Response: a quotation (as A2) for this order's items. Campaigns are priced against the items money held.
Errors: CHANGE_NOT_ALLOWED (a shipper accepted), DROPOFF_INVALID.
C11. Switch to delivery (buyer, pickup order)
Endpoint: POST {base_url}/shipping/orders/{orderId}/switch-to-delivery
Access Level: 🔒 Protected (the buyer)
Request JSON Sample:
{
"quotationId": "3c1f…", "optionId": "o-1",
"dropoff": { "addressId": "9b1d…" },
"receiver": { "name": "Mama Json", "phone": "0712345678" },
"note": "Call when you arrive"
}
| Parameter | Type | Required | Description |
|---|---|---|---|
| quotationId / optionId | UUID | Yes | from C10 |
| dropoff | object | Yes | the same drop-off as in C10 |
| receiver | object | No | omit = the buyer |
| note | string | No | max 300 |
The delivery is paid from the wallet; the pickup code stops working. Ready with both ticks → offer sent now; else back
to ACCEPTED. → fulfillment type: DELIVERY.
Errors: ALREADY_DELIVERY, SWITCH_NOT_ALLOWED, WALLET_INSUFFICIENT, DELIVERY_PRICE_CHANGED,
DELIVERY_OPTION_GONE, CAMPAIGN_ENDED, QUOTATION_OUTDATED, STATION_ROUTE_NOT_HERE.
C12. Change the delivery address (buyer, delivery order)
Endpoint: POST {base_url}/shipping/orders/{orderId}/change-destination
Access Level: 🔒 Protected (the buyer)
Same body as C11. Only before a shipper accepts; max 3 changes. The difference is paid from / refunded to the wallet; an open offer is withdrawn and sent again for the new address (another shipper may be picked). Campaign shares never grow after payment. → fulfillment (message "Delivery address changed").
Errors: CHANGE_NOT_ALLOWED, DESTINATION_CHANGE_LIMIT, NOT_DELIVERY, BOLT_RIDE_BOOKED, DELIVERY_NOT_PAID,
WALLET_INSUFFICIENT, STATION_ROUTE_NOT_HERE, plus the C11 price codes.
C13. Switch to pickup (buyer, delivery order)
Endpoint: POST {base_url}/shipping/orders/{orderId}/switch-to-pickup
Access Level: 🔒 Protected (the buyer)
No body. Only until a shipper accepts; the delivery is refunded in full (every campaign share goes back). If packed, the
order is READY with a collectBy and the buyer gets the pickup code.
Errors: ALREADY_PICKUP, SWITCH_NOT_ALLOWED.
C14. Answer a delivery decision (buyer)
Endpoint: POST {base_url}/shipping/orders/{orderId}/delivery-decision
Access Level: 🔒 Protected (the buyer)
Request: { "choice": "USE_NEW_SHIPPER" } — one of fulfillment.decision.choices (§1.11).
→ fulfillment (message "Decision saved").
Errors: NO_DECISION_NEEDED, DECISION_CHOICE_INVALID, WALLET_INSUFFICIENT, CANNOT_END_HERE.
C15. Cancel (buyer or seller)
Endpoint: POST {base_url}/shipping/orders/{orderId}/cancel
Access Level: 🔒 Protected (the buyer or the shop's seller)
Request JSON Sample (seller):
{ "informedBuyer": true, "reason": "Out of stock in this size" }
| Parameter | Type | Required | Description |
|---|---|---|---|
| informedBuyer | boolean | seller: Yes | the pop-up "I have already informed the buyer" |
| reason | string | No | max 500 |
The buyer may leave the body out. Rules in §1.12; everything is refunded and stock comes back.
Errors: CANCEL_NOT_ALLOWED (message says what to do instead), INFORM_BUYER_FIRST, INSTALLMENT_ORDER.
D. Favourite shippers (buyer)
D1. My favourites
Endpoint: GET {base_url}/shipping/favourites
Access Level: 🔒 Protected
Success Response data: [{ "businessId": "c4bc…", "name": "Mbezi Boda", "logo": { "<size>": "https://…" }, "status": "LIVE", "areaLabel": "Mbezi, Ubungo, Dar es Salaam", "addedAt": "…" }]
D2. Add / remove a favourite
Endpoints:
- PUT
{base_url}/shipping/favourites/{businessId}— add (idempotent) - DELETE
{base_url}/shipping/favourites/{businessId}— remove
Both answer with the updated list. Errors: FAVOURITES_LIMIT (10).
E. Shipper profile and reviews (anyone logged in)
E1. Public shipper profile
Endpoint: GET {base_url}/shipping/businesses/{businessId}/public
Access Level: 🔒 Protected (any logged-in user)
Success Response JSON Sample:
{
"success": true, "httpStatus": "OK", "message": "Shipper profile", "action_time": "2026-10-24T10:30:45",
"data": {
"businessId": "c4bc…", "name": "Mbezi Boda", "logo": { "<size>": "https://…" }, "description": "Fast boda in Ubungo",
"verified": true, "isNew": false, "onNexGateSince": "2026-10-12T09:00:00",
"availability": "OPEN",
"openNow": { "hasHours": true, "openNow": true, "closesAt": "2026-10-24T18:00:00+03:00", "nextOpensAt": null },
"office": { "areaLabel": "Mbezi, Ubungo, Dar es Salaam", "landmark": "Near Mbezi Mwisho stand" },
"services": { "door": true, "station": false, "acceptsBulky": false, "bulkyExtra": null, "maxParcelValue": 2000000, "valueProtection": true },
"coverage": { "businessId": "c4bc…", "trips": "WITHIN_AND_ACROSS", "areas": [ { "level": "REGION", "regionCode": "07", "name": "Dar es Salaam", "label": "Dar es Salaam" } ], "wardCount": 102 },
"hours": { "weekly": [ { "day": "MONDAY", "open": "08:00", "close": "18:00" } ], "specialDays": [], "sameDayCutoff": "16:00", "timeZone": "Africa/Dar_es_Salaam" },
"prices": { "mode": "BANDS", "bands": [ { "fromKm": 0, "toKm": 3, "price": 2000, "minMinutes": 30, "maxMinutes": 60 } ], "maxDistanceKm": 15, "distanceFactor": 1.3 },
"rating": { "average": 4.6, "totalRatings": 120, "distribution": { "1": 2, "2": 3, "3": 8, "4": 30, "5": 77 }, "activeReviews": 64 },
"stats": { "parcelsDelivered": 340, "parcelsLast30Days": 41, "onTimeRate": 92.0, "savedByBuyers": 18, "areasServed": 1, "enoughHistory": true },
"viewer": { "isFavourite": false, "isOwner": false }
}
}
Never shows phone, WhatsApp, email, documents, owner, commission or reliability details. availability: OPEN,
CLOSED, TAKING_A_BREAK (paused). Until enoughHistory show "New shipper".
E2. Reviews
| Endpoint | Does |
|---|---|
POST {base_url}/shipping/businesses/{businessId}/reviews |
write a review |
PUT {base_url}/shipping/businesses/{businessId}/reviews |
edit my review |
DELETE {base_url}/shipping/businesses/{businessId}/reviews |
delete my review |
GET {base_url}/shipping/businesses/{businessId}/reviews |
reviews with text |
GET {base_url}/shipping/businesses/{businessId}/reviews/paged?page=1&size=10 |
paged |
GET {base_url}/shipping/businesses/{businessId}/reviews/my-review |
mine |
GET {base_url}/shipping/businesses/{businessId}/reviews/summary |
average + 1..5 distribution |
POST {base_url}/shipping/businesses/{businessId}/reviews/{reviewId}/reply |
owner reply { "replyText": "…" } (max 1000) |
Request (write / edit): { "ratingValue": 5, "reviewText": "Fast and careful, thank you!" } — at least one of
the two; text 10–1000.
Review JSON Sample:
{ "reviewId": "…", "businessId": "c4bc…", "userId": "…", "userName": "Joshua", "userProfileMedia": null,
"ratingValue": 5, "reviewText": "Fast and careful, thank you!", "status": "ACTIVE",
"replyText": "Asante!", "repliedAt": "…", "createdAt": "…", "updatedAt": "…",
"isMyReview": true, "verifiedDelivery": true }
Errors: OWN_BUSINESS, REVIEW_EXISTS, REVIEW_EMPTY, NOT_REVIEWABLE (not live yet).
Part 3 — Endpoints for shippers and admins
All …/businesses/{businessId}/… endpoints below are for the business owner (403 / NOT_YOUR_BUSINESS for
anyone else) unless marked otherwise. A suspended business can read but not change (BUSINESS_SUSPENDED).
F. Register and manage the business
F1. Register (KYC)
Purpose: Part 1 of becoming a shipper. Auto-approved → KYC_APPROVED (not live yet).
Endpoint: POST {base_url}/shipping/businesses
Access Level: 🔒 Protected (any user)
Request JSON Sample:
{
"name": "Mbezi Boda",
"contactPhone": "0745051250",
"office": { "lat": -6.7350, "lng": 39.1180, "landmark": "Near Mbezi Mwisho stand", "building": "Room 4" },
"businessLicence": { "fileId": "f1…", "number": "BL-2026-00123", "expiresOn": "2027-06-30" },
"directorNida": { "fileId": "f2…", "number": "19900101123450000123" },
"latraLicence": { "fileId": "f3…", "number": "LATRA-55821", "expiresOn": "2027-01-31" }
}
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| name | string | Yes | business name | max 100 |
| contactPhone | string | Yes | the phone buyers and sellers call | valid TZ phone |
| office | object | Yes | the office pin (lat, lng, landmark ≤300, building ≤100) |
inside a ward |
| businessLicence | object | Yes | fileId (File Thunder, business document), number (unique), expiresOn |
number ≤60 |
| directorNida | object | Yes | fileId, number |
20 digits |
| latraLicence | object | Yes | fileId, number, expiresOn |
Success Response: the business (F4), message "Delivery business registered".
Errors: LICENCE_TAKEN, NIDA_INVALID, PHONE_INVALID, DOCUMENT_INVALID, DOCUMENT_EXPIRED, OUTSIDE_COVERAGE.
F2. My businesses
Endpoint: GET {base_url}/shipping/businesses/mine
Success Response data: [{ "id": "c4bc…", "name": "Mbezi Boda", "status": "KYC_APPROVED", "logo": null, "canGoLive": false, "missingToGoLive": ["COVERAGE", "PRICING"] }]
F3. Replace / open a KYC document
- PUT
{base_url}/shipping/businesses/{businessId}/documents/{type}— body{ "fileId", "number", "expiresOn" }→ the business. - GET
{base_url}/shipping/businesses/{businessId}/documents/{type}— owner or admin: a short-lived link{ "type": "LATRA_LICENCE", "mimeType": "application/pdf", "url": "https://…", "expiresInSeconds": 300 }.
type: BUSINESS_LICENCE, DIRECTOR_NIDA, LATRA_LICENCE. Errors: DOCUMENT_NOT_READY, DOCUMENT_INVALID,
DOCUMENT_EXPIRED, LICENCE_TAKEN.
F4. Get the business (owner / admin)
Endpoint: GET {base_url}/shipping/businesses/{businessId}
Success Response JSON Sample:
{
"id": "c4bc…", "ownerId": "3213…", "name": "Mbezi Boda", "contactPhone": "+255745051250",
"office": { "lat": -6.735, "lng": 39.118, "ward": { "wardName": "Mbezi", "…": "…" }, "areaLabel": "Mbezi, Ubungo, Dar es Salaam",
"landmark": "Near Mbezi Mwisho stand", "building": "Room 4" },
"status": "LIVE", "provider": null, "logo": { "<size>": "https://…" },
"description": "Fast boda in Ubungo", "whatsapp": "+255745051250", "email": "hello@mbeziboda.co.tz",
"servesDoor": true, "servesStation": false,
"limits": { "maxParcelValue": 2000000, "acceptsBulky": false, "bulkyExtra": null,
"responseWindowMinutes": 30, "headsUp": true, "stationReachKm": 30 },
"documents": [ { "type": "LATRA_LICENCE", "number": "LATRA-55821", "expiresOn": "2027-01-31", "fileStatus": "READY" } ],
"kycApprovedAt": "…", "wentLiveAt": "…", "pausedAt": null, "pauseReason": null,
"suspendedAt": null, "suspensionReason": null, "createdAt": "…"
}
provider = BOLT for an integrated provider (no owner). App: show a banner from status + pauseReason /
suspensionReason.
F5. Pause / resume
- POST
{base_url}/shipping/businesses/{businessId}/pause— body optional{ "reason": "Ramadan break" }(≤255) - POST
{base_url}/shipping/businesses/{businessId}/resume— also restarts reliability counting after an automatic pause
Both → the business. Paused = no new quotations or offers; accepted jobs must still be finished. Suspended → only an
admin lifts it (BUSINESS_SUSPENDED).
F6. My reliability
Endpoint: GET {base_url}/shipping/businesses/{businessId}/reliability
Success Response JSON Sample:
{
"businessId": "c4bc…", "status": "LIVE", "statusReason": null, "pausedBySystem": false,
"countingSince": "2026-10-12T09:00:00", "offersCounted": 24, "graceOffers": 10,
"acceptanceRate": 88, "missedInARow": 0, "jobsCounted": 21, "onTimeRate": 95,
"strikes": { "brokeBinding": 0, "atFault": 0, "falseBulkyClaims": 0 },
"events": [ { "type": "WARNED", "signal": "MISSED_OFFERS", "orderId": "…", "reason": "2 offers missed in a row", "at": "…" } ]
}
Rates are null until the grace number is reached.
G. Service setup — {base_url}/shipping/businesses/{businessId}/setup/…
G1. Checklist
Endpoint: GET …/setup
{ "businessId": "c4bc…", "status": "KYC_APPROVED", "canGoLive": false,
"items": [ { "key": "KYC", "done": true, "requiredToGoLive": true, "note": null },
{ "key": "SERVICE_TYPE", "done": true, "requiredToGoLive": true, "note": null },
{ "key": "HOURS", "done": true, "requiredToGoLive": true, "note": null },
{ "key": "COVERAGE", "done": false, "requiredToGoLive": true, "note": "Tick the areas you serve" },
{ "key": "PRICING", "done": false, "requiredToGoLive": true, "note": "Set your door prices and times" },
{ "key": "LIMITS", "done": true, "requiredToGoLive": false, "note": null },
{ "key": "PROFILE", "done": false, "requiredToGoLive": false, "note": "…" } ] }
Station businesses also get STATIONS and STATION_PRICES. The business goes live by itself when every required
item is done.
G2. Profile
PUT …/setup/profile — { "logoFileId": "…", "description": "…" (≤500), "whatsapp": "…", "email": "…" } → the business.
Errors: LOGO_INVALID, PHONE_INVALID.
G3. Service type
PUT …/setup/service-type — { "door": true, "station": false } → the business. Errors: SERVICE_TYPE_REQUIRED.
G4. Limits
PUT …/setup/limits
{ "maxParcelValue": 2000000, "acceptsBulky": true, "bulkyExtra": 3000,
"responseWindowMinutes": 30, "headsUp": true, "stationReachKm": 30 }
| Parameter | Description | Validation |
|---|---|---|
| maxParcelValue | most valuable parcel it carries (null = no limit) | ≥ 0 |
| acceptsBulky / bulkyExtra | carries bulky parcels, and the extra charged (0 = none) | extra required when bulky (BULKY_EXTRA_REQUIRED) |
| responseWindowMinutes | time to answer an offer | 5–120, default 30 |
| headsUp | non-binding "order coming" notices | default on |
| stationReachKm | station businesses: how far buyers may be to collect | 1–200, default 30 |
G5. Working hours
- PUT
…/setup/hours· GET…/setup/hours
{
"weekly": [ { "day": "MONDAY", "open": "08:00", "close": "18:00" },
{ "day": "SATURDAY", "open": "09:00", "close": "13:00" } ],
"specialDays": [ { "date": "2026-12-25", "closed": true, "ranges": [], "note": "Christmas" } ],
"sameDayCutoff": "16:00"
}
Several ranges per day (max 21 in all), close before open = past midnight, open = close = all day; special days ≤60, each up to 3 ranges. Offers count only open time; after the cut-off a same-day job starts the next opening.
G6. Coverage
- PUT
…/setup/coverage· GET…/setup/coverage· GET…/setup/coverage/outline(the ticked areas as map shapes, for the read-only map)
Request JSON Sample:
{
"trips": "WITHIN_AND_ACROSS",
"areas": [ { "level": "REGION", "regionCode": "07" },
{ "level": "DISTRICT", "regionCode": "05", "districtCode": "03" },
{ "level": "WARD", "wardId": 1146 } ]
}
| Parameter | Description |
|---|---|
| trips | WITHIN_EACH — both ends in the same ticked area · WITHIN_AND_ACROSS — both ends in any ticked areas |
| areas | up to 100, from the location area picker (location doc §1.3) |
Response: { "businessId", "trips", "areas": [AreaResponse], "wardCount": 116 }. Errors: AREA_NOT_FOUND,
AREA_OVERLAP (two ticked areas overlap, e.g. a ward inside a ticked district), COVERAGE_REQUIRED.
G7. Door prices
- PUT
…/setup/pricing· GET…/setup/pricing
Request JSON Sample (bands + value ladder):
{
"mode": "BANDS",
"bands": [ { "toKm": 3, "price": 2000, "minMinutes": 30, "maxMinutes": 60 },
{ "toKm": 7, "price": 3500, "minMinutes": 60, "maxMinutes": 120 },
{ "toKm": 15, "price": 5000, "minMinutes": 120, "maxMinutes": 180 } ],
"valueAddons": [ { "upToValue": 100000, "amount": 0 },
{ "upToValue": 500000, "amount": 1000 },
{ "percent": 1 } ]
}
| Parameter | Description | Validation |
|---|---|---|
| mode | BANDS (by distance) or FLAT |
|
| bands | each starts where the previous ends (first at 0 km); the last toKm = max distance |
≤10, toKm 0.1–500, price ≥1, minutes 5–10080 |
| flat | { "price", "minMinutes", "maxMinutes" } — price 0 only with a value ladder (value-only pricing) |
|
| valueAddons | insurance ladder by parcel value: a fixed amount per step, a percent allowed on the open top step |
≤10 |
Response: { "businessId", "mode", "bands": [{ fromKm, toKm, price, minMinutes, maxMinutes }], "flat", "valueAddons": [{ fromValue, upToValue, amount, percent }], "maxDistanceKm", "distanceFactor": 1.3 }.
New prices apply to new quotations only. Errors: PRICING_INVALID (message says what), TIME_RANGE_INVALID.
H. Stations (station businesses) — {base_url}/shipping/businesses/{businessId}/setup/…
| Endpoint | Does |
|---|---|
POST …/stations |
add a station |
GET …/stations |
list |
PUT …/stations/{stationId} |
edit |
DELETE …/stations/{stationId} |
remove |
PUT …/stations/{stationId}/hours |
the station's own hours (same body as G5) |
PUT …/station-prices · GET …/station-prices |
the pair price table |
PUT …/collection |
collection from shops |
Station request:
{ "name": "Magufuli Terminal, gate 3", "phone": "0755000111",
"pin": { "lat": -6.8402, "lng": 39.1601, "landmark": "Shabiby office, gate 3", "building": null } }
Station response: { "stationId", "businessId", "name", "phone", "lat", "lng", "ward", "areaLabel", "landmark", "building", "hours" }.
The station's phone receives the handover codes for parcels it receives. Errors: NOT_A_STATION_BUSINESS,
STATION_OUTSIDE_COVERAGE.
Station prices request (the whole table, replaced at once):
{ "pairs": [ { "fromStationId": "s-dar", "toStationId": "s-mbeya", "price": 21000,
"minMinutes": 720, "maxMinutes": 1440, "bothWays": true } ] }
Response: every direction between the stations (price null = not carried yet, show empty to fill in) + the
collection. Errors: STATION_NOT_FOUND, SAME_STATION, PAIR_TWICE, TIME_RANGE_INVALID.
Collection request:
{ "collects": true, "fee": 0, "extraMinutes": 120,
"areas": [ { "level": "DISTRICT", "regionCode": "07", "districtCode": "04" } ] }
collects: false = parcels reach its stations only through a door shipper. Errors: COLLECTION_INCOMPLETE.
I. Offers and jobs (the shipper's work)
A job is a parcel as the shipper sees it:
{
"jobId": "j-1", "businessId": "c4bc…", "parcelNumber": "NX-4K7P-2Q", "bulky": false, "valueBand": "UP_TO_500K",
"from": { "areaLabel": "Kariakoo, Ilala, Dar es Salaam", "wardName": "Kariakoo", "districtName": "Ilala", "regionName": "Dar es Salaam" },
"to": { "areaLabel": "Sinza, Ubungo, Dar es Salaam", "wardName": "Sinza", "districtName": "Ubungo", "regionName": "Dar es Salaam" },
"roadKm": 6.2, "cost": 3500, "commission": 0, "payout": 3500, "payoutStatus": "HELD",
"minMinutes": 30, "maxMinutes": 60, "arrivalFrom": "…", "arrivalTo": "…",
"status": "SHIPPER_ACCEPTED", "stage": null, "signature": "SIGNED", "paidAt": "…",
"details": { "pickup": { "label": "Shoe Shop", "lat": -6.819, "lng": 39.2768, "directions": "…", "phone": "+2557…" },
"dropoff": { "lat": -6.7735, "lng": 39.2187, "directions": "Blue gate" },
"receiverName": "Mama Json", "receiverPhone": "+255712345678", "note": "Call when you arrive" },
"rated": ["SELLER"]
}
| Field | Description |
|---|---|
valueBand |
UP_TO_50K, UP_TO_500K, ABOVE_500K — never the exact value |
from / to |
areas only, until accepted |
details |
exact places, receiver and note — only after the shipper accepted |
cost / commission / payout / payoutStatus |
its price, NexGate's cut, what it gets, and whether held / released / refunded |
stage |
Upcoming only: WAITING_FOR_SELLER, SELLER_PACKING |
signature |
its party state on the deal: PENDING, SIGNED, DECLINED, WITHDRAWN |
rated |
parties it already rated privately |
I1. Lists
| Endpoint | Shows |
|---|---|
GET {base_url}/shipping/businesses/{businessId}/upcoming?page=1&size=20 |
paid parcels coming its way, before any offer |
GET {base_url}/shipping/businesses/{businessId}/offers?page=1&size=20 |
open offers with a countdown |
GET {base_url}/shipping/businesses/{businessId}/jobs?status=ACTIVE&page=1&size=20 |
ACTIVE (accepted, not done) or DONE |
GET {base_url}/shipping/businesses/{businessId}/earnings?page=1&size=20 |
{ held, released, refunded, jobs } |
GET {base_url}/shipping/jobs/{jobId} |
one job |
Pages start at 1 (content, totalElements, totalPages, …). An offer:
{ "offerId", "status": "OPEN", "sentAt", "expiresAt", "job": {…}, "reconfirmNote": null } — reconfirmNote set =
a job it already accepted, asked again because its handover point or time moved ("Later than agreed: …").
I2. Accept an offer
Endpoint: POST {base_url}/shipping/offers/{offerId}/accept
Access Level: 🔒 Protected (the business owner)
No body. Signs the deal for that leg — binding from now on.
Success Response JSON Sample:
{
"success": true, "httpStatus": "OK", "message": "Job accepted", "action_time": "2026-10-24T10:30:45",
"data": { "job": { "jobId": "j-1", "status": "SHIPPER_ACCEPTED", "…": "…" },
"pickupCode": { "code": "7310", "expiresAt": "2026-10-25T10:30:45", "sentTo": "+255745****50" } }
}
pickupCode = the code the seller will type at the shop; shown once (also by SMS). Null until every leg of a
route accepted, and for legs that do not collect at the shop.
Errors: OFFER_CLOSED (expired / withdrawn / answered), BUSINESS_NOT_LIVE, NOT_YOUR_BUSINESS.
I3. Reject an offer
Endpoint: POST {base_url}/shipping/offers/{offerId}/reject
Request: { "reason": "TOO_BUSY", "note": null } — TOO_BUSY, TOO_FAR, NO_VEHICLE, CLOSING_SOON,
PARCEL_TOO_BIG, OTHER (needs a note, ≤300). Message only. Counts in the acceptance rate, never "in a row".
Errors: OFFER_CLOSED, REJECT_NOTE_REQUIRED, REJECT_REASON_INVALID (BULKY_MISMATCH / BADLY_PACKED are
reported at the shop with I7).
I4. Job steps
| Step | Endpoint | Body | Who / when |
|---|---|---|---|
| Heading to the shop | POST …/shipping/jobs/{jobId}/heading |
— | the leg collecting at the shop, after accepting |
| Check the parcel | POST …/shipping/jobs/{jobId}/check-parcel |
{ "parcelNumber": "NX-4K7P-2Q" } |
at the shop or a handover, before the code; → { "yours": true, "message": "✓ this parcel is yours" } |
| Deliver | POST …/shipping/jobs/{jobId}/deliver |
{ "code": "4821" } |
last leg: the receiver's code at the door, or the buyer's code at the station (AT_STATION) |
| Hand to the next leg | POST …/shipping/jobs/{jobId}/hand-over-next |
{ "code": "…" } |
the giver types the next rider's / station counter's code |
| Arrived at the station | POST …/shipping/jobs/{jobId}/arrived |
— | station leg at its destination station → AT_STATION, sends the next code |
| New pickup code | POST …/shipping/jobs/{jobId}/code |
— | the leg collecting at the shop lost its code → message "New pickup code sent" |
| My handover code | POST …/shipping/jobs/{jobId}/handover-code |
— | a receiving leg / station: show its handover code once (+SMS) |
The step endpoints answer with the job (heading, deliver, hand-over-next, arrived) or a { code, expiresAt, sentTo }.
Errors: NOT_AT_THE_SHOP, NOT_THE_LAST_LEG, NOT_A_STATION_ROUTE, NO_NEXT_LEG, NOT_ARRIVED,
PARCEL_NOT_RECEIVED, CODE_WRONG (details.triesLeft), CODE_LOCKED, CODE_EXPIRED, CODE_NOT_ACTIVE, CODE_LIMIT.
I5. Refuse the parcel at the shop
Endpoint: POST {base_url}/shipping/jobs/{jobId}/refuse-parcel
Request: { "reason": "TOO_BIG", "note": "Needs a car, it's a mattress" } — TOO_BIG or BADLY_PACKED.
Only after accepting and before taking the parcel (REFUSE_NOT_ALLOWED). Message only. See §1.13.
I6. Rate the seller or the buyer (private)
Endpoint: POST {base_url}/shipping/jobs/{jobId}/rate
Request: { "party": "BUYER", "score": 2, "issue": "NO_SHOW", "note": "Waited 20 minutes" }
| Field | Values |
|---|---|
| party | SELLER (after the hand-over), BUYER (after delivery) |
| score | 1–5 |
| issue | buyer: WRONG_ADDRESS, NOT_REACHABLE, NO_SHOW · seller: LATE_HANDOVER, BADLY_PACKED · both: RUDE, OTHER (needs a note) |
Message "Thanks, only NexGate sees this". Errors: ALREADY_RATED, RATE_NOT_YET, ISSUE_NOT_FOR_PARTY, NOTE_REQUIRED.
J. Bolt webhook (Bolt only)
POST {base_url}/shipping/providers/bolt/webhook — public, checked by our token in Authorization; ride updates
from Bolt. Always answers 200 with no body. Not for the app.
K. Admin — {base_url}/admin/shipping/…
Body for every action: { "reason": "…" (required, ≤500), "shipperAtFault": false }. Every action is audit-logged.
| Endpoint | Role | Does |
|---|---|---|
GET …/businesses?status=&integrated=®ionCode=&districtCode=&wardId=&search=&from=&to=&page=1&size=20 |
staff | shippers list (with adminStats { from, to, earned, held, deliveries, rating }, default last 30 days) |
GET …/businesses/{businessId}/overview?from=&to= |
staff | one shipper's page: business, coverage, stations, reliability, stats, jobs { active, delivered, missed } |
GET …/businesses/{businessId}/reliability |
staff | as F6, for admins |
POST …/businesses/{businessId}/suspend · /lift |
staff | stop / restart a shipper (the reason is shown to it) |
GET …/businesses/{businessId}/commission |
staff | { source: BUSINESS | PLATFORM, calculation, value, history } |
PUT …/businesses/{businessId}/commission |
super | { "percent": 5, "effectiveFrom": "…", "reason": "…" } — percent null = back to the platform rate; never in the past (COMMISSION_IN_PAST, COMMISSION_ORDER) |
GET …/documents/expiring?days=30 |
staff | KYC documents expired or expiring, soonest first (daysLeft < 0 = expired) |
GET …/operations |
staff | stuck orders: waitingForShipper (Ready over 1 h), buyerDecisions, problemReports, moneyHeldLong (over 14 days), boltSearching (over 10 min) + counts; each { orderId, orderNumber, parcelNumber, status, since, amount, detail, notes } |
GET …/notes?targetType=ORDER|BUSINESS&targetId= · POST …/notes |
staff | internal notes { targetType, targetId, text } (≤1000), with the admin's name |
GET …/reports?groupBy=REGION|DISTRICT|WARD®ionCode=&districtCode=&from=&to= |
staff | by area only: total, areas[] { code, name, deliveries, pickups, onTimeRate, averageCost, boltSpend }, top 10 shippers |
GET …/campaigns?creator=&status=&page=&size= |
staff | every campaign + totals (campaigns doc) |
GET …/accounts/{accountId}/party-ratings |
staff | what shippers privately said about an account, as buyer and as seller |
POST …/refusals/{refusalId}/false-claim |
staff | a "too big" refusal was false → strike (NOT_A_BULKY_CLAIM) |
PUT …/reviews/{reviewId}/status |
staff | { "status": "HIDDEN", "reason": "…" } |
POST …/orders/{orderId}/reassign |
staff | take the job from the shipper (between Ready and pickup), find another (REASSIGN_NOT_ALLOWED, NOT_DELIVERY) |
POST …/orders/{orderId}/cancel |
super | cancel while the parcel is at the shop; everything refunded (a shipper heading keeps 50%) — after it left: ADMIN_USE_ESCROW_ACTIONS |
POST …/escrows/{escrowId}/release · /refund |
super | settle a stuck / reported order part by hand (ESCROW_STATE, ESCROW_NOT_ON_ORDER) |
Order and escrow actions answer { before, after }. shipperAtFault with no accepted shipper → NO_SHIPPER_TO_BLAME.
K2. Integrated providers (Bolt) — {base_url}/admin/shipping/providers/…
| Endpoint | Role | Does |
|---|---|---|
GET …/providers |
staff | providers with status, connection, coverage, spent today vs daily limit |
PUT …/providers/{businessId}/coverage |
super | { "coverage": { "trips", "areas" }, "reason" } — where Bolt is offered |
POST …/providers/{businessId}/on · /off |
super | the kill-switch (COVERAGE_REQUIRED before on) |
GET …/providers/bolt/rides?page=1&size=20 |
staff | rides: states, times, held price vs Bolt's receipt (difference), rider |
POST …/providers/bolt/rides/{rideId}/state |
super | test modes only: move a ride { "state": "DRIVER_ON_ROUTE_TO_CLIENT" } (BOLT_LIVE) |
GET …/providers/bolt/status |
staff | mode, on/off, limit vs spent today, live / stuck / problem rides, owed, this month paid / receipts / kept, lastWebhookAt |
GET …/providers/bolt/invoices |
staff | recorded invoices |
POST …/providers/bolt/invoices |
super | { "invoiceNumber", "from", "to", "amount", "note" } → with our ledgerAmount and difference (INVOICE_ALREADY_RECORDED, INVOICE_PERIOD_INVALID) |
POST …/providers/bolt/invoices/{invoiceId}/pay |
super | { "reference": "<bank ref>", "reason" } — difference settled with revenue, then the bill paid out of PROVIDER-BOLT; paying twice changes nothing (INVOICE_CANNOT_BE_PAID) |
K3. Audit log
GET {base_url}/admin/audit?action=&targetType=&targetId=&adminId=&from=&to=&page=1&size=20 (staff) — every admin
action, newest first: { id, adminId, adminName, action, targetType, targetId, reason, before, after, at }. action
matches its start (SHIPPING_, FINANCE_, BOLT_INVOICE).
Part 4 — Statuses, values, error codes, settings
4.1 Fulfillment status (fulfillment.status)
| Status | Pickup | Delivery | Means |
|---|---|---|---|
PLACED |
✓ | ✓ | paid, waiting for the seller to accept |
ACCEPTED |
✓ | ✓ | seller accepted; parcel number given; packing |
READY |
✓ | ✓ | pickup: waiting for the buyer · delivery: offers out |
SHIPPER_ACCEPTED |
✓ | every leg accepted; delivery locked | |
HEADING_TO_SHOP |
✓ | the shipper (or Bolt rider) is on the way to the shop | |
ON_THE_WAY |
✓ | handed over at the shop | |
AT_STATION |
✓ | waiting at a station (collect, or the next leg) | |
DELIVERED |
✓ | delivered / collected at the station; waiting for the buyer's confirm | |
COLLECTED |
✓ | collected at the shop; waiting for the buyer's confirm | |
COMPLETED |
✓ | ✓ | buyer confirmed; money released |
CANCELLED |
✓ | ✓ | see closeReason |
EXPIRED |
✓ | ✓ | not collected in time; refunded |
4.2 Agreement status (backend; shown through the order)
DRAFT |
PENDING |
AGREED |
DONE |
CANCELLED |
|---|---|---|---|---|
| checkout, editable | paid, waiting for seller / shippers to sign | everyone signed | buyer confirmed | refunded |
4.3 Other values
| Value | Options |
|---|---|
FulfillmentType |
PICKUP, DELIVERY |
BusinessStatus |
KYC_APPROVED, LIVE, PAUSED, SUSPENDED |
TripRule |
WITHIN_EACH, WITHIN_AND_ACROSS |
PricingMode |
BANDS, FLAT |
QuoteRefusal |
NO_PRICING, TOO_FAR, VALUE_TOO_HIGH, BULKY_NOT_ACCEPTED, NOT_OPEN_SOON |
OptionLabel |
RECOMMENDED, CHEAPEST, FASTEST, USED_BEFORE |
HiddenReason |
DOMINATED, OUTLIER, BEYOND_TOP |
SelectionReason |
BUYER_CHOICE, FAVOURITE, RECOMMENDED |
ShiftReason |
SHOP_PAUSED, SHOP_CLOSED, SHIPPER_CLOSED, CUT_OFF |
CartChoiceBy |
BUYER, DEFAULT |
RouteKind |
STATION_COLLECTS, DOOR_TO_STATION |
DecisionReason |
NO_SHIPPER, COSTS_MORE, SLOWER, TOO_MANY_CHANGES, WAITING_TOO_LONG, PARCEL_TOO_BIG |
DecisionChoice |
USE_NEW_SHIPPER, KEEP_WAITING, PICKUP, END_AT_STATION, CANCEL |
OfferStatus |
OPEN, ACCEPTED, REJECTED, EXPIRED, WITHDRAWN |
OfferRejectReason |
TOO_BUSY, TOO_FAR, NO_VEHICLE, CLOSING_SOON, PARCEL_TOO_BIG, OTHER (+ shown only: BULKY_MISMATCH, BADLY_PACKED) |
ParcelRefusalReason |
TOO_BIG, BADLY_PACKED |
CodeStatus |
ACTIVE, USED, LOCKED, REPLACED, EXPIRED |
ValueBand |
UP_TO_50K, UP_TO_500K, ABOVE_500K |
UpcomingStage |
WAITING_FOR_SELLER, SELLER_PACKING |
JobListStatus |
ACTIVE, DONE |
PartyState |
PENDING, SIGNED, DECLINED, WITHDRAWN |
RatedParty / PartyIssue |
BUYER, SELLER / WRONG_ADDRESS, NOT_REACHABLE, NO_SHOW, LATE_HANDOVER, BADLY_PACKED, RUDE, OTHER |
ReliabilitySignal |
MISSED_OFFERS, ACCEPTANCE_RATE, ON_TIME, BROKE_BINDING, AT_FAULT, FALSE_BULKY_CLAIM, AUTO_PAUSES |
ReliabilityEventType |
STRIKE, WARNED, PAUSED, SUSPENDED |
BusinessDocumentType |
BUSINESS_LICENCE, DIRECTOR_NIDA, LATRA_LICENCE |
ReportLevel / NoteTarget |
REGION, DISTRICT, WARD / ORDER, BUSINESS |
BoltMode / BoltInvoiceStatus |
OFF, FAKE, SANDBOX, LIVE / RECORDED, PAID |
4.4 Fixed rules (backend settings)
| Rule | Value |
|---|---|
| Seller accept window | 24 opening hours |
| Offer response window | 30 min default (shipper sets 5–120), only while open; closed > 4 h → next shipper |
| Replacements per order before the buyer is always asked | 3 |
| Chain safety net / buyer decision | 24 h / 24 h → pickup |
| Re-confirm an accepted leg when its time moves | > 4 h |
| Pickup collection / station collection | 14 days from Ready / 7 days |
| Address changes per order | 3 (only before a shipper accepts) |
| Quotation price hold | 5 days |
| Options shown | top 5; outlier = more than 3× the cheapest |
| Codes | 4 digits, 24 h, 5 wrong tries, 5 new codes per handover per 24 h |
| Distance | straight line × 1.3; add-ons rounded up to 100 |
| Meeting points | 1 km counts as the same place |
| Station reach | 30 km default |
| Estimation cache | 1 h |
| Favourites | 10 |
| "New" badge | 30 days after going live |
| Rating nudge on ⭐ | shippers with ≥ 5 reviews, up to 5% |
| Bolt | boda, non-bulky, price buffer 2%, polled every 15 s, searching > 10 min → replaced |
| Admin alerts | Ready over 1 h without a shipper, money held over 14 days, digest 07:30 |
4.5 Error codes (HTTP 409, data.code)
Location codes (DROPOFF_INVALID, SHOP_LOCATION_REQUIRED, …) are in docs/location/location_api_doc.md; campaign codes in
docs/shipping/campaigns/campaigns_api_doc.md; checkout codes (FULFILLMENT_REQUIRED, RECEIVER_INCOMPLETE, SHOP_CLOSED) in
commerce_logistics_changes.md §11c / §22.
| Code | Meaning |
|---|---|
ADMIN_USE_ESCROW_ACTIONS |
The parcel already left the shop; settle it with escrow release / refund |
AGREEMENT_CLOSED |
This order is already completed |
AGREEMENT_FROZEN |
A problem was reported on this order; support will settle it |
AGREEMENT_STATE |
Not possible in the deal's current state |
ALREADY_DELIVERY |
This order is already for delivery |
ALREADY_PICKUP |
This order is already for pickup |
ALREADY_RATED |
You already rated this party on this job |
AREA_NOT_FOUND |
One of the areas does not exist |
AREA_OVERLAP |
Two ticked areas overlap (e.g. a ward inside a ticked district, or ticked twice) |
BOLT_CANNOT_CANCEL |
This ride can no longer be cancelled |
BOLT_FARE_EXPIRED |
The Bolt price ran out; estimate again |
BOLT_LIVE |
Ride states can only be set in FAKE or SANDBOX mode |
BOLT_NOT_ASSIGNED |
Bolt has not assigned a rider yet |
BOLT_NOT_CONFIGURED |
Bolt credentials are not set |
BOLT_NO_RECEIPT |
This ride has no charge |
BOLT_OFF |
Bolt is switched off |
BOLT_RIDE_BOOKED |
A Bolt rider is already booked for this address; it can't change now |
BOLT_RIDE_NOT_FOUND |
No such Bolt ride |
BOLT_STOPS_CHANGED |
Book with the stops that were estimated |
BOLT_UNAVAILABLE |
Bolt did not accept the request |
BULKY_EXTRA_REQUIRED |
Set the extra you charge for bulky parcels (0 for none) |
BUSINESS_NOT_LIVE |
Resume your business before accepting jobs |
BUSINESS_SUSPENDED |
This business is suspended; contact NexGate support |
CAMPAIGN_ENDED |
At payment: a campaign ended / ran out since checkout (details as DELIVERY_PRICE_CHANGED) — PATCH the session, then pay |
CANCEL_NOT_ALLOWED |
Cancelling is not possible now; the message says what to do instead (e.g. switch to pickup) |
CANNOT_END_HERE |
This route cannot end at a station here |
CHANGE_NOT_ALLOWED |
A shipper has taken this order; the delivery address can no longer change |
CODE_EXPIRED |
This code has expired. Tap "I lost my code" for a new one |
CODE_LIMIT |
Too many new codes today. Try again tomorrow |
CODE_LOCKED |
Too many wrong tries. Ask for a new code |
CODE_NOT_ACTIVE |
There is no active code for this handover |
CODE_NOT_YET |
Your collection code comes when the parcel reaches the station |
CODE_WRONG |
Wrong code (details.triesLeft) |
COLLECTION_INCOMPLETE |
Set the areas you collect from and your fee (0 for free) |
COMMISSION_IN_PAST |
A commission change cannot start in the past |
COMMISSION_ORDER |
A commission change must start after the current one |
COVERAGE_REQUIRED |
Set the areas the business serves first |
DECISION_CHOICE_INVALID |
Choose one of the offered choices |
DELIVERY_NOT_AVAILABLE |
No shipper can deliver this parcel to this address |
DELIVERY_NOT_PAID |
This leg was free, so it cannot cost more |
DELIVERY_OPTION_GONE |
The chosen shipper can no longer deliver (details: shopId, businessId, businessName, before, after) |
DELIVERY_PRICE_CHANGED |
The delivery price changed since the quotation (details as above) — show before → after, retry |
DESTINATION_CHANGE_LIMIT |
The address was already changed 3 times; contact support |
DESTINATION_REQUIRED |
Add a delivery address first |
DIGITAL_NO_DELIVERY |
A digital product needs no delivery |
DOCUMENT_EXPIRED |
The document has already expired |
DOCUMENT_INVALID |
The document file was not found, is not yours, or failed to upload |
DOCUMENT_NOT_READY |
This document cannot be opened right now |
ESCROW_AMOUNT |
The escrow amount does not allow this action |
ESCROW_NOT_ON_ORDER |
This escrow is not linked to a product order |
ESCROW_STATE |
The escrow is not held (already released / refunded) |
FAVOURITES_LIMIT |
You can keep up to 10 favourite shippers |
FULFILLMENT_STATE |
There is no pickup code to replace for this job |
INFORM_BUYER_FIRST |
Confirm that you have already informed the buyer |
INSTALLMENT_ORDER |
This order is paid by installments; only its agreement can be cancelled |
INVOICE_ALREADY_RECORDED |
This invoice number is already recorded |
INVOICE_CANNOT_BE_PAID |
Not enough in PROVIDER-BOLT or PLATFORM-REVENUE to pay this invoice |
INVOICE_PERIOD_INVALID |
The period ends before it starts |
ISSUE_NOT_FOR_PARTY |
That issue is not about this party (e.g. NO_SHOW for a seller) |
ITEMS_REQUIRED |
Add at least one physical item to quote delivery |
LAYAWAY_DELIVERY_LATER |
Layaway plans choose delivery after the last instalment |
LEG_HAS_SHIPPER |
This leg still has a shipper |
LEG_NOT_FOUND |
This option has no such leg |
LICENCE_TAKEN |
A business with this licence number is already registered |
LOGO_INVALID |
The logo file was not found |
NIDA_INVALID |
The director's NIDA number must have 20 digits |
NOTE_REQUIRED |
Say what went wrong |
NOTHING_TO_CHANGE_BACK |
There is no earlier address to go back to |
NOT_ARRIVED |
Tap Arrived at the station first; that sends the rider's code |
NOT_AT_THE_SHOP |
Only the shipper collecting at the shop does this |
NOT_A_BULKY_CLAIM |
Only a 'too big' refusal can be a false bulky claim |
NOT_A_DOOR_LEG |
To change the station business, choose another option |
NOT_A_PARTY |
You are not a party to this agreement |
NOT_A_PROVIDER |
This business registered itself; manage it from the shippers list |
NOT_A_STATION_BUSINESS |
Tick station in your service type first |
NOT_A_STATION_ROUTE |
This job does not end at a station |
NOT_DELIVERY |
This order is for pickup; there is no shipper to replace |
NOT_PICKUP |
This order is for delivery, not pickup |
NOT_REVIEWABLE |
This shipper is not taking orders yet |
NOT_THE_LAST_LEG |
Only the shipper at the end of the route does this |
NOT_YOUR_BUSINESS |
This job is for another delivery business |
NOT_YOUR_ORDER |
Only the seller can do this |
NOT_YOUR_SHOP |
This is not your shop |
NO_BOLT_RIDE |
No Bolt rider is booked for this order |
NO_DECISION_NEEDED |
Nothing is waiting for your decision on this order |
NO_NEXT_LEG |
This job ends with the buyer; there is nobody to hand over to |
NO_SHIPPER_TO_BLAME |
No shipper has accepted this job, so none broke it |
OFFER_CLOSED |
The offer is no longer open (expired, withdrawn or answered) |
OPTION_NOT_FOUND |
That option is not part of this quotation |
OPTION_REQUIRED |
Choose one of the quotation's options |
OWN_BUSINESS |
You cannot review your own delivery business |
PAIR_TWICE |
The same direction has two different prices or times |
PARCEL_NOT_CHECKED |
The rider must scan or type the parcel number first |
PARCEL_NOT_RECEIVED |
Receive the parcel first |
PHONE_INVALID |
The WhatsApp number is not valid |
PICKUP_TIME_INVALID |
The shop is closed at that time |
PLATE_MISMATCH |
Wrong rider: the message names the plate Bolt sent |
PRICING_INVALID |
The price table is not valid (the message says what) |
PRODUCTS_FROM_DIFFERENT_SHOPS |
Quote one shop at a time |
PRODUCT_NOT_AVAILABLE |
One of the products is not available |
PRODUCT_NOT_IN_SHOP |
Choose only products of this shop |
QUOTATION_EXPIRED |
These prices are no longer held; ask again for today's prices |
QUOTATION_OUTDATED |
Your parcel or address changed since these options; ask again |
QUOTATION_REPLACED |
This quotation was replaced by a newer one; ask again |
RATE_NOT_YET |
You can rate the buyer once the parcel is delivered |
READY_CONFIRM_REQUIRED |
Delivery Ready needs both ticks: packed well, label on the package |
REASSIGN_NOT_ALLOWED |
Only between Ready and pickup |
REFUSE_NOT_ALLOWED |
Only at the shop, before you take the parcel |
REJECT_NOTE_REQUIRED |
Say why you can't take this job |
REJECT_REASON_INVALID |
Report the parcel from the job once you are at the shop |
REVIEW_EMPTY |
Give a rating or write a review |
REVIEW_EXISTS |
You already reviewed this shipper. Update your review instead |
SAME_STATION |
A price needs two different stations |
SERVICE_TYPE_REQUIRED |
Choose door, station or both |
SHIPPER_CANNOT_DO_LEG |
That shipper cannot carry this leg |
SHIPPER_DELIVERS |
A shipper delivers this order; use the delivery steps |
SHIPPER_NOT_FOUND |
The shipper no longer exists |
SHOP_NOT_FOUND |
One of the shops does not exist |
SHOP_NOT_IN_CART |
Nothing from this shop in your cart needs pickup or delivery |
SHOP_REQUIRED |
Send the shop, or the items to quote |
STATION_NOT_FOUND |
One of the stations is not yours |
STATION_OUTSIDE_COVERAGE |
The station is not in the areas the business serves |
STATION_ROUTE_NOT_HERE |
Collecting at a station can only be chosen at checkout for now; choose a door option |
STRIKE_INVALID |
Only broke-binding, at-fault and false bulky claims are strikes |
SWITCH_NOT_ALLOWED |
A shipper has taken this order; it can no longer switch to pickup |
TIME_RANGE_INVALID |
The shortest time cannot be longer than the longest |
TOO_MANY_PRODUCTS |
Too many products in one estimation request |
USE_PICKUP_FLOW |
This is a pickup order; use the pickup steps |
USE_PLATE_CHECK |
Bolt riders have no code: check the plate and hand over with it |
WALLET_INSUFFICIENT |
The wallet does not hold enough for this payment |
No comments to display
No comments to display