An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework

Payments: quotes, ZamPay and one finalisation path

Money is where integrations are attacked first. In this lesson you follow a payment from the quote to the receipt, learn why there is exactly one way for an invoice to become paid, and practise the rules that stop double payments, wrong amounts and replays.

The path of a payment

quote (server) -> invoice intent -> ZamPay invoice -> citizen pays on ZamPay
      -> callback / poll / sweep -> finaliser asks ZamPay -> paid once -> receipt, case advances, events

ZamPay (DotGovPay, reached through the GSB ZamConnect gateway) takes the money. The platform decides how much is owed, raises the invoice, and records the payment when ZamPay confirms it.

Step 1: the quote is the server's

The amount never comes from the browser. A service's fee comes from its fee configuration. A module that prices something itself answers the payment.quote extension point:

// a module's contribution (sketch): price a case from the platform's own data, never from the form
contributions['payment.quote'] = async ({ kind, case: c, service, params, transaction }) => {
  if (!isMine(service)) return undefined;              // not my business: core uses the service's fee
  const unit = await loadUnitForCase(c, { transaction });
  if (!unit || unit.rent == null) throw httpError(409, 'UNIT_NOT_PRICED');
  return { amount: unit.rent, lines: [{ title: 'Monthly rent', amount: unit.rent }] };
};

Shops, pay-points and POS sales likewise take prices from the platform's product lists and tariffs.

Step 2: the invoice

The platform describes the invoice once (an invoice intent) and the ZamPay adapter turns it into ZamPay's request. Three protections live here:

  • a unique external reference per invoice, <case number>/<12 hex>, so a retried request cannot quietly create a

    second invoice;

  • idempotent creation: asking again for the same fee returns the open invoice of the same amount, as long as it has

    at least a day left;

  • one payable invoice per fee: an expired or changed invoice is cancelled at ZamPay before a new one is raised.

Step 3: checkout and the callback

The citizen pays on ZamPay's pages. ZamPay then calls POST /api/zampay/callback through ZamConnect. ZamPay does not sign callbacks, so the platform authenticates the caller (HTTP Basic or a Registration Authority JWT), records each callback in a replay table, and treats it only as a prompt: the finaliser asks ZamPay what really happened.

Callbacks get lost, so a sweep asks ZamPay about pending invoices every 10 minutes, and a nightly reconciliation compares records with ZamPay's completed list.

Step 4: one finaliser

Every path to "paid" goes through the payment finaliser: the citizen's poll, the POS poll, the callback, the sweep and a finance officer's manual check. It:

  1. checks the invoice belongs to the named case and that the caller may see the case;
  2. asks ZamPay and reads the receipt;
  3. compares the amount settled with the amount invoiced (short = not paid; over = flagged);
  4. in one transaction, with the payment and case rows locked, applies the side effects **only on the first transition

    to paid**: receipt, module effects through payment.onFinalised, case status;

  5. after commit, writes one audit row and sends one receipt notice.

Call it twice and the second call just reports the state. Two concurrent polls cannot both apply side effects.

Manual validation needs a finance role and, by default, a second approver. Money collected twice (online and at the counter) becomes a collected_twice exception for a refund.

Pay-points, POS and receipts

  • Pay-points are QR codes for small fixed charges. The portal's generic /pay/<token> page uses

    /api/pay-points/{ref}, /order and /ticket. One browser session gets one ticket invoice, even when two orders race. The owning module prices it and is told when it is paid; it never marks anything paid.

  • POS terminals sell through /api/pos; mobile money goes through ZamPay and the same finaliser. Cash taken

    offline is synchronised later and reconciled by a supervisor.

  • Receipts are checked at GET /api/receipts/verify/{ref}?sig=...; anything but a genuine receipt of a paid case

    gets the same 404 RECEIPT_NOT_FOUND.

Across the federation

A service on a ministry node is paid to the node's own ZamPay merchant; the callback goes to the node, which finalises. The hub never holds the node's ZamPay credentials. The node tells the hub with signed payment.invoiced, payment.settled and payment.refunded events. "Pay" on the national portal asks the node for a signed 15-minute pay link bound to the citizen. If the node is down, the hub shows the last known state and says so; nothing is written.

Modules switched off

Switching a module off never loses money. payment.onFinalised is a bypass point: a payment that arrives for a module switched off since is still finalised by that module, and that run is audited.

Testing with the ZamPay stub

Never test against real ZamPay. The platform's test doubles include a contract-faithful ZamPay stub with a control API. In an integration test you can play the payer:

curl -s http://localhost:5199/__control/invoices                 # invoices the platform raised
curl -s -X POST "http://localhost:5199/__control/invoices/$INV/pay" \
  -H "Content-Type: application/json" -d '{"outcome":"success","callback":true}'
curl -s -X POST "http://localhost:5199/__control/invoices/$INV/callback"   # send the callback again: a replay

The abuse cases every money change must cover:

CaseWhat the test proves
Double paymentA second poll or callback after "paid" changes nothing; money taken twice is flagged
Wrong amountA short payment is not paid
ReplayA repeated callback is a duplicate, with no new side effect or event
RaceTwo concurrent finalisations apply side effects once

Exercise

In a development stack, raise an invoice for a sandbox case, pay it through the stub with callback: false, and wait for the sweep (or poll the status endpoint). Then send the callback twice. Count the receipts, audit rows and payment.confirmed events: there should be one of each.

Check your understanding

  • Where does the amount of an invoice come from, and where must it never come from?
  • Why does the platform ask ZamPay again after a callback?
  • What does the finaliser do on the first transition to paid, and what does a second call do?
  • How does the platform keep only one invoice payable for a fee?
  • On a federated deployment, which merchant receives the money for a node's service?
  • Name the four abuse cases a money change must be tested against.

Sign in to record your progress.

Previous: Signing people in: Pamodzi Pass and ZamPass Next: Going to production