On this page
- The flow in one picture
- 1. The amount is worked out on the server
- 2. The invoice
- 3. Checkout and callbacks
- 4. One finalisation path
- 5. Status polling
- 6. Pay-points
- 7. POS terminals
- 8. Receipts and their verification
- 9. Payments across the federation
- 10. Telling partner systems
- Known ZamPay defects and the platform's controls
- Test doubles for development
- Rules for every integrator
Payments with ZamPay
Government fees on the platform are collected through ZamPay, the national payment gateway (DotGovPay behind the GSB ZamConnect gateway). ZamPay takes the money by mobile money, card and other channels; the platform works out what is owed, raises the invoice, and records the payment once ZamPay confirms it.
This guide is for developers who build modules, services or partner systems that touch money. The rules at the end apply to everyone.
The flow in one picture
quote (server) ──► invoice intent ──► ZamPay invoice ──► citizen pays on ZamPay ──► callback / poll / sweep
│
one finaliser: ask ZamPay again ◄┘
│
paid once: receipt, case advances, module told (payment.onFinalised), events, notice
1. The amount is worked out on the server
What a citizen owes is never taken from the browser or the form.
- A service's fee comes from the service's fee configuration (fees designed in the back office).
- A module may price a case itself through the
payment.quoteextension point. It receives{ kind, case?, service?, params, user, transaction }and answers{ amount, lines? }, orundefinedwhen the case is not its business. For example a rentals module prices a lease from the officer's confirmed fee or the unit's rent on the property register, never from the rent the applicant typed, and refuses a unit that is unpriced or already let. - Officer-computed fees can need finance approval first, and fees approved in instalments are invoiced part by
part.
- A shop, a pay-point or a POS sale takes the price from the platform's own product list or tariff, never from the
client.
2. The invoice
The platform describes the invoice once, in its own words (an invoice intent: purpose, external reference, customer, lines with revenue codes, amount, currency, expiry, return address), and the ZamPay adapter turns it into ZamPay's request. That keeps one place to change when the gateway's contract changes.
- Each invoice has a unique external reference,
<case number>/<12 hex of the intent>, so a retried request cannotcreate a second payable invoice unnoticed.
- Creating an invoice is idempotent: asking again for the same case fee returns the open invoice of the same
amount. An open invoice is handed out again only while it has at least a day left; otherwise it is superseded.
- An expired or changed invoice is checked with ZamPay and cancelled there before a new one is raised, so only one
invoice for a fee is ever payable.
- Creating an invoice is a
POST(never aGETa link prefetcher could repeat).
3. Checkout and callbacks
The citizen pays on ZamPay's own pages (the platform links to the invoice's pay page) and returns to the platform's return address. ZamPay then tells the platform, through ZamConnect, with a callback to POST /api/zampay/callback.
ZamPay does not sign its callbacks. So the platform:
- authenticates the caller (
ZAMPAY_CALLBACK_AUTH: HTTP Basic from the ZamConnect relay, a Registration Authority JWTfor direct callbacks, or either during the change-over), optionally with an address allow-list;
- records each callback in a replay table keyed on the transaction reference and status, so a repeated callback is a
duplicate;
- treats the callback as a prompt, not proof: it only wakes the finaliser, which asks ZamPay itself.
Callbacks get lost. A sweep runs every 10 minutes and asks ZamPay about every pending invoice; a nightly reconciliation compares the platform's records with ZamPay's completed list. An alert fires when the sweep confirms payments while no callbacks arrive.
4. One finalisation path
Every way an invoice can become paid goes through one function, the payment finaliser: the citizen's status poll, the POS poll, the ZamPay callback, the sweep, and a finance officer's manual check. It:
- finds the invoice and checks that it belongs to the case the caller named, and that the caller may see that case;
- asks ZamPay through the payment gateway port; nothing a client says is trusted. When ZamPay says paid, it reads
the receipt so the amount check uses the amount ZamPay settled;
- compares the amount: a short payment is not paid (and is flagged); an overpayment is flagged;
- in one transaction, with the payment and case rows locked (
SELECT ... FOR UPDATE), applies the side effectsonly on the first transition to paid: payment date, receipt row, the module's own effects (stock, rent ledger) through
payment.onFinalised, and the case status. The case advances only if it is on a payment step and everything it owes is settled; - after commit, writes one audit row and sends one receipt notice.
Calling it again is a no-op that reports the current state. Two polls at the same moment cannot both apply the side effects.
Manual validation (finance confirms a payment by hand) is limited to finance roles and needs a second approver by default (PAYMENT_MANUAL_VALIDATION_TWO_PERSON). Money taken twice (online and at the counter) is recorded as a collected_twice exception for a refund, which finance arranges with a second officer's approval.
Switching a module off never loses money. payment.onFinalised is a bypass extension point: a payment that arrives for a module switched off since is still finalised by that module, and the run is audited.
5. Status polling
Pages that wait for a payment poll:
GET /api/zampay/checkPaymentStatus/{invoiceNumber}for a signed-in applicant (own case) or staff;GET /api/zampay/checkPaymentStatusNoAuth/{invoiceNumber}for the shop, rent and POS pages. It answers only{ invoiceNumber, paid, status }and is limited per address and invoice (30 a minute by default) and per address.
Both go through the finaliser. Poll every few seconds while the citizen is on the page, not in a loop.
6. Pay-points
A pay-point is a QR code printed where people pay a small fixed charge (for example a market or a gate). Its code opens the portal's generic page /pay/<token>, backed by public endpoints under /api/pay-points:
GET /api/pay-points/{ref}: what the point sells and the price (set on the server by the module that owns it);POST /api/pay-points/{ref}/order: raises (or hands out again) the browser session's ticket invoice;GET /api/pay-points/{ref}/ticket: the ticket, paid only when the finaliser recorded it, with a receipt check.
Every unknown or unavailable code gets the same 404 POINT_UNAVAILABLE. The browser session is a random id in an HttpOnly cookie; two orders from one session at the same moment raise one invoice, not two. A module answers the payment.pointDescribe, payment.pointOrder and payment.pointTicket extension points and never marks anything paid itself.
7. POS terminals
The POS app sells services and products at the counter through /api/pos. Terminals are enrolled devices with their own keys; sales and case actions use the same roles as the back office. Mobile-money sales go through ZamPay and the same finaliser. Cash taken while a terminal is offline is queued on the terminal and synchronised later as offline cash receipts, which a supervisor reconciles and reviews. Stock is deducted once, under a case lock, never below zero.
8. Receipts and their verification
Each finalised payment gets a receipt with a verification reference and signature. Anyone can check one:
GET /api/receipts/verify/{ref}?sig=...
A paid case's receipt with a valid signature answers 200 { valid: true, ... }; anything else gets the same 404 { valid: false, code: "RECEIPT_NOT_FOUND" }, so the endpoint reveals nothing. It is limited per address (30 checks per 10 minutes by default). See Document verification.
9. Payments across the federation
When a service lives on a ministry node:
- the invoice is raised on the node's own ZamPay merchant, and ZamPay's callback goes to the node, which finalises
exactly as above. The hub never holds a node's ZamPay credentials;
- the node tells the hub with signed
payment.invoiced,payment.settledandpayment.refundedevents, applied onceeach and in order;
- the citizen's "My payments" on the national portal merges local and remote rows; open remote invoices are refreshed
live, and the rest are marked as last known (
stale) when a node does not answer; - "Pay" on the hub asks the node for a signed, 15-minute pay link bound to that citizen, which redirects to the node's
ZamPay checkout (
409 ALREADY_PAID,410 INVOICE_CLOSEDand503 NODE_UNAVAILABLEare possible, and nothing is written then); - a node's receipt reference starts with the node's key, and the hub relays the check to that node.
10. Telling partner systems
Partner systems subscribed to webhooks receive payment.invoiced and payment.confirmed for applications in their MDA scope (identifiers only). See Webhooks and events. The USSD gateway client can read an application's invoice status for the applicant's own phone number (POST /api/v1/ussd/cases/lookup).
Known ZamPay defects and the platform's controls
A review of ZamPay's source found defects that affect money; they are reported to its maintainers. The platform compensates on its side, for example:
| Defect | Platform control |
|---|---|
| A forged card "accepted" result can mark an invoice paid | Settlement is read back from ZamPay's reports; a paid invoice not settled in time raises a locked alert |
| A mobile-money retry can debit twice | A second successful transaction on one invoice is a duplicate_debit exception for a refund |
| Status polling gaps on some channels | The platform's own 10-minute sweep |
| No idempotent invoice creation | Unique external references and invoice intents |
| Callbacks unsigned | Callback authentication, replay table, and the finaliser always asking ZamPay |
| Lazy expiry | The platform tracks ZamPay's real expiry and reuses an invoice only with a day left |
Test doubles for development
Never call the real ZamPay, SMS or signing services from tests, demos or screenshots. The platform ships contract-faithful test doubles in zcm-backend/test/fixtures/providers/; start them with npm run stack:providers. The ZamPay stub (port 5199 by default) issues invoices, serves a pay page, sends receipt callbacks and has a control API so a test can play the payer and the operator:
# list invoices the platform raised, then pay one successfully (with a callback)
curl -s http://localhost:5199/__control/invoices
INV=... # an invoice number from that list (16 digits)
curl -s -X POST "http://localhost:5199/__control/invoices/$INV/pay" \
-H "Content-Type: application/json" -d '{"outcome":"success","callback":true}'
# make the next call fail, or move the stub's clock for expiry tests
curl -s -X POST http://localhost:5199/__control/config -H "Content-Type: application/json" \
-d '{"failNext":{"path":"/t/govzm/pay/status","status":503,"count":1}}'
curl -s -X POST http://localhost:5199/__control/clock -H "Content-Type: application/json" -d '{"offsetMs":172800000}'
Other control calls: .../lock, .../fail, .../expire, .../settle, .../callback, GET /__control/callbacks, GET /__control/calls and POST /__control/reset. The shared test deployment uses the real GSB test tenant of ZamPay, never production.
Rules for every integrator
- Never trust an amount from the browser or any client. Work it out on the server (
payment.quote, feeconfiguration, product list) and compare what ZamPay settled with what was invoiced.
- Never mark anything paid yourself. Call the finaliser, or answer
payment.onFinalisedwhen it tells you. - Be idempotent. Use unique external references; retries must return the same invoice, not a second one; process
each callback and event once.
- Prevent double payment. Keep one payable invoice per fee; cancel at ZamPay before superseding; record money
taken twice as an exception for a refund, never silently.
- Refuse replays. A repeated callback, event or request must not repeat a side effect.
- Expect races. Lock the payment and case rows when you change them, and test two concurrent requests.
- Test the abuse cases. Every money change ships with integration tests proving that a double payment, a wrong
amount, a replay and a race are refused, and that the legitimate path works.
- Keep personal data out of the gateway. Send ZamPay no recipient e-mail or user id it does not need, and never
put invoice or personal data in URLs you build.
- Switching a module off must never lose money: payments already started still finalise.
Related: Webhooks and events, Document verification.