# 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.

**Related docs**:
- `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 with `data.details`) — switch on the code. Full list in Part 4.
- Money is whole TZS; times are East Africa Time.

---

## Standard Response Format

### Success
```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Delivery options",
  "action_time": "2026-10-24T10:30:45",
  "data": { }
}
```

### Error (coded refusal)
```json
{
  "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** - <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> - read only
- **POST** - <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> - create / act
- **PUT** - <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> - replace
- **DELETE** - <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> - 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

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)
```

- 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)
```

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
```

- 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 a **`rider`** card (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_CHECK` if the code
  hand-over is used; `PLATE_MISMATCH` if 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 says `triesLeft`).
- 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: buyer `POST /shipping/orders/{id}/code`,
  shipper at the shop `POST /shipping/jobs/{id}/code`, receiving rider / station `POST /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-problem` with 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)
```

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

- **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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/estimations`

**Access Level**: 🔒 Protected (any logged-in user) · Bearer token

**Request JSON Sample**:
```json
{
  "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**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/quotations`

**Access Level**: 🔒 Protected (buyer) · Bearer token

**Request JSON Sample** (sheet):
```json
{
  "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**:
```json
{
  "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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"):
```json
[ { "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/cart/destination`

**Access Level**: 🔒 Protected (buyer)

**Request JSON Sample**:
```json
{ "dropoff": { "recentId": "a71e…" } }
```

