Shipping Campaigns API
Base URL: https://api.nexgate.co/api/v1
Short Description: A shipping campaign is someone paying part of a buyer's delivery, for buyers who meet some rules, for some time — "Free delivery in Dar this weekend", "I pay 50% of delivery", "25% off my deliveries into Kinondoni". Three kinds of creator can run one: a shipper (a discount on its own price), a seller (paid from the order's own items money) and NexGate (paid from a pot of money held in escrow). This doc explains how they are worked out, paid, refunded and managed.
Hints:
cost.totaleverywhere stays the shipper's table price. What the buyer pays isoffer.buyerPays; showcost.totalbuyerPays and the shares ("Shop pays 3,000 · NexGate pays 1,800").- The campaign is shown at estimation, worked out at the quotation, paid at payment and frozen after.
Nothing is held at quotation time — the buyer may learn at payment that a campaign has just ended (
CAMPAIGN_ENDED). - A campaign never pays more after payment.
- Coded refusals are HTTP 409 with
data.code.
Standard Response Format
Success
{
"success": true,
"httpStatus": "OK",
"message": "Campaign created",
"action_time": "2026-10-24T10:30:45",
"data": { }
}
Error (coded refusal)
{
"success": false,
"httpStatus": "CONFLICT",
"message": "Say the most you pay per delivery (the cap)",
"action_time": "2026-10-24T10:30:45",
"data": { "code": "CAMPAIGN_CAP_REQUIRED", "reason": "Say the most you pay per delivery (the cap)" }
}
| 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? } |
HTTP Method Badge Standards
- GET - GET - read only
- POST - POST - create / act
- PUT - PUT - replace
Part 1 — How campaigns work
1.1 Who can run what
| Creator | Covers | Kinds | Money comes from | Who manages it |
|---|---|---|---|---|
| SHIPPER | its own deliveries, from any shop | PERCENT, FIXED (no FREE) |
nowhere — it simply charges less | the business owner only (not admins) |
| SELLER | deliveries of its own shop's orders | FREE, PERCENT, FIXED |
the order's own items money, at payment | the shop owner (or a super admin) |
| NEXGATE | deliveries of any shop, or chosen shops | FREE, PERCENT, FIXED |
a pot held in escrow, funded from SHIPPING-CAMPAIGNS |
super admins |
| Kind | Means | Applies to |
|---|---|---|
FREE |
the whole delivery, up to a cap per order |
the cheapest qualifying option becomes free; a faster one costs the buyer the difference |
PERCENT |
value% of the delivery, optional cap |
the option the buyer picks |
FIXED |
value TZS off |
the option the buyer picks |
1.2 Who qualifies
| Rule | Field | Notes |
|---|---|---|
| Where the parcel goes (main) | areas |
the buyer's destination: regions / districts / wards; empty = everywhere |
| When (main) | startsAt, endsAt |
start defaults to now; NexGate needs an end |
| Where it starts | fromAreas |
shipper only |
| Minimum order | minOrder |
this shop's items |
| Audience | audience |
EVERYONE or FIRST_ORDER |
| Shops | shopIds |
NexGate only (empty = all shops) |
| Products | productIds / excludedProductIds |
seller only; a mixed parcel qualifies only if every item does |
| Per buyer | perBuyerLimit |
e.g. 1 = once per buyer |
| Cap per order | cap |
FREE and PERCENT |
| Spending limit | spendLimit |
seller only — a counter, not money held ("stop after 200,000") |
A creator may run several campaigns at once; per order only one campaign per creator applies — the one that gives the buyer the most, worked out per option.
1.3 Stacking — when several apply
- Order: shipper discount → seller → NexGate.
- The shipper's discount sets the real price (
price); every other share is worked out from that full price, never from "what is left". - Shares together never exceed the price; the last one is trimmed (normally NexGate). The buyer never pays below zero.
| Case | Seller | NexGate | Buyer |
|---|---|---|---|
| 6,000 · seller 50% · NexGate 30% | 3,000 | 1,800 | 1,200 |
| 10,000 · seller 50% at most 3,000 · NexGate 30% | 3,000 | 3,000 | 4,000 |
| 6,000 · seller 70% · NexGate 50% | 4,200 | 1,800 (trimmed) | 0 · Free |
| 8,000, shipper 25% off → 6,000 · seller 50% · NexGate 30% | 3,000 | 1,800 | 1,200 (the shipper earns 6,000) |
| 6,000 · NexGate 50% but the pot holds 1,000 | — | 0 (full share or nothing) | 6,000 |
Ranking uses what the buyer pays (+ arrival time), so a campaign can make a shipper ⭐ Recommended or 💰 Cheapest.
1.4 The offer on an option
Every delivery option (quotation, cart, checkout, order) gains offer when a campaign applies (absent otherwise):
"offer": {
"listPrice": 7000,
"price": 7000,
"buyerPays": 1900,
"shares": [
{ "campaignId": "…", "funder": "SELLER", "name": "Half delivery in Dar", "amount": 3000 },
{ "campaignId": "…", "funder": "NEXGATE", "name": "Dar 30 percent", "amount": 2100 }
]
}
| Field | Meaning |
|---|---|
listPrice |
the shipper's table price (= cost.total) |
price |
after the shipper's own discount — what the shipper is paid |
shares |
who pays part of price; a SHIPPER share is the discount (not money) |
buyerPays |
the rest |
Product cards show a badge from the estimation's campaign field: { "listFrom": 7000, "paidBy": ["SELLER", "NEXGATE"] }
→ "Delivery from 7,000 1,900 · Shop + NexGate pay part"; freeDelivery: true when the cheapest option is free.
The cart shows a nudge under a shop when a minimum order is near: "Add 4,995 more from My Shop for free delivery".
1.5 Where the money sits
Shipper — nothing set aside; it earns less per job. Refunds return nothing.
Seller — taken from the order's own money at payment: items 50,000 + delivery 6,000, seller pays all of the delivery → the buyer pays 50,000, split in escrow into 44,000 items + 6,000 delivery. The shipper is fully covered from payment. Guards so a seller never ends below zero:
- every seller
FREE/PERCENTcampaign needs a cap; - with a minimum order, the cap must fit inside it after commission (
CAMPAIGN_CAP_ABOVE_MINIMUM); - at the quotation and again at payment the share must fit this order's items money minus commission, else the seller's campaign does not apply to that option (full share or nothing);
- with a
spendLimit, the share must fit what is left of it.
NexGate — a pot per campaign, held in escrow:
real money ──top-up──► PLATFORM-REVENUE ──transfer──► SHIPPING-CAMPAIGNS ──fund / top-up──► campaign pot ──at payment──► the order's delivery part
(the only door ▲ │
for outside money) └──── leftovers, late refunds ──────┘
- Creating a NexGate campaign moves its
budgetfromSHIPPING-CAMPAIGNSinto its pot (CAMPAIGN_BUDGET_LOWif short); a top-up adds more. - Each use moves the share from the pot into the order's delivery part under a lock — parallel checkouts can never take a pot below zero. Full share or nothing.
- Pot empty → Ran out (stops by itself); a top-up or a refund restarts it.
- Ended / stopped → whatever is left goes back to
SHIPPING-CAMPAIGNSat once.
1.6 One order, start to finish
| Stage | What happens |
|---|---|
| Estimation (card) | badge only — matching campaigns, nothing exact, nothing held |
| Quotation (sheet / cart / checkout) | the rules run on every option; best campaign per creator; the quotation remembers which it used; nothing held |
| Payment | checked again under a lock: still running? pot / limit / items still cover the share? Yes → the buyer pays only their part, the shares move into the delivery part. No → payment refused CAMPAIGN_ENDED (details: shopId, businessId, businessName, before, after); nothing charged; PATCH the checkout session (same pick) for today's price, then pay |
| Recorded | buyer · seller · NexGate amounts frozen on the delivery escrow — they drive every refund and report |
| Delivered + confirmed | the shipper gets its full price; the seller gets items minus its share (commission on the full items price) |
1.7 After payment
| Change | Campaign money |
|---|---|
| Address change (before a shipper accepts) | each share kept up to what it paid; one that no longer applies goes back to its source and the buyer pays it; the buyer tops up / gets back only their own part |
| Cheaper shipper on reassignment | the difference comes back in proportion (pot, seller's items part, buyer) |
| Dearer shipper | shares stay; the buyer pays the extra |
| Switch to pickup / cancel / refund | every share goes back where it came from; the buyer gets back all they paid |
| Pickup → delivery after payment | campaigns apply like at checkout (CAMPAIGN_ENDED possible) |
| Admin cancel while the rider is heading | the 50% wasted trip is paid pro rata by everyone in the delivery part |
Where a NexGate share goes on a refund:
| Campaign state at refund | NexGate's share | Seller's limit |
|---|---|---|
| Running / Paused | back to the pot | restored |
| Ran out | back to the pot → Running again | restored |
| Ended / Cancelled | back to the pot, moved on to SHIPPING-CAMPAIGNS within a minute |
nothing to do |
1.8 Installments and group purchase
- Immediate installment: the seller's share comes from the down payment (qualifying uses the full price; the
share + commission must fit the down payment, else payment refuses
CAMPAIGN_ENDED"… pay a bigger down payment"). NexGate and shipper campaigns work as usual. - Layaway: campaigns apply when the buyer chooses delivery after the last instalment.
- Group purchase: each member's delivery gets its own campaigns; leaving or a failed group refunds in full and every share goes back.
- A station route (several businesses) gets seller and NexGate shares but no shipper discount.
1.9 States
status (stored) |
state (shown) |
Means |
|---|---|---|
SCHEDULED |
SCHEDULED |
starts later (startsAt) |
RUNNING |
RUNNING / RAN_OUT |
live; RAN_OUT = pot empty or spending limit used up |
PAUSED |
PAUSED |
stopped by the creator; resume any time |
ENDED |
ENDED |
the end date passed (timer, every minute) |
CANCELLED |
CANCELLED |
stopped for good by the creator |
A closed campaign (ended / cancelled) cannot be edited or restarted — create a new one (CAMPAIGN_CLOSED). Edits
change new quotations only; paid orders keep what they got.
Notifications to whoever runs it (in-app + push, opens SHIPPING_CAMPAIGN with the campaignId):
SHIPPING_CAMPAIGN_RAN_OUT ("Dar 30 percent" ran out · the pot is empty) and SHIPPING_CAMPAIGN_ENDED
("Dar 30 percent" has ended · It paid 184,000 towards delivery on 96 orders.).
Part 2 — Endpoints
1. Create a campaign
Purpose: Start a campaign for a shop, a delivery business, or NexGate.
Endpoint: POST {base_url}/shipping/campaigns
Access Level: 🔒 Protected — SELLER: the shop owner · SHIPPER: the business owner · NEXGATE: SUPER_ADMIN
Authentication: Bearer token
Request JSON Sample (seller):
{
"creator": "SELLER",
"shopId": "aaaa0000-0000-4000-8000-000000000001",
"name": "Free delivery over 50,000",
"kind": "FREE",
"cap": 5000,
"areas": [ { "level": "REGION", "regionCode": "07" } ],
"startsAt": "2026-10-25T00:00:00",
"endsAt": "2026-11-30T23:59:00",
"minOrder": 50000,
"audience": "EVERYONE",
"excludedProductIds": ["77a1…"],
"perBuyerLimit": 2,
"spendLimit": 200000
}
Request JSON Sample (NexGate):
{
"creator": "NEXGATE",
"name": "Dar 30 percent",
"kind": "PERCENT", "value": 30, "cap": 3000,
"areas": [ { "level": "REGION", "regionCode": "07" } ],
"endsAt": "2026-11-02T23:59:00",
"shopIds": [],
"budget": 500000
}
Request Body Parameters:
| Parameter | Type | Required | Description | Validation |
|---|---|---|---|---|
| creator | enum | Yes | SHIPPER, SELLER, NEXGATE |
cannot change later |
| shopId | UUID | SELLER | the shop running it | your shop |
| businessId | UUID | SHIPPER | the delivery business running it | your business |
| name | string | Yes | shown to buyers in the shares | max 80 |
| kind | enum | Yes | FREE, PERCENT, FIXED |
shipper: no FREE |
| value | number | PERCENT / FIXED | percent 1–100, or TZS off | none for FREE |
| cap | number | FREE / PERCENT for sellers | most paid per delivery | ≥ 1 |
| areas | array | No | buyer's destination areas (empty = everywhere) | ≤ 100 |
| fromAreas | array | No | shipper only: where the parcel starts | ≤ 100 |
| startsAt / endsAt | datetime | endsAt: NEXGATE | the period | end after start, in the future |
| minOrder | number | No | this shop's items at least | ≥ 0 |
| audience | enum | No | EVERYONE (default), FIRST_ORDER |
|
| shopIds | array | No | NexGate only: chosen shops | ≤ 500 |
| productIds / excludedProductIds | array | No | seller only: only these / never these | own shop's products |
| perBuyerLimit | integer | No | uses per buyer | ≥ 1 |
| spendLimit | number | No | seller only: stop after this much | ≥ 1 |
| budget | number | NEXGATE | moved from SHIPPING-CAMPAIGNS into the pot |
≥ 1 |
Success Response JSON Sample:
{
"success": true, "httpStatus": "OK", "message": "Campaign created", "action_time": "2026-10-24T10:30:45",
"data": {
"campaignId": "…", "creator": "NEXGATE", "name": "Dar 30 percent", "kind": "PERCENT", "value": 30, "cap": 3000,
"areas": [ { "level": "REGION", "regionCode": "07", "name": "Dar es Salaam", "label": "Dar es Salaam" } ],
"startsAt": "2026-10-24T10:30:45", "endsAt": "2026-11-02T23:59:00", "audience": "EVERYONE",
"state": "RUNNING",
"numbers": { "orders": 0, "buyers": 0, "givenAway": 0, "returned": 0 },
"pot": { "accountNumber": "POT-3dd27daf", "funded": 500000, "used": 0, "returned": 0, "left": 500000 },
"createdAt": "2026-10-24T10:30:45"
}
}
Success Response Fields:
| Field | Description |
|---|---|
state |
§1.9 |
numbers |
orders and buyers that used it; givenAway (net of refunds), returned |
pot |
NexGate only: funded, used, returned, left |
limit |
seller with a spending limit: { limit, used } |
endedAt |
when it ended / was stopped |
Empty fields are left out of the response.
Errors: CAMPAIGN_KIND_NOT_ALLOWED, CAMPAIGN_VALUE_INVALID, CAMPAIGN_CAP_REQUIRED, CAMPAIGN_CAP_ABOVE_MINIMUM,
CAMPAIGN_END_REQUIRED, CAMPAIGN_DATES_INVALID, CAMPAIGN_FIELD_NOT_ALLOWED (message says which field),
CAMPAIGN_BUDGET_REQUIRED, CAMPAIGN_BUDGET_LOW, SHOP_REQUIRED, BUSINESS_REQUIRED, NOT_YOUR_SHOP,
NOT_YOUR_BUSINESS, ADMIN_ONLY, AREA_NOT_FOUND, SHOP_NOT_FOUND, PRODUCT_NOT_IN_SHOP.
2. List one creator's campaigns
Endpoint: GET {base_url}/shipping/campaigns?creator=SELLER&shopId=…
Access Level: 🔒 Protected (same as create)
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| creator | enum | Yes | SELLER, SHIPPER, NEXGATE |
| shopId | UUID | SELLER | the shop |
| businessId | UUID | SHIPPER | the business |
Success Response: a list of campaigns (as create), newest first.
3. Get / edit one campaign
- GET
{base_url}/shipping/campaigns/{campaignId} - PUT
{base_url}/shipping/campaigns/{campaignId}— the whole campaign (same body as create;creatormust stay the same;budgetis ignored — use top-up)
Access Level: 🔒 Protected (whoever runs it)
Edits apply to new quotations only. Moving startsAt into the future makes it SCHEDULED again.
Errors: as create, plus CAMPAIGN_CLOSED, CAMPAIGN_CREATOR_FIXED.
4. Orders that used it
Endpoint: GET {base_url}/shipping/campaigns/{campaignId}/uses?page=1&size=20
Access Level: 🔒 Protected (whoever runs it)
Success Response data (a page, newest first):
{ "content": [ { "orderId": "…", "orderNumber": "ORD-2026-21597", "amount": 2100, "returned": 0, "at": "2026-10-24T11:02:10" } ],
"totalElements": 1, "totalPages": 1, "…": "…" }
orderId / orderNumber are null until the order exists (a group purchase only when the group fills).
5. Pause / resume / stop
| Endpoint | Does |
|---|---|
POST {base_url}/shipping/campaigns/{campaignId}/pause |
stops for new orders; paid orders keep it; idempotent |
POST {base_url}/shipping/campaigns/{campaignId}/resume |
back to RUNNING (or SCHEDULED); idempotent |
POST {base_url}/shipping/campaigns/{campaignId}/stop |
CANCELLED for good; a NexGate pot's leftover goes back to SHIPPING-CAMPAIGNS at once |
Access Level: 🔒 Protected (whoever runs it). Each answers with the campaign. Errors: CAMPAIGN_CLOSED.
6. Top up a NexGate pot
Endpoint: POST {base_url}/shipping/campaigns/{campaignId}/top-up
Access Level: 🔒 Protected (SUPER_ADMIN)
Request: { "amount": 200000 } — moved from SHIPPING-CAMPAIGNS into the pot; a RAN_OUT campaign runs again.
Audit-logged.
Errors: CAMPAIGN_HAS_NO_POT (seller / shipper campaigns), CAMPAIGN_CLOSED, CAMPAIGN_BUDGET_LOW.
7. Admin: every campaign
Endpoint: GET {base_url}/admin/shipping/campaigns?creator=&status=&page=1&size=20
Access Level: 🔒 Protected (STAFF_ADMIN, SUPER_ADMIN) — read only
Query Parameters: creator (SHIPPER / SELLER / NEXGATE), status (SCHEDULED, RUNNING, PAUSED,
ENDED, CANCELLED), page (from 1), size (≤ 100).
Success Response data:
{
"items": [ { "campaignId": "…", "creator": "SELLER", "name": "Free delivery over 50,000", "state": "RUNNING", "…": "…" } ],
"page": 1, "size": 20, "totalElements": 7, "totalPages": 1,
"totals": { "byState": { "RUNNING": 4, "ENDED": 2, "RAN_OUT": 1 }, "givenAway": 184000, "returned": 6300, "potsLeft": 412000 }
}
totals cover everything matching the filter (not just this page); potsLeft = money still in pots of campaigns not
closed.
8. Funding: NexGate system accounts — {base_url}/admin/finance/system-accounts
Access Level: 🔒 Protected (SUPER_ADMIN) — every move needs a reason and is audit-logged.
| Endpoint | Does |
|---|---|
GET …/system-accounts |
PLATFORM-REVENUE, SHIPPING-CAMPAIGNS, PROVIDER-BOLT, … with balances; revenue also shows putIn (owner top-ups) and transferredOut |
GET …/system-accounts/{accountNumber}/entries?page=1&size=20 |
history: { entryNumber, type, direction: IN | OUT, amount, other, description, metadata, at } |
POST …/system-accounts/top-up |
real money NexGate put in → PLATFORM-REVENUE: { "amount": 1000000, "reference": "<bank / M-Pesa id>", "reason": "…" } — record only when the money really arrived |
POST …/system-accounts/transfer |
{ "fromAccount": "PLATFORM-REVENUE", "toAccount": "SHIPPING-CAMPAIGNS", "amount": 500000, "reason": "…" } — the only allowed pair for now |
Errors: REFERENCE_ALREADY_USED (the same reference twice), TRANSFER_NOT_ALLOWED, INSUFFICIENT_BALANCE.
Nothing leaves SHIPPING-CAMPAIGNS except into a campaign pot; no account goes below zero.
Part 3 — Values and error codes
| Value | Options |
|---|---|
CampaignCreator |
SHIPPER, SELLER, NEXGATE (also the stacking order) |
CampaignKind |
FREE, PERCENT, FIXED |
CampaignAudience |
EVERYONE, FIRST_ORDER |
CampaignStatus (filter) |
SCHEDULED, RUNNING, PAUSED, ENDED, CANCELLED |
CampaignState (shown) |
the statuses + RAN_OUT |
| Code | When |
|---|---|
CAMPAIGN_KIND_NOT_ALLOWED |
a shipper asked for FREE |
CAMPAIGN_VALUE_INVALID |
FREE with a value, a percent outside 1–100, FIXED without an amount |
CAMPAIGN_CAP_REQUIRED |
a seller FREE / PERCENT without a cap |
CAMPAIGN_CAP_ABOVE_MINIMUM |
a seller's cap (or fixed amount) is bigger than the minimum order after commission |
CAMPAIGN_END_REQUIRED |
a NexGate campaign without an end date |
CAMPAIGN_DATES_INVALID |
end not after the start, or in the past |
CAMPAIGN_FIELD_NOT_ALLOWED |
a field this creator cannot use (from areas, shops, products, spending limit) |
CAMPAIGN_BUDGET_REQUIRED |
a NexGate campaign without a budget |
CAMPAIGN_BUDGET_LOW |
SHIPPING-CAMPAIGNS holds less than the budget / top-up (message says how much) |
CAMPAIGN_CLOSED |
editing, pausing, resuming or topping up an ended / cancelled campaign |
CAMPAIGN_CREATOR_FIXED |
changing creator on edit |
CAMPAIGN_HAS_NO_POT |
top-up on a seller / shipper campaign |
CAMPAIGN_ENDED |
at payment: a campaign ended, ran out or no longer fits since checkout (details = { shopId, businessId, businessName, before, after }) |
SHOP_REQUIRED / BUSINESS_REQUIRED |
seller / shipper campaign without its shop / business |
NOT_YOUR_SHOP / NOT_YOUR_BUSINESS / ADMIN_ONLY |
not allowed to run this creator's campaigns |
AREA_NOT_FOUND · SHOP_NOT_FOUND · PRODUCT_NOT_IN_SHOP |
a bad area, shop or product id |
REFERENCE_ALREADY_USED · TRANSFER_NOT_ALLOWED · INSUFFICIENT_BALANCE |
system accounts |
No comments to display
No comments to display