Shipping Service API

Author: Josh S. Sakweli, Backend Lead Team Last Updated: 2026-10-24 Version: v1.0

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.

Hints:


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


Contents


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 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

  1. Where: both places' ward / district / region.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
  6. 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.
  7. 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.
  8. Order: ⭐, 💰, ⚡, then favourites, then the rest by cost; 5 shown, the others in more.
  9. 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)
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)
  1. 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).
  2. Ready (/ready) — one tap "Packed & ready" (packedWell tick recorded). The buyer gets the place (shop, landmark, phone), a collect-by date (14 days) and the 4-digit code by SMS.
  3. 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).
  4. Collect (/collect) — at the counter the seller types the buyer's code.
  5. 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
  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. Heading to the shop — the shipper taps it when it sets out; the seller is told (SMS too).
  6. 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 becomes SHIPPED and the delivery code goes by SMS to the receiver.
  7. At the door — the rider types the receiver's code (/jobs/{id}/deliver) → DELIVERED. The buyer is asked to confirm; the seller is told.
  8. 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

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".

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

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:

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)
  1. 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.
  2. Service setup (any order, any time; GET …/setup = checklist with canGoLive and what is missing):
    • PROFILE logo, description, WhatsApp, email (optional)
    • SERVICE_TYPE door and / or station (required)
    • HOURS working hours (default Mon–Sat 08:00–18:00; several ranges a day, special days, same-day cut-off)
    • COVERAGE ticked areas + trip rule (required)
    • PRICING door prices: distance bands or flat, optional value add-on ladder (required for door)
    • LIMITS max parcel value, bulky yes / no + extra, response window (5–120 min), heads-up on/off, station reach
    • STATIONS + STATION_PRICES (station businesses: a station in every ticked area, at least one priced pair) and optional collection from shops (areas + fee + extra minutes)
  3. 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

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 7,000 3,500 · Shop + NexGate pay part" (absent without a campaign)

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:

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

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

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

{
  "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

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

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=&regionCode=&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&regionCode=&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

Revision #1
Created 24 October 2026 19:27:38 by Admin Qbit
Updated 24 October 2026 19:27:59 by Admin Qbit