Documentation

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.signature becomes response.signature.
  • There is no success flag. Branch on the HTTP status code instead.
  • There is no message on 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" on POST /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. errorCode is simply ZRA's resultCode under 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 success as 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 bare and 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/v1 has always been ten digits.
  • A missing name was filled in for you. If you sent CustomerTpin with no CustomerName, this gateway substituted the literal Walk-in Customer and filed the invoice against that TPIN under that name. ZRA accepted it — a 000 — 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 400 with 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 on success, 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 429 even with the right password; the limit is on the account, not on the guess.
  • branchInfo is 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

  1. Keep your POST /api/ApiKey/getactive setup 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 from GET /zm/api/Branches with the key alone — either way, refuse a branch whose initialized is false.
  2. Un-nest every response — delete the .data hop and stop reading success. (Or, if you cannot, mint the key with the wrapped response format and skip this step — see §2.)
  3. Rename localPurchaseOrder to lpoNumber.
  4. Send the currency you invoiced in (ZMW, USD, GBP, EUR, CNY, ZAR) with its conversionRate — 1 for ZMW, the real rate otherwise.
  5. Map your tax labels onto A B C C1 C2 C3 D E RVAT.
  6. On export (C1) invoices, send destinationCountryCode — it used to be ignored and is now required, and a C1 invoice may carry no other tax code.
  7. Move discounts into UnitPrice — the discount fields are ignored.
  8. Stop sending anything but a real ten-digit TPIN in CustomerTpin, and send CustomerName with it whenever you do — both are now refused otherwise. Omit both on a walk-in sale.
  9. Handle 401, 402, 403 and 429, and both 422 shapes.
  10. Point any Purchase / Stock / Import traffic somewhere else.
  11. Fiscalise one ReceiptTypeCode: S sale 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.