Documentation

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/api support 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.
  • users is 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_by records till:<email>.
  • branchInfo is 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 an X-SdcId, because it is where the X-SdcId comes from. Returns branch, name, tpin, sdcId, initialized and lastInvoiceNo — the same row shape as devices[] on GET /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. A pk_… key minted without a branch files through whichever of its taxpayer's branches the X-SdcId names, 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/v1 reads Sanctum tokens rather than api_keys — so the console's default mint and every POST /api/v1/tenants/{tenant}/api-keys sent without a branch produced 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, resultMessage and errors exactly as the bare one had them, and gains success, httpCode and errorCode around them. errorCode mirrors ZRA's resultCode.
  • The HTTP status code is unchanged. A wrapped failure is still a 4xx. A success flag 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 success is 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 destinationCountryCode already was. Omit them.
  • Sending a currency or exchangeRate that disagrees with the original is now refused with 422. 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 (5022120400 for 50221204) 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:

  • TOT is 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): 01 wrong quantity invoiced, 02 wrong invoice amount, 03 omitted item, 04 other [specify]. The codes overlap but the meanings do not — 04 is "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 C1 invoice may carry no other VAT category, exactly as C2 may 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 on POST /sales and the credit-note endpoint: currency (defaults to ZMW) and exchangeRate.
  • /zm/api — currencyType is no longer restricted to ZMW. It previously answered 422 for anything else, and conversionRate had to be 1. 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 — exchangeRate added to the sale resource, next to currency, 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

  • conversionRate was documented as "accepted and ignored" on POST /zm/api/Invoice, and described as "kept only so existing legacy-VSDC payloads continue to validate". Both were wrong, and backwards: any value other than 1 has 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 as enum: [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/api are returned bare — there is no {success, message, data} wrapper — so a till that changes only the URL and key reads undefined off 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.
  • 403 documented on all four /zm/api operations. The middleware returns it for an IP outside the key's allowlist and for a key without invoices:write — a scope it checks on the read endpoints too, not just on POST /zm/api/Invoice.
  • 402 documented on the /zm/api read operations, and its two body shapes spelled out: the tenant check omits error, the quota check includes it. Branch on the status code, not on the field.
  • Terminal-Id documented as an accepted alias for X-SdcId.
  • Every response schema now covers every field its endpoint returns. Three had drifted behind the code at once: Tenant was missing four fields, ApiKey six and Device two. ApiKey was the costly one — expiresAt and rotatedToId are 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.
  • 404 on every tenant-scoped provisioning operation. All ten check that the tenant is on the caller's book and answer 404 when 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 no error object, while the two device-branch lookups return {"error": {"code": "device_not_found", …}}.
  • 422 on GET /api/v1/item-classes. A limit above 200 is a refusal, not a clamp — a bulk mapper asking for 1000 rows a page receives nothing.

Corrected (second pass)

  • ZmUnprocessable claimed one body shape; there are three, in two different envelopes. A field-validation failure returns Laravel's {message, errors} — which has no status key — while the two fiscalisation refusals return ZmError. A client detecting errors by reading body.status therefore read undefined on the most common 422 of the three. Branch on the HTTP status, then look for errors.
  • POST …/devices/{branch}/initialize documented a validation body it cannot produce. The endpoint takes no request body, so its 422 is always {"error": {"code": "initialization_failed", …}} carrying ZRA's own text — never an errors map.
  • The one-shot warning pointed at the wrong hazard. It said never to call initialize again 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.
  • taxBreakdown always 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 only A, which taught callers to read the key set as "the categories on this invoice". Filter on taxbl > 0 for that. An unused bucket also reports rate: 0 rather than its category's real rate.
  • fiscalisedAt in the webhook payload is the record's last-modified time, not the fiscalisation moment. The two coincide on an invoice.fiscalised event and diverge on every later write to the row — and it is non-null on a failed invoice that never filed at all. Use fiscal.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 lastFailureAt moving rather than on status flipping.
  • The 402 examples on /zm/api showed the wrong reason. The example named quotaExhausted carried error: 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-Delivery is 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); vsdcBaseUrl is fixed at registration, because re-posting a branch is refused with branch_exists rather than updating it; allowedIps caps at 20 entries; omitting expiresInDays is 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": [] on POST …/api-keys is 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/api checks invoices:write in its authentication middleware, ahead of routing, so a read-only key cannot read either.
  • sdcId: null in 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

  • Sale response schema on POST /api/v1/sales, the credit note and the sale GETs — including the fiscal.* block a compliant receipt prints.
  • status (uploaded / pending / failed), resultCode, lastError and reference on 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 / resultMessage as 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.
  • occurredAt on sales and credit notes, and SaleDate on /zm/api is now honoured (it was documented and dropped): offline sales are declared on the day they happened.
  • Item-class pagination: GET /api/v1/item-classes pages with links/meta.total (the 200-row ceiling was previously silent and final), and the full ~101k-code catalogue is downloadable at GET /docs/zra-item-classes.csv.gz with 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.
  • 429 documented on every operation, with the per-plan ceilings in the introduction. X-Branch documented everywhere it applies, including the credit-note branch rule.
  • operationId on 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:

  • currencyType accepts only ZMW. Six currencies were advertised; the engine filed every invoice in kwacha at face value and ignored conversionRate. A USD invoice was a ~96% under-declaration that read back as ZMW. Convert to kwacha before sending; a non-ZMW currency or a conversionRate other than 1 is now a 422 that says so.
  • vatCategory / TaxCodes are a closed vocabulary (A B C C1 C2 C3 D E RVAT, case-insensitive). An unknown code — including TOT, which an earlier version of the spec listed — used to bucket silently to Exempt, declaring 0% VAT on standard-rated goods. Now 422.
  • paymentMethod / PaymentTypeCode are 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 is 05; mobile money is 06. 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.
  • SaleDate older 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.