Skip to main content

Shipping Campaigns 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: 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.total everywhere stays the shipper's table price. What the buyer pays is offer.buyerPays; show cost.total buyerPays 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

  1. Order: shipper discount → seller → NexGate.
  2. The shipper's discount sets the real price (price); every other share is worked out from that full price, never from "what is left".
  3. 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 / PERCENT campaign 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 budget from SHIPPING-CAMPAIGNS into its pot (CAMPAIGN_BUDGET_LOW if 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-CAMPAIGNS at 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; creator must stay the same; budget is 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