API changelog
What changed, the versioning and deprecation policy, and the /zm/api support horizon.
API changelog
The record of what changed on the public surfaces (/api/v1 and /zm/api),
newest first. The OpenAPI document's info.version matches the newest entry.
Versioning and deprecation policy
- Additive changes ship without notice — new endpoints, new optional request fields, new response fields, new webhook event types. Build clients that ignore unknown response fields; that is the compatibility contract.
- Breaking changes get at least 90 days' notice in this changelog and on the affected operation's description before they take effect: removing or renaming a field, tightening validation on an existing field, changing a status code, retiring an endpoint.
- Exception — silent misdeclaration. Where the API was silently filing wrong data with ZRA, the fix ships as soon as it exists, with a changelog entry, because every day of notice is another day of wrong fiscal filings. The 1.2.0 validation tightenings below are this case.
/zm/apisupport horizon: the compatibility surface is fully supported and no sunset is planned. If one is ever scheduled it will be announced here at least 12 months ahead, with a migration guide to/api/v1.operationIds are frozen: generated SDK method names will not churn.
1.10.0 — 2026-09-14
New: POST /api/ApiKey/getactive. The setup call a POS built against an
older Zambian gateway already makes — same path, same {email, password} body,
same {success, message, data} reply carrying apiKeyValue and branchInfo[].
A till can now provision itself from a URL, an email and a password, with no
change to its installer flow. It is the only endpoint this gateway serves
outside /api/v1 and /zm/api.
The credential is a till login, not the merchant's dashboard password. This is the one deliberate difference from the contract being imitated, where the two are the same string — which is what makes the key it returns re-derivable by anyone holding the account, and key rotation largely decorative. Here:
- A till login is created against one key, under Developers → Till keys → Add a login. It is worth that key and nothing else, and removing it leaves a running till undisturbed.
usersis not consulted on this path. A merchant's real dashboard email and password are answered "Invalid email or password" like anything else, and a dashboard address cannot be claimed as a till login — one string, one credential.- Every failure is a 400 with an identical body. A wrong password and an
unregistered address are indistinguishable by design, so the endpoint cannot
be used to discover which taxpayers are here. Branch on
success. - Throttled to five attempts per fifteen minutes, per address and per caller. A sixth is 429 even with the right password: the limit is on the account, not on the guess.
- Every hand-back is audited against the key, the same way a plaintext read
from the web UI is —
secret_revealed_byrecordstill:<email>. branchInfois deduplicated: one row per branch.
1.9.0 — 2026-09-13
A till can now discover its own branch, and a tenant key is a credential rather than a dead string. Additive throughout: nothing that works today changes.
- New:
GET /zm/api/Branches. The only call on the compat surface that does not need anX-SdcId, because it is where theX-SdcIdcomes from. Returnsbranch,name,tpin,sdcId,initializedandlastInvoiceNo— the same row shape asdevices[]onGET /api/v1/me. A setup screen that asks an installer for a URL and a key can now fill its branch picker itself. - Tenant keys authenticate on
/zm/api. Apk_…key minted without abranchfiles through whichever of its taxpayer's branches theX-SdcIdnames, so one credential covers a whole business. Branch keys are unchanged: they still accept their own device's SDC id and no other. - A tenant key previously authenticated nowhere. This middleware required a
device-owned key, the partner surface requires a partner key, and
/api/v1reads Sanctum tokens rather thanapi_keys— so the console's default mint and everyPOST /api/v1/tenants/{tenant}/api-keyssent without abranchproduced a credential that answered 401 on every surface. The spec documented that as a wart; it is now a working key instead. - An SDC id that names a branch the key may not file through is still 401.
A tenant key reaches its own taxpayer's branches and nothing else — the
resolution is a walk down from the credential's owner, never a lookup by
sdc_id, which is printed on receipts and is not a secret. - Merchants can mint these keys themselves, under Developers → Till keys. They came only from an operator or the CLI before.
New: a key can ask for the legacy response envelope. Mint it with
dialect: wrapped — on the Till keys screen, or on
POST /api/v1/tenants/{tenant}/api-keys — and every /zm/api reply to that key
comes back inside {success, message, data} instead of bare. This is for a till
that reads response.data.signature and whose vendor cannot rewrite it; the
un-nesting in "Migrating a till" §2 is still the better end state.
- The envelope adds, it never rewrites. A wrapped error keeps
status,message,error,resultCode,resultMessageanderrorsexactly as the bare one had them, and gainssuccess,httpCodeanderrorCodearound them.errorCodemirrors ZRA'sresultCode. - The HTTP status code is unchanged. A wrapped failure is still a 4xx. A
successflag on a 200-for-everything surface is what lets a till print an unfiscalised receipt as a good one, so the status stays load-bearing. - Per key, not per request. No header to send, nothing to negotiate, and
every key that exists today is
bare— which is what it already receives. - One documented edge: a request with a missing or unknown key is refused bare,
because no key has yet been resolved to have asked otherwise. Status is still
401 and an absent
successis falsy.
1.7.2 — 2026-09-09
A credit or debit note is now filed in the currency of the invoice it adjusts.
This is the silent-misdeclaration exception, so it ships without notice.
currency and exchangeRate were not carried across from the original, and an
omitted currency defaults to the base one — so a note raised against a
USD invoice was filed to ZRA as ZMW at rate 1: the same magnitudes,
relabelled as kwacha, on a document ZRA reconciles against the invoice it
reverses. Any note raised against a foreign-currency invoice before today is
wrong at ZRA and should be reviewed.
- Both fields are now inherited from the invoice being adjusted, the way
destinationCountryCodealready was. Omit them. - Sending a
currencyorexchangeRatethat disagrees with the original is now refused with422. Formally this is a tightening, but no correct client is affected: sending a different currency previously produced a misdeclared document, and sending a matching one still succeeds. A note reverses the money the original moved, not the money that would move today.
Kwacha-only integrations are unaffected in every respect.
1.7.1 — 2026-09-09
Token abilities are now enforced. No action required — every token in existence already carries what it needs.
What was wrong
Every token this platform mints carries invoices:read and invoices:write,
and the Developers screen prints those two strings beside the token as its
scope. Nothing checked them. No ability middleware was registered at all, and
the merchant route group authenticated the bearer token and then let it reach
every route on the surface.
So a token was never limited by the scope shown against it. One labelled read-only could create a customer — which queues a registration to ZRA under the taxpayer's own TPIN — rewrite the item master, and approve or reject a customs declaration line.
What this was not: it was never cross-tenant. Tenant isolation is enforced separately and held throughout; a token could only ever reach its own taxpayer's data. And a partner minting a token for a tenant it already provisions held that authority by other means. What was broken is that the scope mechanism was inert and the label was a promise the software did not keep.
What changed
Reads require invoices:read. Writes — creating a sale, a credit or debit note,
a customer or an item, releasing an item, and approving or rejecting an import
line — require invoices:write. A token missing the ability a route needs is
answered 403.
Every token ever issued carries both abilities, so no existing integration is affected. What changes is that a narrower token is now honoured instead of silently ignored, and the scope displayed to a merchant is the scope enforced.
Found by an internal re-audit of the platform's UAT compliance position, and shipped immediately rather than on notice: an unenforced scope shown to a user as a control is a misstatement, and the policy exception above covers it.
1.7.0 — 2026-09-09
Import items — customs declarations, and the answer to each line. Four new endpoints. Additive, so it ships without notice per the policy above.
What changed
GET /api/v1/import-items
GET /api/v1/import-items/{importItem}
POST /api/v1/import-items/{importItem}/approve
POST /api/v1/import-items/{importItem}/reject
Declaration lines ZRA has addressed to the merchant's branch, pulled hourly, and the two answers that can be given to one.
Three things to build against, not around
There is no POST /api/v1/import-items, and there never will be. A
declaration comes from ASYCUDA through ZRA. The merchant is not its author, and a
posted one would be a record ZRA could never match against a real declaration.
This is the only ledger on this API without a create.
Approving needs an itemCode; rejecting does not. ZRA refuses an approval
that does not say what the merchant calls the goods — the declaration names them
the way customs does, and the stock movement is filed against your own code. Send
itemCode on the approve call to match and file in one request. Two refusals
come back 422, and both are yours to fix: a line with no match, and one
pointing at a draft item whose classification nobody has reviewed (release it
through POST /api/v1/items/{item}/release first). A rejection carries no such
requirement, because refusing goods you never received should not oblige you to
invent a product record for them.
decision and filing are separate objects because they fail separately.
decision is what the merchant said and exists the moment they answer; filing
is whether ZRA has accepted that answer and can read sending or failed long
afterwards. A failed filing is not a reversed decision. stockPostedAt is a
third fact again: an approved line filed before it was matched has no movement
until it is.
Polling
Ask for ?status=2. That is ZRA's own class-26 code for Waiting — the state
every pulled line arrives in and the only one that needs a person. The filter
takes ZRA's code rather than a word of ours so it cannot drift: 1 Unsent,
2 Waiting, 3 Approved, 4 Cancelled.
What is not here
Import VAT does not appear on the VAT input schedule, and that is deliberate: a declaration line carries no tax fields at all, because VAT on an import is assessed and paid at the border on a customs document. Claim it from that document, not from this one.
1.6.2 — 2026-09-08
rewardMessage — ZRA's invoice-lottery prize, on every fiscal block.
Additive, so it ships without notice per the policy above.
What changed
fiscal.rewardMessage is published on GET /api/v1/sales/{id} (and the sale
returned by POST /api/v1/sales), on the invoice.fiscalised / invoice.failed
webhook payloads, and as rewardMessage on /zm/api fiscal details.
It is null on almost every invoice, and will stay null until ZRA switches its draw on. Do not treat a null as an error.
Why it exists
VSDC 1.0.11.4 added a reward subsystem. saveSales has been returning a
rewardMsg field in its response ever since — this gateway simply never read
it, so the message survived only inside an internal raw-response column. When
ZRA enables the draw, that string is how a customer learns they have won
something, and it is meant to be printed on their receipt.
Print it verbatim. The wording is ZRA's; do not translate, truncate or re-phrase a prize notice.
Note for /zm/api callers: this field is not part of the third-party surface
that endpoint mirrors, because ZRA introduced the lottery after it. It is
published there anyway so a till integrating on the compatibility surface is not
the only one unable to tell a customer they won.
1.6.1 — 2026-09-07
customerNo must be exactly 10 characters. Corrective, and narrowing —
ships without notice under the silent misdeclaration exception above.
What changed
POST /api/v1/customers and PATCH /api/v1/customers/{id} now answer 422
for a customerNo that is not exactly 10 characters. It previously answered
201 and the customer was then rejected by Smart Invoice with
[<custNo> : length must be between 10 and 10] — so the API reported a customer
created and registered that ZRA would never accept.
Why
The rule was found by re-probing the VSDC after discovering that this gateway's
sandbox had been running an older build than its own image tag claimed. The
probe that originally established customerNo as mandatory went straight from
"field absent" to "field valid" and never sent a short one, so only half the
rule was learned. Swept against the correct build, lengths 1, 5, 9, 11, 20, 32,
50 and 64 are every one refused and only 10 is accepted.
Nine characters is not a hypothetical: it is how a mistyped Zambian mobile number arrives.
What a caller must send
Exactly 10 characters when the field is given at all — still optional for a
customer with no TPIN, still required alongside one. The check is on length, not
on digits: 0977123456 and ABCDEFGHIJ are both accepted by Smart Invoice.
1.6.0 — 2026-09-07
Item classification codes are checked before an invoice is filed. Corrective, and narrowing — ships without notice under the silent misdeclaration exception above.
What changed
items[].classificationCode on POST /api/v1/sales, and
invoiceItems[].itemClassificationCode on POST /zm/api/Invoice, are now
validated against ZRA's published classification catalogue. A code ZRA does not
publish is a 422 naming the field, where it previously reached ZRA and was
answered 000.
POST /api/v1/items and PATCH /api/v1/items/{id} apply the same check.
Why this could not wait for 90 days' notice
ZRA does not validate this field on a sale. saveItem checks itemClsCd
against the catalogue and answers 910 [<itemClsCd> : Invalid Item Class Code];
saveSales accepts any string. So an invented, mistyped or wrong-standard code
was filed, accepted, and returned a receipt — and nothing anywhere said
otherwise until the item it named was registered, which for most callers is
never.
Found on this gateway's own sandbox: of 379 sales carrying a classification
code, 17 were even the right shape and 365 had been answered 000. The
codes were customs/HS tariff numbers and fabricated digits that came from this
project's own sample data, since fixed. Every one of those invoices is filed at
ZRA against a classification ZRA does not publish, and a classification is what
an item is taxed against — so this is a misdeclaration, not a cosmetic defect.
What a caller must send
An 8-digit code from ZRA's published list — GET /api/v1/item-classes, or
the bulk /docs/zra-item-classes.csv.gz. Two things are deliberately tolerated:
- The 10-digit zero-padded form ZRA itself echoes back (
5022120400for50221204) is accepted and normalised. A caller repeating what ZRA told them is not wrong. - Where a deployment's catalogue is not loaded, the check stands down rather than refusing. It cannot distinguish an unknown code from an unloaded table, and refusing on that ambiguity would stop tills trading.
What is most likely to break
Customs/HS tariff numbers. They are 10 digits, so they look like a padded
classification, but ZRA publishes none of them — 7403110000 (copper cathode),
8544429000 (cable) and 8299900000 are all refused. The catalogue has real
codes for the same goods: 11101907, 43211617, 86132102.
1.5.1 — 2026-09-07
GET /zm/api/TaxCodes stops advertising codes the gateway refuses.
Corrective, and narrowing — see the note on notice below.
What changed
The endpoint published every row of ZRA's standard code class 04. That class
includes TOT (Turnover Tax), which POST /zm/api/Invoice has always
refused with a 422: this gateway files VAT, and TOT has no header bucket in a
saveSales payload to be filed in. So the endpoint whose one job is to tell an
integrator which codes to send was naming a code that could not be sent.
It now publishes the intersection of three things that were previously allowed to disagree: the codes the invoice endpoint accepts, the codes the pricer can rate, and the codes ZRA still publishes as in use. Concretely:
TOTis no longer listed. It was never accepted; nothing that worked stops working. If you need Turnover Tax filing, say so — it is a feature, not a bug fix.- A code ZRA retires (
useYn = 'N') stops being listed when the next code sync runs, instead of being served indefinitely. - Rates now come from the same table the filing is computed with. A rate quoted here can no longer differ from the rate actually declared to ZRA.
Names, ordering, the response shape and the operationId are unchanged.
Why this ships without 90 days' notice
Under the policy above this is the silent misdeclaration exception rather than
a breaking change: no client could ever have fiscalised with TOT, so no
working integration loses a capability. A client that read the list and offered
TOT in a dropdown was offering its users a 422. Removing it converts a runtime
rejection into an accurate vocabulary.
The related fix, invisible on the wire: a fifth copy of the VAT rate table lived
in the web till, and it had no RVAT entry — a reverse-VAT line previewed as
zero tax and then filed at 16%. The preview and the filing now read one table.
1.5.0 — 2026-09-06
Debit notes. Additive — a new endpoint and two new optional fields; nothing that validated before stops validating.
POST /api/v1/sales/{sale}/debit-note
The document an undercharge is. ZRA's saveSales has always carried rcptTyCd
D ("adjustment upwards") plus dbtRsnCd and invcAdjustReason; this gateway
could reach none of them, so an omitted item or a wrong quantity had no fiscal
correction at all and the VAT on it went undeclared.
It behaves exactly like the credit-note endpoint — same branch rule, same refusal on a sale ZRA has not yet seen, same inheritance of the original's customer and export destination, same idempotency — with two differences worth reading:
- The reason vocabulary is different, and not interchangeable. ZRA publishes
four debit-note codes (standard-code class 67) against the credit note's seven
(class 32):
01wrong quantity invoiced,02wrong invoice amount,03omitted item,04other [specify]. The codes overlap but the meanings do not —04is "other" on a debit note and "wrong customer" on a credit note. Sending a credit-note code to the debit endpoint is refused with 422 rather than passed through for the VSDC to reject. - There is no cap. A credit note may not give back more than the invoice took; a debit note adds value, so nothing bounds it.
note on both adjustment endpoints
ZRA's invcAdjustReason, the free text behind an "other" reason code. Optional,
max 200 characters, printed on the note.
type on Sale gains debit-note
Sale.type was sale | credit-note and is now sale | credit-note | debit-note. A client that branches on this must handle the new value — one
that treats anything non-credit-note as a sale will double-count the original.
This is why the field exists.
1.4.0 — 2026-09-06
Export sales. Additive on /api/v1; a tightening on /zm/api for one field
that was previously accepted and ignored — see the exception in the policy above.
destinationCountryCode is now read, and required on exports
ZRA's saveSales has always carried destnCountryCd, and this gateway never
sent it, so an export was filed as an ordinary zero-rated sale with no record of
where the goods went. The VSDC ties the field to VAT category C1 in both
directions, and the gateway now enforces the same rule before an invoice number
is consumed:
- Required when any item uses
C1(Exports) — 422 without it. - Rejected on an invoice that is not
C1— a domestic sale has no destination. - A
C1invoice may carry no other VAT category, exactly asC2may not.
Both surfaces take the field as destinationCountryCode, a two-letter ISO
3166-1 alpha-2 code, case-insensitive. It is filed with ZRA and printed on the
tax invoice.
/zm/api callers: this field was listed in the migration guide among those
"accepted and silently ignored". If your till has been sending it, it now takes
effect. If your till sends C1 items without it, those invoices now fail with
422 rather than filing an unattributed export.
New response fields on Sale
destinationCountryCode, lpoNumber and customer.address are now returned.
All three were accepted and stored but never read back, so a client could file
an export or an LPO invoice and had no way to confirm what the document
actually carried.
1.3.0 — 2026-09-05
Multi-currency invoicing. Additive on /api/v1; a relaxation on /zm/api —
nothing that validated before stops validating.
Invoices can now be issued in six currencies
ZMW, USD, GBP, EUR, CNY and ZAR — the set ZRA Smart Invoice accepts.
The currency and its exchange rate are filed with ZRA and printed on the tax
invoice, alongside the kwacha equivalent.
/api/v1— new optional fields onPOST /salesand the credit-note endpoint:currency(defaults toZMW) andexchangeRate./zm/api—currencyTypeis no longer restricted toZMW. It previously answered 422 for anything else, andconversionRatehad to be1. Both now accept real values. If your till was converting prices to kwacha before sending, you can stop — send the currency you actually invoiced in.- Response —
exchangeRateadded to the sale resource, next tocurrency, so a client can read back the rate an invoice was filed at.
The rate rule mirrors ZRA's exactly and is enforced before an invoice number is consumed, in both directions:
| Currency | exchangeRate |
|---|---|
ZMW |
must be 1, or omitted |
| any other | required, greater than zero, and never 1 |
A currency outside the six — including ones in ZRA's own published currency
table, such as JPY — is refused with 422 rather than filed.
Why this was restricted before
currencyType and conversionRate were validated and then dropped at the
boundary: every invoice was filed as ZMW at rate 1 whatever was sent. A USD
invoice returned a valid signature while ZRA had been told its face value in
kwacha, and the read-back showed the hard-coded ZMW too, so the caller could
not see the under-declaration. Restricting the field to ZMW was the honest
description of that behaviour. Both fields now reach ZRA, so the restriction is
lifted.
1.2.1 — 2026-09-02
Documentation only. No endpoint changed behaviour in this release — every correction below describes what the server was already doing.
Corrected
conversionRatewas documented as "accepted and ignored" onPOST /zm/api/Invoice, and described as "kept only so existing legacy-VSDC payloads continue to validate". Both were wrong, and backwards: any value other than1has been refused with 422. A legacy payload carrying a real exchange rate is precisely the one that does not validate. The field is now documented asenum: [1]with the refusal stated.- The compatibility surface was called a drop-in. The spec said an existing
till "usually needs only a new base URL and key", and the guide said "keep
your payload". Request field names do carry over, but responses on
/zm/apiare returned bare — there is no{success, message, data}wrapper — so a till that changes only the URL and key readsundefinedoff its first successful call. Both claims are retracted.
Added
- Migrating a till from a legacy VSDC gateway —
the field-by-field delta: what carries over, what is refused, what is
accepted and silently ignored (
localPurchaseOrder, the discount fields), and which legacy calls have no destination here. 403documented on all four/zm/apioperations. The middleware returns it for an IP outside the key's allowlist and for a key withoutinvoices:write— a scope it checks on the read endpoints too, not just onPOST /zm/api/Invoice.402documented on the/zm/apiread operations, and its two body shapes spelled out: the tenant check omitserror, the quota check includes it. Branch on the status code, not on the field.Terminal-Iddocumented as an accepted alias forX-SdcId.- Every response schema now covers every field its endpoint returns. Three
had drifted behind the code at once:
Tenantwas missing four fields,ApiKeysix andDevicetwo.ApiKeywas the costly one —expiresAtandrotatedToIdare exactly the two fields a partner needs to run a key rotation, and neither existed in the contract. A test now renders each resource and compares it to its schema in both directions, so a field added on either side without the other fails the build. 404on every tenant-scoped provisioning operation. All ten check that the tenant is on the caller's book and answer404when it is not — the most likely response to a script holding a stale id, previously documented on two of them. Its two body shapes are now spelled out: ownership and route-binding failures return Laravel's bare{"message": …}with noerrorobject, while the two device-branch lookups return{"error": {"code": "device_not_found", …}}.422onGET /api/v1/item-classes. Alimitabove 200 is a refusal, not a clamp — a bulk mapper asking for 1000 rows a page receives nothing.
Corrected (second pass)
ZmUnprocessableclaimed one body shape; there are three, in two different envelopes. A field-validation failure returns Laravel's{message, errors}— which has nostatuskey — while the two fiscalisation refusals returnZmError. A client detecting errors by readingbody.statustherefore readundefinedon the most common 422 of the three. Branch on the HTTP status, then look forerrors.POST …/devices/{branch}/initializedocumented a validation body it cannot produce. The endpoint takes no request body, so its422is always{"error": {"code": "initialization_failed", …}}carrying ZRA's own text — never anerrorsmap.- The one-shot warning pointed at the wrong hazard. It said never to call
initializeagain on a device that had already initialised, "that is unrecoverable". Calling it again is in fact safe and idempotent — the right thing to do after a timeout. The real constraint is on the serial: ZRA returns the initialisation payload once, to the first branch that claims it, so a serial installed at ZRA that this gateway holds no record of cannot be recovered through the API. taxBreakdownalways carries all eight header categories, unused ones included as{taxbl: 0, tax: 0, rate: 0}— the VSDC reconciles every category against the lines. The published example showed onlyA, which taught callers to read the key set as "the categories on this invoice". Filter ontaxbl > 0for that. An unused bucket also reportsrate: 0rather than its category's real rate.fiscalisedAtin the webhook payload is the record's last-modified time, not the fiscalisation moment. The two coincide on aninvoice.fiscalisedevent and diverge on every later write to the row — and it is non-null on afailedinvoice that never filed at all. Usefiscal.publishedAt, which is ZRA's own timestamp and null exactly when the document is not filed.- Auto-disable is not always six attempts over eight hours. A receiver that
times out or errors works through the full retry ladder first, but a URL the
SSRF guard refuses at send time — a hostname that now resolves to a private
address, or stopped resolving — is terminal on the first attempt. Ten
such events disable an endpoint in the time it takes to file ten invoices.
Alert on
lastFailureAtmoving rather than onstatusflipping. - The 402 examples on
/zm/apishowed the wrong reason. The example namedquotaExhaustedcarriederror: subscription_inactive; anyone copying it built a quota branch matching a string the quota path never sends. All three reasons are now published, each under its own name, and pinned to the constants that raise them. X-Webhook-Deliveryis the delivery row's id, not the attempt's. It is identical across every retry of a delivery, so it must not be a primary key for an attempts table.- Webhooks are partner-only, and two merchant-facing pages did not say so.
A merchant holding only a bearer token cannot register a receiver — that call
answers 401. Poll
GET /api/v1/sales/{sale}instead, or ask whoever provisioned you to register one. - Provisioning constraints that existed only in code are now in the
contract: a TPIN is 10 digits and unique across the whole platform, not
just your book (so a merchant can be onboarded by exactly one partner);
vsdcBaseUrlis fixed at registration, because re-posting a branch is refused withbranch_existsrather than updating it;allowedIpscaps at 20 entries; omittingexpiresInDaysis refused rather than clamped when the calling key itself expires; and a rotation's replacement inherits the rotated key's expiry deadline — rotation preserves a key, it does not renew one. "scopes": []onPOST …/api-keysis not the same as omitting the field. Omitting it grants both client scopes; an empty array grants none, and the key then 403s on every call. So does["invoices:read"]alone —/zm/apichecksinvoices:writein its authentication middleware, ahead of routing, so a read-only key cannot read either.sdcId: nullin a mint response means the key is unusable, and it is the only signal at mint time: the branch was omitted, or it has not been initialised. The mint still answers 201 and the failure surfaces as a 401 on the till. Assert on it in provisioning scripts.
Support horizon
POST /zm/api/Purchase, POST /zm/api/Stock, POST /zm/api/Stock/batch and
the /zm/api/Import pair exist on some legacy gateways and are not served
here, with no current ETA. If your taxpayer obligations include purchase,
stock or import reporting, talk to us before migrating a till that depends on
them.
1.2.0 — 2026-09-01
The integration-readiness release: the response side of the contract, the error vocabulary, and several places where the documentation and the engine disagreed — resolved in the engine's favour where the docs promised too much, and in the docs' favour where the engine was misdeclaring.
Added
Saleresponse schema onPOST /api/v1/sales, the credit note and the sale GETs — including thefiscal.*block a compliant receipt prints.status(uploaded/pending/failed),resultCode,lastErrorandreferenceon the merchant sale response. A 2xx was never proof ZRA had the document; now the response says which it is.- Structured ZRA verdicts: rejections carry
resultCode/resultMessageas fields on every surface (additively on/zm/api). The result-code table lives at/docs/result-codes. - Six previously undocumented operations:
GET /api/v1/me,GET /api/v1/sales(with?reference=lost-response recovery),GET /api/v1/sales/{sale}, and the tenant-token trio (GET|POST /tenants/{tenant}/tokens,DELETE …/tokens/{token}) — the only way a partner mints the credential that fiscalises. occurredAton sales and credit notes, andSaleDateon/zm/apiis now honoured (it was documented and dropped): offline sales are declared on the day they happened.- Item-class pagination:
GET /api/v1/item-classespages withlinks/meta.total(the 200-row ceiling was previously silent and final), and the full ~101k-code catalogue is downloadable atGET /docs/zra-item-classes.csv.gzwith no credential. POST /api/v1/webhooks/{id}/enable— recovery from auto-disable, keeping the signing secret. Events fired while disabled are not queued; the guide says how to reconcile.- Webhook events declared in the spec (
invoice.fiscalised,invoice.failed) with payload schemas, delivery headers, the retry ladder and the signing contract; runnable verifiers at/docs/webhooks. 429documented on every operation, with the per-plan ceilings in the introduction.X-Branchdocumented everywhere it applies, including the credit-note branch rule.operationIdon every operation, code samples on the fiscalisation endpoints, and guides at/docs/webhooks,/docs/partner-keys,/docs/result-codes.
Changed — validation tightened (the silent-misdeclaration exception)
Each of these previously returned 2xx while filing something other than what the caller meant. They now fail loudly instead:
currencyTypeaccepts onlyZMW. Six currencies were advertised; the engine filed every invoice in kwacha at face value and ignoredconversionRate. A USD invoice was a ~96% under-declaration that read back as ZMW. Convert to kwacha before sending; a non-ZMW currency or aconversionRateother than 1 is now a 422 that says so.vatCategory/TaxCodesare a closed vocabulary (A B C C1 C2 C3 D E RVAT, case-insensitive). An unknown code — includingTOT, which an earlier version of the spec listed — used to bucket silently to Exempt, declaring 0% VAT on standard-rated goods. Now 422.paymentMethod/PaymentTypeCodeare ZRA's own table (01–08). The platform previously used a private four-code table that disagreed with ZRA on three of them — a card sale was being declared as Credit (02) and a mobile-money sale as Cash/Credit (03). Card is05; mobile money is06. Unknown codes are now 422; receipts print ZRA's label for the code actually declared.- Cross-branch credit notes are refused with a 409 naming the branch that raised the original invoice. Previously the note was silently fiscalised on the wrong branch's invoice sequence.
SaleDateolder than ZRA's 180-day floor is refused at validation, in words, instead of surfacing later as a bare result code.
Fixed
- The published production base URL was a placeholder domain; the served document now always advertises the host serving it.
- The webhook signing algorithm, and the guides containing it, were reachable by no route.
1.1.0 and earlier — 2026-07/08 (summarised)
The /zm/api compatibility surface for legacy VSDC gateways, partner
provisioning (tenants → devices → initialize → keys), self-service partner key
management with rotation and IP allowlists, signed webhooks with at-least-once
delivery, offline-tolerant fiscalisation with in-order upload, credit notes,
LPO (C2) invoices, and metered billing with 402 on blocked subscriptions.