**Success Response JSON Sample** (the delivery change):
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/e-commerce/cart/shops/{shopId}/fulfillment`

**Access Level**: 🔒 Protected (buyer)

**Request JSON Sample**:
```json
{ "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`):

```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/orders/{orderId}/ready`

**Access Level**: 🔒 Protected (the shop's seller)

**Request JSON Sample**:
```json
{ "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**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/orders/{orderId}/code`

**Access Level**: 🔒 Protected (the buyer)

**Success Response JSON Sample**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/orders/{orderId}/switch-to-delivery`

**Access Level**: 🔒 Protected (the buyer)

**Request JSON Sample**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/orders/{orderId}/cancel`

**Access Level**: 🔒 Protected (the buyer or the shop's seller)

**Request JSON Sample** (seller):
```json
{ "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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**:
- <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/shipping/favourites/{businessId}` — add (idempotent)
- <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/public`

**Access Level**: 🔒 Protected (any logged-in user)

**Success Response JSON Sample**:
```json
{
  "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 |
|---|---|
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/businesses/{businessId}/reviews` | write a review |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/shipping/businesses/{businessId}/reviews` | edit my review |
| <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/shipping/businesses/{businessId}/reviews` | delete my review |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/reviews` | reviews with text |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/reviews/paged?page=1&size=10` | paged |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/reviews/my-review` | mine |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/reviews/summary` | average + 1..5 distribution |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{ "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/businesses`

**Access Level**: 🔒 Protected (any user)

**Request JSON Sample**:
```json
{
  "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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
- <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/shipping/businesses/{businessId}/documents/{type}` — body `{ "fileId", "number", "expiresOn" }` → the business.
- <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}`

**Success Response JSON Sample**:
```json
{
  "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
- <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/shipping/businesses/{businessId}/pause` — body optional `{ "reason": "Ramadan break" }` (≤255)
- <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/reliability`

**Success Response JSON Sample**:
```json
{
  "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**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/setup`

```json
{ "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
<span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/setup/profile` — `{ "logoFileId": "…", "description": "…" (≤500), "whatsapp": "…", "email": "…" }` → the business.
**Errors**: `LOGO_INVALID`, `PHONE_INVALID`.

## G3. Service type
<span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/setup/service-type` — `{ "door": true, "station": false }` → the business. **Errors**: `SERVICE_TYPE_REQUIRED`.

## G4. Limits
<span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/setup/limits`

```json
{ "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
- <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/setup/hours` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/setup/hours`

```json
{
  "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
- <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/setup/coverage` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/setup/coverage` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/setup/coverage/outline` (the ticked areas as map shapes, for the read-only map)

**Request JSON Sample**:
```json
{
  "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
- <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/setup/pricing` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/setup/pricing`

**Request JSON Sample** (bands + value ladder):
```json
{
  "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 |
|---|---|
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/stations` | add a station |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/stations` | list |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/stations/{stationId}` | edit |
| <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `…/stations/{stationId}` | remove |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/stations/{stationId}/hours` | the station's own hours (same body as G5) |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/station-prices` · <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/station-prices` | the pair price table |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/collection` | collection from shops |

**Station request**:
```json
{ "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):
```json
{ "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**:
```json
{ "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:
```json
{
  "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 |
|---|---|
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/upcoming?page=1&size=20` | paid parcels coming its way, before any offer |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/offers?page=1&size=20` | open offers with a countdown |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/jobs?status=ACTIVE&page=1&size=20` | `ACTIVE` (accepted, not done) or `DONE` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/shipping/businesses/{businessId}/earnings?page=1&size=20` | `{ held, released, refunded, jobs }` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**:
```json
{
  "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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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 | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/shipping/jobs/{jobId}/heading` | — | the leg collecting at the shop, after accepting |
| Check the parcel | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/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 | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/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 | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/shipping/jobs/{jobId}/hand-over-next` | `{ "code": "…" }` | the giver types the next rider's / station counter's code |
| Arrived at the station | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/shipping/jobs/{jobId}/arrived` | — | station leg at its destination station → `AT_STATION`, sends the next code |
| New pickup code | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/shipping/jobs/{jobId}/code` | — | the leg collecting at the shop lost its code → message "New pickup code sent" |
| My handover code | <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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)
<span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{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 |
|---|---|---|
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/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) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/businesses/{businessId}/overview?from=&to=` | staff | one shipper's page: business, coverage, stations, reliability, stats, jobs `{ active, delivered, missed }` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/businesses/{businessId}/reliability` | staff | as F6, for admins |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/businesses/{businessId}/suspend` · `/lift` | staff | stop / restart a shipper (the reason is shown to it) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/businesses/{businessId}/commission` | staff | `{ source: BUSINESS \| PLATFORM, calculation, value, history }` |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/businesses/{businessId}/commission` | super | `{ "percent": 5, "effectiveFrom": "…", "reason": "…" }` — `percent` null = back to the platform rate; never in the past (`COMMISSION_IN_PAST`, `COMMISSION_ORDER`) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/documents/expiring?days=30` | staff | KYC documents expired or expiring, soonest first (`daysLeft` < 0 = expired) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/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 }` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/notes?targetType=ORDER\|BUSINESS&targetId=` · <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/notes` | staff | internal notes `{ targetType, targetId, text }` (≤1000), with the admin's name |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/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` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/campaigns?creator=&status=&page=&size=` | staff | every campaign + totals (campaigns doc) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/accounts/{accountId}/party-ratings` | staff | what shippers privately said about an account, as buyer and as seller |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/refusals/{refusalId}/false-claim` | staff | a "too big" refusal was false → strike (`NOT_A_BULKY_CLAIM`) |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/reviews/{reviewId}/status` | staff | `{ "status": "HIDDEN", "reason": "…" }` |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/orders/{orderId}/reassign` | staff | take the job from the shipper (between Ready and pickup), find another (`REASSIGN_NOT_ALLOWED`, `NOT_DELIVERY`) |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/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` |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/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 |
|---|---|---|
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/providers` | staff | providers with status, connection, coverage, spent today vs daily limit |
| <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `…/providers/{businessId}/coverage` | super | `{ "coverage": { "trips", "areas" }, "reason" }` — where Bolt is offered |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/providers/{businessId}/on` · `/off` | super | the kill-switch (`COVERAGE_REQUIRED` before on) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/providers/bolt/rides?page=1&size=20` | staff | rides: states, times, held price vs Bolt's receipt (`difference`), rider |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/providers/bolt/rides/{rideId}/state` | super | test modes only: move a ride `{ "state": "DRIVER_ON_ROUTE_TO_CLIENT" }` (`BOLT_LIVE`) |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/providers/bolt/status` | staff | mode, on/off, limit vs spent today, live / stuck / problem rides, `owed`, this month `paid / receipts / kept`, `lastWebhookAt` |
| <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `…/providers/bolt/invoices` | staff | recorded invoices |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/providers/bolt/invoices` | super | `{ "invoiceNumber", "from", "to", "amount", "note" }` → with our `ledgerAmount` and `difference` (`INVOICE_ALREADY_RECORDED`, `INVOICE_PERIOD_INVALID`) |
| <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `…/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
<span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{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 |