Migrating a till from a legacy VSDC gateway
What carries over unchanged, what comes back in a different shape, and which legacy calls have no destination here.
Migrating a till from a legacy VSDC gateway
If your point-of-sale already fiscalises through another Zambian VSDC gateway, this page is the whole delta: what carries over untouched, what returns a different shape, what is refused, and which of your current calls have no destination here.
Everything below describes what the server does today. Where a row says a request is refused, there is a test asserting it is refused.
1. What carries over unchanged
Authentication. The same two headers, with the same meanings:
X-Api-Key: pk_live_… the branch fiscalisation key
X-SdcId: SDC0010000123 that branch's SDC id
Terminal-Id is accepted as a fallback for X-SdcId, so a till that sends
that name instead keeps working. There is no bearer token and no request
signing on this surface.
Your key may be either of two shapes, and this matters at install time. A
branch key is bound to one device: it accepts that branch's X-SdcId and no
other. A tenant key accepts any branch of the same taxpayer, so one credential
covers the whole business and the X-SdcId is what picks the till's branch —
the arrangement a setup screen that asks for a URL, a key and a branch assumes.
Either way the SDC id must name a branch the key may file through; one that does
not is answered 401 rather than quietly resolved to a default. Ask
GET /zm/api/Branches which ids your key accepts.
Request field names. If your till speaks the modern /zm/api/Invoice
dialect, the request body needs no renaming. The mixed casing is deliberate and
matches the legacy contract exactly — InvoiceNumber, IssuerName, IssuerId,
ReceiptTypeCode, PaymentTypeCode, currencyType, conversionRate,
SaleDate, CustomerName, CustomerTpin, OriginalInvoiceNumber, sdcId,
invoiceItems[], and inside each item ItemSequenceNumber, ItemDesc,
itemCode, itemClassificationCode, TaxCodes[], Quantity, UnitPrice,
isTaxInclusive, PackagingUnitCode, QuantityUnitCode.
Some rules are looser here. SaleDate and sdcId are optional rather than
required, ItemSequenceNumber is optional (line order is used), tax code
letters are case-insensitive, and PaymentTypeCode accepts ZRA's full table
01–08 including 03 and 08. A compact yyyyMMddHHmmss SaleDate parses
correctly.
One rule is tighter. CustomerTpin must be exactly ten digits when it is
sent, and CustomerName must come with it. Both fields stay optional — a
walk-in cash sale sends neither — but the field is no longer free text. See
§3 for what to do if you have been putting a customer reference in it.
Re-sending an invoice is safe. InvoiceNumber is the idempotency key: a
repeat of one already fiscalised returns the original fiscal details rather
than filing a second invoice.
2. Responses come back bare — read this first
This is the change that breaks a till on its first call, and it is not a field rename. Many legacy gateways wrap every reply in a success envelope. This one does not.
// Legacy gateway // ZRA Gateway
{ {
"success": true, "invoiceNumber": "INV-1042",
"message": "", "signature": "PGRWGD65",
"data": { "qrCode": "https://…",
"signature": "PGRWGD65", "internalData": "…",
"qrCode": "https://…" …
} }
}
Every field your receipt printer needs still exists, under the same name, one level up. So:
response.data.signaturebecomesresponse.signature.- There is no
successflag. Branch on the HTTP status code instead. - There is no
messageon a successful call.
If you cannot change the parser, ask for the old shape
Un-nesting is the better end state and the rest of this page assumes you did it. But if the till is someone else's binary, or the change cannot be scheduled before your ZRA deadline, the envelope is available as a property of the key:
Developers → Till keys → Mint key → Response format → Wrapped, or
"dialect": "wrapped"onPOST /api/v1/tenants/{tenant}/api-keys.
Every reply to that key is then wrapped the way you already expect:
// success // failure
{ {
"success": true, "success": false,
"message": "", "httpCode": 422,
"data": { "data": null,
"signature": "PGRWGD65", "errorCode": "913",
"qrCode": "https://…", "status": 422,
… "message": "Fiscalisation failed.",
} "resultCode": "913",
} "resultMessage": "…"
}
Three things to know about it:
- The envelope adds; it never rewrites. Every field a bare error carries is
still there underneath, so §4 below reads the same in either shape.
errorCodeis simply ZRA'sresultCodeunder the name the legacy contract uses. - The HTTP status code does not change. A wrapped failure is still a 4xx.
Keep branching on status; treat
successas a convenience, not the signal. - It is per key, not per request. There is no header to send and nothing to
negotiate — mint the key in the shape the till speaks. Existing keys are all
bareand are unaffected.
One edge worth handling: a request whose key is missing or unrecognised is
refused bare, because there is no key yet to have asked for anything. The
status is still 401, and an absent success is falsy, so a wrapped-mode till
that branches on status handles it correctly without a special case.
Two casing and type notes: the SDC id comes back as sdcId (not SdcId), and
invoiceSequence is an integer, not a string.
taxItems[] carries taxCode, rate, taxableAmount and taxAmount. Lines
that are entirely zero are omitted from it.
A 200 does not always mean fiscalised
If ZRA's VSDC is unreachable the sale is still accepted, queued, and answered 200 — the gateway files it when the connection returns, in invoice order. That reply is degraded, and a till that prints it as a fiscal receipt prints one with no signature on it:
| Field | Queued offline |
|---|---|
signature, qrCode, internalData |
null |
vsdcdate, sdcId |
null |
taxPayerInvoiceNumber |
"" |
receiptTotalCounter, normalReceiptTypeCounter |
0 |
Test signature === null. Mark that receipt PROVISIONAL and reprint it
once the document uploads — GET /zm/api/Invoice/{your invoice number} returns
the same payload with the fields filled in.
3. Requests that will be refused
Each row is enforced by the server. "Silently ignored" means the call still returns 200 — which is the dangerous kind.
| Your legacy behaviour | What happens here | What to do |
|---|---|---|
currencyType of ZMW, USD, GBP, EUR, CNY or ZAR |
Honoured. The currency and its rate are filed with ZRA and printed on the invoice | Send the currency you actually invoiced in, with its conversionRate. This was ZMW-only until 2026-09-05; if you were converting to kwacha yourself, you can stop. |
currencyType outside those six — e.g. JPY |
422 | ZRA publishes 179 currencies but Smart Invoice accepts six. Invoice in one of them. |
conversionRate on a non-ZMW invoice |
Required, must be greater than zero and not 1 |
Send how many kwacha one unit of the currency buys, e.g. 26.5 for USD. ZRA refuses a foreign currency at a rate of 1. |
conversionRate other than 1 on a ZMW invoice |
422 | Send 1, or omit it. ZMW is the base currency, so there is nothing to convert. |
ReceiptTypeCode of R or D |
422 — only S is supported on this surface |
Credit and debit notes go through POST /api/v1/sales/{sale}/credit-note and POST /api/v1/sales/{sale}/debit-note on the v1 surface, which need a tenant token rather than a branch key. |
Tax labels outside A B C C1 C2 C3 D E RVAT — e.g. F, TOT, TL, IPL1, IPL2, ECM, EXEEG |
422 | Map them before sending. An unknown code is refused rather than defaulted, because defaulting it would silently zero-rate standard-rated goods. Bare C is accepted and treated as C3. |
destinationCountryCode on a C1 (Exports) invoice |
Honoured and required. The country is filed with ZRA and printed on the invoice | Send the two-letter ISO country code the goods went to. This was silently ignored until 2026-09-06; an export invoice without it now returns 422, and a C1 invoice may carry no other tax category. |
destinationCountryCode on an invoice that is not C1 |
422 | A domestic sale has no export destination. Remove it, or mark the items C1. |
localPurchaseOrder |
Silently ignored — the field is lpoNumber here. On a C2 (Zero-rating LPO) invoice the call then fails with "lpo required" even though you sent one |
Rename the key to lpoNumber. |
cashDiscountRate, cashDiscountAmount, or item-level DiscountAmount |
Silently ignored — the invoice fiscalises at the undiscounted Quantity × UnitPrice and returns 200. You would over-declare to ZRA |
Apply the discount to UnitPrice yourself before sending. |
More than one entry in a line's TaxCodes[] |
Only the first is applied | One tax code per line. |
| An XML request body | Unsupported | JSON only. |
itemCode longer than 50 characters, or itemClassificationCode longer than 20 |
422 | Trim. These are uncapped on some legacy gateways. |
CustomerTpin that is not exactly ten digits — a customer account number, a name, a 14-character reference |
422 | Send ten digits, or omit the field. This was free text of up to 14 characters until 2026-09-08, so "NOT A TPIN" was accepted and printed on the tax invoice in the customer-TPIN box. |
CustomerTpin sent without CustomerName |
422 | Send the name the TPIN is registered to. |
Neither CustomerTpin nor CustomerName — an ordinary cash sale |
Accepted, and filed as Walk-in Customer with no TPIN |
Nothing. This has not changed, and is not going to. |
A SaleDate more than 180 days old |
422 | ZRA will not accept it under its own date. |
Fields accepted by the legacy contract that this surface does not read at all —
they are ignored without error, so do not rely on them: CustomerMobileNumber,
refundReasonCode, RefundReason, Remark, item TotalAmount, Barcode and
rrp.
destinationCountryCode was on that list until 2026-09-06 and no longer is —
it is now read, required on exports and refused elsewhere. If your till has
been sending it all along, it will start taking effect; check that what you send
is the destination you mean.
The customer TPIN became a real TPIN on 2026-09-08
This is a tightening of validation, and it is the one change on this page that can 422 a call that worked yesterday. It is listed as a breaking change with notice for that reason — but it is a narrow one, because what it stops was never a working integration:
- The field was free text of up to 14 characters. Anything fitted, and
whatever you sent was printed on the tax invoice in the box labelled as the
customer's TPIN. A tax invoice that states a taxpayer identification number
that is not one is a defective document; the same field on the web till and on
/api/v1has always been ten digits. - A missing name was filled in for you. If you sent
CustomerTpinwith noCustomerName, this gateway substituted the literalWalk-in Customerand filed the invoice against that TPIN under that name. ZRA accepted it — a000— so nothing ever told you. The VSDC's own rule is "Customer name is mandatory when custTpin is provided"; we now apply it at the edge, so you get a 422 before an invoice number is consumed instead of a wrong filing after.
If you send a 14-character customer reference in CustomerTpin — an
account number, a loyalty id, an NRC — those calls now fail. There is no
field on this surface that will carry it: CustomerMobileNumber and Remark
are read by nothing, so putting it there only moves the silence. Keep it in your
own records against your own InvoiceNumber, which is the key both systems
already share, and send CustomerTpin only when you hold the customer's real
ten-digit ZRA TPIN.
4. Status codes you may not have seen before
| Code | When | Body |
|---|---|---|
401 |
Missing, unknown or mismatched X-Api-Key / X-SdcId |
{status, message} |
402 |
Subscription inactive, or the plan's invoice quota is exhausted. Nothing was fiscalised | {status, message} — plus an error field when it comes from the quota check, and without one when it comes from the tenant check. Branch on the status, not on error |
403 |
The key is valid but your IP is outside its allowlist, or it lacks the invoices:write scope — which is required for the read endpoints here too |
{status, message} |
429 |
Rate limit. The ceiling comes from the merchant's plan — Free Trial 60/min, Starter 120, Growth 300, Enterprise 600, falling back to 300 on /zm/api when no plan value is set. It is not a flat 300 |
Retry-After and X-RateLimit-* headers |
422 arrives in three shapes. Branch on the status code first, then tell
them apart by which keys are present:
| Cause | Body |
|---|---|
A field failed validation — wrong currency, bad tax code, over-long itemCode |
{"message": "...", "errors": {"field": ["..."]}} — Laravel's shape, with no status key |
ReceiptTypeCode other than S |
{"status": 422, "message": "..."} |
| ZRA rejected the document, or an LPO/device rule refused it | {"status": 422, "message": "Fiscalisation failed.", "error": "..."} — plus resultCode and resultMessage only when ZRA itself refused |
So errors present means your payload failed our validation; resultCode
present means it reached ZRA and ZRA said no. Neither present means it failed a
rule in between.
5. Endpoint coverage
Five of the legacy endpoints are served here, plus one that is in no legacy
contract and is worth wiring first — see below the table. The rest return a
plain 404.
| Legacy call | Here | Your move |
|---|---|---|
POST /zm/api/Invoice |
Served | Apply §2 and §3 |
GET /zm/api/Invoice/{clientInvoicenumber} |
Served, but it returns the same fiscal-details payload as the POST — not the full invoice document, and no line items or upload status | Reconcile from your own records, or subscribe to the invoice.fiscalised webhook |
GET /zm/api/Signature/{invoiceNumber} |
Served — identical payload to the line above | — |
GET /zm/api/TaxCodes |
Served, as a bare array. Fields are taxCode, categoryName, rate, taxableAmount, taxAmount, conversionRate. Every code it lists is accepted by POST /zm/api/Invoice at the rate quoted; TOT is not listed, because a VAT gateway cannot file it |
Update the parser if you read taxName / taxRate |
POST /api/InvoiceSign |
404. This is the older dialect — Cashier, Items, PosSerialNumber, terminalId, Currency-Type — and it shares no field names with POST /zm/api/Invoice |
Rewrite the payload against §1 and §3; it is a genuine rewrite, not a rename |
POST /api/InvoiceRetrieve |
404 | Use GET /zm/api/Invoice/{n} — different verb, path and response shape |
POST /api/InvoiceReturn |
404 — no credit notes on this surface yet | POST /api/v1/sales/{sale}/credit-note on the v1 surface |
POST /api/ApiKey/getactive |
Served — same request body, same response shape. The credential is a till login, not the merchant's dashboard password: see below | Create a till login against the key, then point your setup screen here unchanged |
GET /zm/api/Import/pending, PUT /zm/api/Import/{id} |
404 | No equivalent — see below |
POST /zm/api/Purchase |
404 | No equivalent — see below |
POST /zm/api/Stock, POST /zm/api/Stock/batch |
404 | No equivalent — see below |
POST /api/ApiKey/getactive — served, with a different credential
Your setup screen can stay as it is. Same path, same {email, password} body,
same reply:
{
"success": true,
"message": "Data retrieval successful.",
"data": {
"apiKeyName": "AIDEPOS",
"apiKeyValue": "pk_live_…",
"branchInfo": [
{ "sdcId": "SDC0010000000", "branchName": "Head Office" },
{ "sdcId": "SDC0010000001", "branchName": "Kabwe" }
],
"isValid": true, "edits": 0, "deactivatedOn": null, …
}
}
What changes is which email and password. On the gateway you are coming from, these are the account's own — the same credentials that open the taxpayer's portal, which is why the key they hand back can be re-derived by anyone holding them. Here they are a till login: created against one key, worth that key and nothing else, and withdrawn without touching the key itself. A merchant's dashboard password is matched by nothing on this path.
The merchant creates one under Developers → Till keys → Till login. Give your installer that address and password instead of the portal ones.
Three behaviours to code against:
- Every failure is a
400with the same body — a wrong password and an address nobody registered are indistinguishable on purpose, so this endpoint cannot be used to discover who banks here. Branch onsuccess, and do not try to tell the two apart. - It is throttled per address and per caller, five attempts per fifteen
minutes. A sixth is
429even with the right password; the limit is on the account, not on the guess. branchInfois deduplicated. One row per branch, which may differ from what you are used to.
Everything else on this page still applies — this call sets the till up, it does not change how the till files.
GET /zm/api/Branches — the one addition
It takes the X-Api-Key alone. No X-SdcId, because this is the call that
tells you what your X-SdcId is:
[
{ "branch": "000", "name": "Head Office", "tpin": "1002101584",
"sdcId": "SDC0060000514", "initialized": true, "lastInvoiceNo": 127 },
{ "branch": "001", "name": "Kabwe", "tpin": "1002101584",
"sdcId": "SDC0060000515", "initialized": false, "lastInvoiceNo": 0 }
]
A tenant key lists every branch of its taxpayer; a branch key lists the one it
is bound to. Either way, store the sdcId of the branch the till is installed
at and send it on every later call.
Check initialized before you offer a branch in a picker. A branch that
has not finished device initialisation with ZRA holds no signing keys, so a sale
routed through it fails at the VSDC — late, and with a message about the device
rather than about the setup screen that let it be chosen.
If your setup flow currently has the installer type an SDC id in by hand, this
is the call that replaces it, and tpin is worth showing back to them: it is
the cheapest confirmation that the key they pasted belongs to the business they
think it does.
Purchase, stock and import reporting are not served on this compatibility surface, and are not planned for it. The four routes marked Served above are the whole of it, and that is a settled decision rather than a gap waiting to be filled — so the "404" in those rows will still be a 404 after the capability itself ships.
Note the scoping, because it changed: this is a statement about /zm/api, not
about the platform. Purchase, stock and import ledgers are being built, and they
will live behind the merchant dashboard and the /api/v1 surface. If a till of
yours posts that traffic through a legacy path today, talk to us before you
migrate rather than after — the destination exists, it is simply not this
door.
6. Migration checklist
- Keep your
POST /api/ApiKey/getactivesetup screen as it is, and have the merchant give your installer a till login rather than their dashboard password. Or, if you would rather not hold a password at all, fill the branch picker fromGET /zm/api/Brancheswith the key alone — either way, refuse a branch whoseinitializedis false. - Un-nest every response — delete the
.datahop and stop readingsuccess. (Or, if you cannot, mint the key with the wrapped response format and skip this step — see §2.) - Rename
localPurchaseOrdertolpoNumber. - Send the currency you invoiced in (
ZMW,USD,GBP,EUR,CNY,ZAR) with itsconversionRate—1for ZMW, the real rate otherwise. - Map your tax labels onto
A B C C1 C2 C3 D E RVAT. - On export (
C1) invoices, senddestinationCountryCode— it used to be ignored and is now required, and a C1 invoice may carry no other tax code. - Move discounts into
UnitPrice— the discount fields are ignored. - Stop sending anything but a real ten-digit TPIN in
CustomerTpin, and sendCustomerNamewith it whenever you do — both are now refused otherwise. Omit both on a walk-in sale. - Handle
401,402,403and429, and both422shapes. - Point any Purchase / Stock / Import traffic somewhere else.
- Fiscalise one
ReceiptTypeCode: Ssale end to end and print the receipt before switching production traffic over.
A request that works
Copy this, change the headers, and it will fiscalise.
{
"InvoiceNumber": "INV-1042",
"IssuerName": "Katema Stores Ltd",
"IssuerId": "1002101584",
"ReceiptTypeCode": "S",
"PaymentTypeCode": "01",
"currencyType": "ZMW",
"conversionRate": 1,
"CustomerName": "Walk-in Customer",
"invoiceItems": [
{
"ItemSequenceNumber": 1,
"ItemDesc": "Maize meal 25kg",
"itemCode": "MM-25",
"itemClassificationCode": "50221204",
"TaxCodes": ["A"],
"Quantity": 2,
"UnitPrice": 350
}
]
}
See the API reference for every field, and ZRA result codes for what to do when ZRA rejects a sale.