zimra-fdms docs
0stars 0forks
GitHub
docsv0.4.0 ·MIT·Node 18+·Android 8+

Documentation

Everything needed to fiscalise with zimra-fdms — from a first registered device to a signed fiscal-day close. Every behaviour described here is the behaviour of the shipped code, verified live against ZIMRA's FDMS test environment.

Introduction#

zimra-fdms is an open-source TypeScript SDK that talks directly to ZIMRA's Fiscalisation Data Management System using your own device certificates. There is no middleman service in the path, no per-receipt fee, and no vendor between your POS and the tax authority.

What it handles for you

  • Device registration — ECDSA P-256 key generation, a CSR in ZIMRA's exact subject format, and certificate issuance.
  • mTLS transport — mutual TLS over node:https on Node and OkHttp on Android, with typed errors.
  • Receipt signing — the canonical signing string, SHA-256 hash, DER-encoded ECDSA signature and the receipt-to-receipt hash chain.
  • Fiscal day lifecycle — open, counter accumulation across receipts, and a correctly sorted, signed close.
  • Offline queue — FIFO queueing and retry inside FDMS's 72-hour grace window.
  • Verification QR data — the exact string ZIMRA's validation portal expects.

What it does not handle

The SDK solves the integration. Going live still requires the human path: ZIMRA onboarding, sample-document approval, live device registration, ITF263 and ongoing compliance. See going to production on the home page.

Community project. Not affiliated with or endorsed by the Zimbabwe Revenue Authority. "ZIMRA" and "FDMS" refer to the authority's public fiscalisation programme and API.

Installation#

npm install zimra-fdms

Node 18 or newer. Three runtime dependencies: @peculiar/x509 for certificate parsing, and @modelcontextprotocol/server and zod for the MCP server. The fiscal core, zimra-fdms/core, depends on nothing.

Android

npm install zimra-fdms zimra-fdms-react-native

Android 8 (API 26) or later and React Native 0.71 or later. Autolinking picks up the Kotlin module, so rebuild the app after installing. Expo managed projects need a development build; the module does not load in Expo Go. On any platform other than Android the first native call throws. The published version is 0.4.0-beta.1. The guide is under Android.

Module format

The package is ESM-only ("type": "module"). In a CommonJS project, reach it through a dynamic import:

const { FiscalDevice } = await import("zimra-fdms");

What you need before writing code

Three values, issued when the device is added on the FDMS portal:

ValueExampleWhere it comes from
deviceId12345FDMS portal, on device creation
serialNumberMYPOS001Chosen when registering the device
activationKeyAAAABBBB8-character key from the portal, single use

For the test environment these are self-service and take minutes: fdmsops.zimra.co.zw/fdms-public/add-device.

Quick start#

A complete fiscal day: register once, then open, sell, close.

1 · Register the device (once)

import { registerDevice } from "zimra-fdms";

const { keys, certificatePem } = await registerDevice(
  { deviceId: 12345, serialNumber: "MYPOS001", modelName: "Server", modelVersion: "v1" },
  "ACTIVKEY",
  { environment: "test" },
);

// Persist these two securely — together they are the device identity.
await saveSecret("device-private-key.pem", keys.privateKeyPem);
await saveSecret("device-certificate.pem", certificatePem);

2 · Run the fiscal day

import { FiscalDevice } from "zimra-fdms";

const device = new FiscalDevice(
  { deviceId: 12345, serialNumber: "MYPOS001", modelName: "Server", modelVersion: "v1" },
  { certificatePem, privateKeyPem },
  { environment: "test" },
);

await device.getConfig();   // tax table, qrUrl, operating mode
await device.openDay();

const sale = await device.submitReceipt({
  currency: "USD",
  invoiceNo: "INV-0001",
  lines: [
    { name: "Consulting", price: 115, quantity: 1, taxId: 513, taxPercent: 0 },
  ],
  payments: [{ moneyType: "Cash", amount: 115 }],
});

console.log(sale.qrData);   // encode this into the printed QR code

await device.closeDay();    // signed counters, computed for you

Persist the fiscal day state. Call device.getState() after every receipt and device.restoreState() on startup. The receipt hash chain and counters must survive process restarts — see fiscal day lifecycle.

Prefer not to write code yet? The same cycle runs from the terminal — see the CLI.

On a phone, the same cycle runs with the device key inside Android Keystore. See Android.

Device registration#

Registration is the bootstrap step, and the only call that runs without a client certificate — the activation key is what authenticates you. It happens once per device.

What the call actually does

  1. Generates an ECDSA P-256 key pair with WebCrypto.
  2. Builds a CSR whose subject CN is ZIMRA-{serial}-{deviceId padded to 10 digits}.
  3. POSTs it with the activation key to /Public/v1/{deviceId}/RegisterDevice.
  4. Returns the issued PEM certificate alongside the generated keys.

That is the Node form. A key that already exists and cannot be exported, such as one in Android Keystore or an HSM, registers through the Signer form instead: registerDevice(device, activationKey, signer, options) skips step 1 and returns no keys. See Runtimes and Android.

import { registerDevice, deviceCommonName } from "zimra-fdms";

const device = {
  deviceId: 12345,
  serialNumber: "MYPOS001",
  modelName: "Server",
  modelVersion: "v1",
};

deviceCommonName(device.serialNumber, device.deviceId);
// "ZIMRA-MYPOS001-0000012345"

const { keys, certificatePem, operationId } = await registerDevice(
  device,
  "ACTIVKEY",
  { environment: "test" },
);

What to store, and how

ValueSensitivityNotes
keys.privateKeyPemSecretPKCS#8 PEM. Never leaves the device. Losing it means re-registering.
certificatePemNot secretThe issued device certificate. Pairs with the private key for mTLS.
keys.publicKeyPemNot secretSPKI PEM. Kept for convenience; FDMS does not need it again.
keys.csrPemNot secretThe CSR that was submitted. Useful for support tickets.
operationIdNot secretZIMRA's request correlation id — quote it when reporting a problem.

The private key is the device. Anyone holding it can issue fiscal receipts as you. Store it with the same care as a payment credential — file permissions 0600 at minimum, a secret manager in production, and never in version control.

Environments

Pass environment: "test" (the default) or "production". They resolve to different hosts and are entirely separate worlds — a test device and its certificate are meaningless in production.

EnvironmentBase URL
testhttps://fdmsapitest.zimra.co.zw
productionhttps://fdmsapi.zimra.co.zw

Activation keys are single use

Once a device is registered, its activation key is spent. Re-registering the same device requires a fresh key from the portal — which is why the CLI refuses to overwrite an existing profile without --force.

Fiscal day lifecycle#

FDMS is stateful. Receipts only exist inside an open fiscal day, they must be numbered consecutively, and the day closes with a signature over accumulated counters. The SDK owns that state for you in a FiscalDayState object.

The states

fiscalDayStatusMeaning
FiscalDayClosedNo day is open. The only state from which openDay() succeeds.
FiscalDayOpenedReceipts can be submitted.
FiscalDayCloseInitiatedClose accepted, still settling server-side. Poll until it resolves.
FiscalDayCloseFailedThe close was rejected — usually a counter or signature mismatch.

Opening

await device.openDay();              // FDMS assigns the next fiscal day number
await device.openDay(41);            // or request a specific one
await device.openDay(41, openedAt);  // and control the opening timestamp

openDay() calls getStatus() first and throws if the device is not in FiscalDayClosed, so a double-open fails locally instead of costing an API round trip. It also seeds receiptGlobalNo from the server's lastReceiptGlobalNo, which is what keeps global numbering continuous across days.

Persisting state between processes

A POS process restarts; the hash chain must not. Persist after every receipt:

const sale = await device.submitReceipt(input);

// Commit the new chain position in the same transaction as the sale.
const state = device.getState();
await db.saveFiscalDayState(state);

And restore on startup:

const saved = await db.loadFiscalDayState();
if (saved) device.restoreState(saved);

getState() returns a deep clone, and restoreState() clones on the way in — so the object you persist is a snapshot, not a live reference into the device.

What the state contains

FieldTypePurpose
fiscalDayNonumberThe open day, as assigned by FDMS.
fiscalDayDatestringYYYY-MM-DD, part of the day-close signing string.
receiptCounternumberReceipts issued in this day, starting at 1.
receiptGlobalNonumberReceipts issued over the device lifetime.
previousReceiptHashstring?The hash chain link. Undefined for the first receipt of a day.
countersFiscalDayCounter[]Running totals by tax, money type and currency.

Closing

closeDay() builds the canonical fiscal-day string from the accumulated counters, signs it, drops zero-value counters, submits, and clears local state.

await device.closeDay();

CloseDay settles asynchronously. A successful response means accepted, not closed. Poll getStatus() until fiscalDayStatus reaches FiscalDayClosed or FiscalDayCloseFailed before opening the next day.

await device.closeDay();

let status = "FiscalDayCloseInitiated";
for (let i = 0; i < 12 && status === "FiscalDayCloseInitiated"; i++) {
  await new Promise((r) => setTimeout(r, 3000));
  status = (await device.getStatus()).fiscalDayStatus;
}

if (status !== "FiscalDayClosed") {
  throw new Error(`Fiscal day did not close cleanly: ${status}`);
}

Recovering a lost day state

If the persisted state is lost while a day is open, the day can still be closed: FDMS itself reports the counters it has accumulated. Read fiscalDayCounter from getStatus(), sign those, and submit. This is exactly what zimra-fdms day close does when it finds no local state — see the CLI reference.

Receipts & signing#

One call builds, signs and submits a receipt. Everything FDMS validates — counters, totals in cents, the tax summary, the canonical string, the DER signature, the hash chain — is derived from the input you pass.

Receipt input

const sale = await device.submitReceipt({
  currency: "USD",
  invoiceNo: "INV-0001",
  lines: [
    { name: "Bread", price: 2.5, quantity: 2, taxId: 1, taxPercent: 15, taxCode: "A" },
    { name: "Milk",  price: 1.8, quantity: 1, taxId: 2, taxPercent: null },  // exempt
  ],
  payments: [{ moneyType: "Cash", amount: 6.8 }],
});

sale.receipt;    // the full signed Receipt sent to FDMS
sale.response;   // receiptID, serverDate, server signature, validationErrors
sale.qrData;     // string to encode in the printed QR code
FieldTypeDefaultNotes
currencystringISO code, e.g. "USD", "ZWG".
invoiceNostringYour invoice number. Must be unique within the fiscal day.
linesReceiptLineInput[]At least one line.
payments{ moneyType, amount }[]Must sum exactly to the receipt total.
receiptTypeReceiptType"FiscalInvoice"Or "CreditNote" / "DebitNote".
linesTaxInclusivebooleantrueWhether line prices already contain tax.
receiptDateDatenew Date()Device local time; serialised without a timezone.
notesstringnullFree text printed on the receipt.
buyerBuyerDatanullRequired for a buyer to claim input VAT.
creditDebitNoteCreditDebitNotenullRequired for credit and debit notes.

Line fields

FieldTypeNotes
namestringPrinted description.
pricenumberUnit price. Tax-inclusive unless linesTaxInclusive is false.
quantitynumberLine total is price × quantity, rounded to 2 dp.
taxIdnumberMust be a valid id from getConfig().applicableTaxes.
taxPercentnumber | nullnull or omitted marks the line exempt.
taxCodestring?Optional letter code from the tax table.
hsCodestring?Optional harmonised system code.

qrData requires getConfig(). The QR string is built from qrUrl, which arrives with the device configuration. If you never call getConfig(), receipts still submit and validate — but sale.qrData is undefined and you have nothing to print. Call it once at startup.

Tax handling

Lines are grouped into a tax summary by (taxId, taxPercent) and sorted by taxID. With tax-inclusive pricing the tax is extracted from the line total (total − total ÷ (1 + rate)); with exclusive pricing it is added on top. Amounts are rounded to 2 dp at each step, then converted to integer cents for signing.

Payments are checked against the computed total in cents — a mismatch throws before anything is sent:

Error: Payments (6.79) do not equal receipt total (6.80)

Credit and debit notes

A credit note reverses an earlier receipt and must point back at it. Its counters are accumulated with a negative sign, which is what makes the day balance.

await device.submitReceipt({
  receiptType: "CreditNote",
  currency: "USD",
  invoiceNo: "CN-0001",
  creditDebitNote: {
    receiptID: original.response.receiptID,
    deviceID: 12345,
    receiptGlobalNo: original.receipt.receiptGlobalNo,
    fiscalDayNo: 41,
  },
  lines: [{ name: "Consulting", price: 115, quantity: 1, taxId: 513, taxPercent: 0 }],
  payments: [{ moneyType: "Cash", amount: 115 }],
});

The canonical signing string

This is the format FDMS verifies. Getting one separator wrong yields RCPT010, so the SDK builds it for you — but it is worth knowing what is being signed:

deviceID
+ RECEIPTTYPE          uppercase, e.g. FISCALINVOICE
+ CURRENCY             uppercase, e.g. USD
+ receiptGlobalNo
+ receiptDate          YYYY-MM-DDTHH:mm:ss, local, no timezone
+ receiptTotal         integer cents
+ taxes                sorted by taxID, each: percent "15.00" ("" if exempt)
                       + taxAmount cents + salesAmountWithTax cents
+ previousReceiptHash  base64; omitted for the first receipt of the day

The signature is base64(ECDSA-P256-SHA256(canonical)) in ASN.1 DER encoding, and hash is base64(SHA-256(canonical)). That hash becomes the next receipt's previousReceiptHash — the chain.

State commits only after acceptance. Counters, the global number and the chain hash advance only once FDMS has accepted the receipt. A rejected submission leaves the device state untouched, so a retry is safe.

Validation errors

A receipt can be accepted and still carry warnings. sale.response.validationErrors is an array of { validationErrorCode, validationErrorColor, validationErrorDescription } — surface these in your logs rather than discarding them, because they are what ZIMRA will ask about later.

The colour matters more than the code. Yellow is a warning. Red means the fiscal day can no longer be closed by the device: CloseDay fails with ReceiptsWithValidationErrors and only ZIMRA can close it (self-service on the test ops portal, a request to ZIMRA in production). The SDK records Red errors in FiscalDayState.redErrors and closeDay() throws DayNotClosableError up front rather than failing thirty seconds later; pass { force: true } to submit anyway. Observed on the test environment, 23 August 2026:

CodeColourMeaningDay still closable
RCPT031YellowReceipt date is in the futureYes
RCPT014YellowReceipt date is before the day openedYes
RCPT030RedReceipt date is earlier than the previous receiptNo
RCPT012RedReceipt global number is not sequentialNo

A wrong clock is a one-way door. Once a future-dated receipt is accepted, every later receipt must carry a date strictly after it until real time catches up. Submitting with the real (earlier) time is RCPT030 and bricks the day. The SDK nudges auto-generated dates one second past the previous receipt for exactly this reason; only set receiptDate yourself if you know it is later than the last one. FDMS also reports lastReceiptGlobalNo as the number of the receipt with the latest date, not the highest number issued, so the profile keeps its own high-water mark and openDay uses the larger of the two.

Offline queue#

FDMS allows sales to continue while the connection is down, with a 72-hour grace window to submit them. OfflineReceiptQueue wraps a device with that behaviour: submit if you can, queue if you cannot, and drain in order when the link returns.

import { OfflineReceiptQueue } from "zimra-fdms";

const queue = new OfflineReceiptQueue(device);

// Returns the submitted receipt, or undefined if it was queued instead.
const sale = await queue.submitOrEnqueue(receiptInput);
if (!sale) console.warn(`Offline — ${queue.size} receipt(s) pending`);

// Later, when connectivity is back:
const { submitted, remaining, error } = await queue.flush();

How it behaves

  • Ordering first. submitOrEnqueue() drains anything already queued before attempting the new sale. If the drain does not finish, the new receipt joins the back of the queue rather than jumping ahead.
  • Only network failures queue. ECONNREFUSED, ECONNRESET, ENOTFOUND, ETIMEDOUT, EAI_AGAIN and timeouts are treated as offline. A rejection from FDMS — a bad tax id, a duplicate invoice number — throws, because queueing it would just fail again later.
  • Flush stops at the first failure. The chain is sequential; skipping a receipt would break it. flush() returns what it managed to submit, how many remain, and the error that stopped it.

Receipts are numbered, hash-chained and signed at sale time. enqueue() calls signReceipt(), advances the device counters and appends the signed receipt to the journal. The chain is fixed the moment the sale happens; flush() only delivers. receiptDate is the time of sale from the server-corrected clock.

Persistent storage: the journal

The default MemoryJournal loses pending receipts on restart, which is exactly when you need them. Node ships FileJournal, a JSONL file plus a cursor file; for SQLite or a database implement Journal:

import { OfflineReceiptQueue, FileJournal } from "zimra-fdms";
import type { Journal, JournalEntry } from "zimra-fdms";

// Node: append-only file, never rewritten.
const queue = new OfflineReceiptQueue(device, new FileJournal("./.zimra"));

// Anywhere else: a table with an autoincrement id as the cursor.
export class SqlJournal implements Journal {
  async append(record: string): Promise<void> { /* INSERT */ }
  async readFrom(cursor: number): Promise<JournalEntry[]> { /* SELECT id, record WHERE id >= cursor ORDER BY id */ }
  async commit(cursor: number): Promise<void> { /* UPDATE meta SET committed = cursor */ }
  async committed(): Promise<number> { /* SELECT committed FROM meta */ }
}

Why append-only matters after a crash: enqueue is one append, delivery is one commit, and nothing is ever rewritten. A process that dies between the two leaves a receipt to resubmit, which reconcile() settles without a duplicate, or nothing at all. It never leaves a gap in the numbering.

A 0.3.x QueueStorage (load/save snapshots) is still accepted and wrapped in a journal. Unsigned receipts from an old snapshot are signed, in order, the first time the queue loads.

The 72-hour window is yours to watch. The SDK does not expire queued receipts. If a device stays offline past the grace period, submission will be refused. Alert on queue.size and queue.oldestPendingAgeMs.

Amounts#

FDMS signs over amounts in cents, and a float that drifted by 1e-15 changes the canonical string and earns an RCPT010. So the SDK keeps every amount as integer cents and refuses to guess what a fractional JS number was meant to be.

import { cents } from "zimra-fdms";

lines: [
  { name: "Consulting", price: 115, quantity: 1, taxId: 513, taxPercent: 0 },        // whole units: exact
  { name: "Bread", price: cents("2.50"), quantity: 2, taxId: 1, taxPercent: 15 },    // decimal string: exact
  { name: "Milk", price: 1.25, quantity: 1, taxId: 1, taxPercent: 15 },              // throws: fractional float
],
payments: [{ moneyType: "Cash", amount: cents("120.00") }],

The error names the field: lines[2].price: 1.25 has a fractional part. Pass cents("1.25") or a whole number of major units. Tax is computed per line in cents and rounded per line, as FDMS does; totals, payments and counters are summed in cents.

Amounts from JSON

A number in a JSON file or HTTP body was exact decimal text before it was parsed. receiptInputFromJson(input) converts numbers with up to two decimal places to Money and refuses anything finer. The CLI and the MCP server do this at the edge, so "price": 2.5 in a receipt file works.

HelperDoes
cents("11.50")Parses a decimal string with up to two places. Returns { cents: 1150 }.
fromCents(1150)Wraps a value already in cents.
amountToCents(v, field)Cents from a number | Money; throws on a fractional number, naming field.
receiptInputFromJson(input)Converts every amount in a ReceiptInput that came through JSON.
formatCents(1150)"11.50", for display.

Crash safety#

A POS process can die at any instruction: power cut, OOM, a tablet going to sleep mid-request. The dangerous window is between sending a receipt and learning whether FDMS took it. Guess wrong one way and the next receipt reuses a global number (RCPT012, Red, day unclosable); guess wrong the other way and a number is skipped.

Give the device a Storage and it removes the guess. Before every SubmitReceipt it writes a pending-submit marker holding the signed receipt and the state that will apply once it is accepted. After the response it commits the state and deletes the marker. On startup, reconcile() looks for a marker and settles it against FDMS.

import { FiscalDevice } from "zimra-fdms";

const device = new FiscalDevice(identity, pems, { environment: "test", stateDir: "./.zimra" });

const r = await device.reconcile();   // every start, before the first receipt
// r.action: "none" | "confirmed" | "resubmitted" | "rejected"

await device.submitReceipt(input);    // marker -> request -> commit -> marker deleted

What reconcile() does

Found on diskAction
Day state, no markerLoads the state. action: "none".
Marker, FDMS already holds that global numberThe answer was lost after FDMS accepted. Commits the state. "confirmed".
Marker, FDMS does not hold itThe request never arrived. Sends the identical signed receipt again. "resubmitted".
Marker, FDMS refuses the resubmitCounters stay where they were; the receipt is dropped. "rejected" with the error.

While a marker exists, submitReceipt(), openDay() and closeDay() throw PendingSubmitError rather than compound the problem. A server rejection (4xx) during a normal submit clears the marker itself, since FDMS answered and did not take the receipt.

Storage

Three keys are ever written: day-state, pending-submit and last-receipt-global-no. Each set must be atomic for its key. Node ships FileStorage, which writes to a temp file and renames, and uses the same file names the CLI profile has always used (day-state.json, last-receipt-global-no.json), so a profile directory is a valid store. In-memory is MemoryStorage. zimra-fdms-react-native ships no storage; on a phone, implement Storage over SQLite as shown under Android.

The highest number ever issued is kept locally. GetStatus.lastReceiptGlobalNo is the number of the receipt with the latest date, not the highest number, so after a future-dated receipt it under-reports. openDay() takes the larger of the server's value, the stored value and opts.lastReceiptGlobalNo. The same quirk means reconcile() can take the resubmit path for a receipt FDMS already has, which FDMS then answers with a Red RCPT012; it is rare and only follows an RCPT031.

Runtimes#

The fiscal engine has no platform imports: no node:*, no Buffer, no fetch, no structuredClone. SHA-256, MD5 and base64 are plain TypeScript. Everything a platform differs on goes through four small interfaces, and Node is one adapter over the engine, not the other way round.

ImportWhat you getRuns on
zimra-fdmsThe Node bundle: core plus PemSigner, NodeTransport, FileStorage, FileJournal, the CLI and the MCP server. What 0.3.x code imports.Node 18+
zimra-fdms/coreFiscalDevice, signing, counters, hash chain, QR, queue, buildCsr. You supply a Signer and a Transport.Anything with ES2020
zimra-fdms/simulatorA local FDMS. See Simulator.Node
zimra-fdms-react-nativeKeystoreSigner and OkHttpTransport over a Kotlin module, plus everything in zimra-fdms/core. See Android.Android 8+

Keys that cannot leave the hardware

0.3.x assumed the private key was an exportable PEM, which ruled out Android Keystore, StrongBox, an HSM and cloud KMS. Now the core never sees key material. It asks a Signer for signatures, and builds the PKCS#10 registration request itself with a small DER writer so the Signer can sign that too. A key generated inside a secure element registers, signs receipts and authenticates the TLS connection without ever being exported.

import { FiscalDevice, buildCsr, registerDevice } from "zimra-fdms/core";
import type { Signer, Transport } from "zimra-fdms/core";

const signer: Signer = {
  sign: (data) => hsm.signEcdsaSha256Der(slot, data),     // DER ECDSA P-256 over SHA-256 of data
  publicKeySpki: () => hsm.publicKeyDer(slot),            // SubjectPublicKeyInfo
};
const transport: Transport = myMtlsTransport;              // owns TLS with the same key

const { certificatePem } = await registerDevice(identity, "ACTIVKEY", signer, { transport });
const device = new FiscalDevice(identity, { signer, transport, storage });

On Android, zimra-fdms-react-native supplies both: a Signer over a key in Android Keystore, and a Transport over OkHttp that presents the same key for mutual TLS. See Android.

Server-corrected time

A device with a wrong clock produces receipts FDMS dates in the future (RCPT031) and then cannot date anything after them (RCPT030, Red). FiscalDevice learns the offset to FDMS from every response Date header and stamps receipts and day-open times with server time. device.clock.offsetMs and .confidence are readable.

Android#

zimra-fdms-react-native runs the fiscal core on Hermes and gives three jobs to a Kotlin module: hold the device key, sign bytes with it, and run HTTPS with that key as the TLS client identity. The key is ECDSA P-256, generated inside Android Keystore with PURPOSE_SIGN only, in StrongBox when the phone has one. It cannot be exported, so it cannot be copied to another phone. Hermes has no WebCrypto and no TextEncoder, and the core needs neither.

Install it as shown under Installation. Every export is in the API reference.

Register once

import { KeystoreSigner, registerDevice } from "zimra-fdms-react-native";

const identity = { deviceId: 12345, serialNumber: "MYPOS001", modelName: "Android", modelVersion: "1" };
const alias = "zimra-device-12345";

const signer = await KeystoreSigner.ensure(alias);   // creates the key on first run, reuses it after
const { certificatePem } = await registerDevice(identity, "ACTIVKEY", signer, { environment: "test" });

// certificatePem is public, so store it anywhere. The key never leaves the keystore.

registerDevice builds the CSR in the core, has the keystore sign it, and sends it through OkHttpTransport. It returns certificatePem, csrPem, commonName and operationId. There is no keys, because there is no key material to hand back. Keep the alias. It is how the app finds the key again after a restart.

Every day

import { createFiscalDevice } from "zimra-fdms-react-native";

const device = createFiscalDevice(identity, { alias, certificatePem }, { environment: "test" });

await device.getConfig();   // tax table and qrUrl
await device.openDay();
const sale = await device.submitReceipt({ /* the same ReceiptInput as on Node */ });
await device.closeDay();

createFiscalDevice puts a KeystoreSigner and an OkHttpTransport into the core FiscalDevice. It takes no storage, so day state lives in memory. Persist device.getState() after every receipt and call restoreState() on launch, or give the device a Storage as below. Receipt dates come from the server-corrected clock, so a phone with the wrong time still gets Green receipts.

Surviving a killed app

Android kills apps in the background. The dangerous moment is the same as on a server: after a receipt has gone out and before its answer comes back. reconcile() settles it, but only when the device has a Storage. This package ships none, and createFiscalDevice does not take one, so build the device directly:

import { FiscalDevice, KeystoreSigner, OkHttpTransport } from "zimra-fdms-react-native";
import type { Storage } from "zimra-fdms-react-native";

// kv(key TEXT PRIMARY KEY, value TEXT). Each statement is atomic in SQLite.
class SqliteStorage implements Storage {
  async get(key: string): Promise<string | null> { /* SELECT value FROM kv WHERE key = ? */ }
  async set(key: string, value: string): Promise<void> { /* INSERT OR REPLACE INTO kv VALUES (?, ?) */ }
  async delete(key: string): Promise<void> { /* DELETE FROM kv WHERE key = ? */ }
}

const device = new FiscalDevice(
  identity,
  {
    signer: new KeystoreSigner(alias),
    transport: new OkHttpTransport({ alias, certificatePem }),
    storage: new SqliteStorage(),
  },
  { environment: "test" },
);

await device.reconcile();   // every launch, before the first receipt

Without a Storage, reconcile() returns { action: "none" } and does nothing. The offline queue has the same gap. Its default MemoryJournal loses receipts when the app dies, so implement Journal over the same database before selling without signal.

Key attestation and StrongBox

const signer = await KeystoreSigner.ensure(alias, { requireStrongBox: true });
const chain = await signer.attestationChain();   // PEM strings, root last

By default the key goes into StrongBox when the phone has one and into the TEE otherwise. requireStrongBox: true makes key creation fail instead of falling back, and needs Android 9 or later. ensure() creates a key only when the alias has none, so the option matters on first run.

attestationChain() returns an empty array when the phone cannot attest. From a non-empty chain, a bank or auditor can check that the key is hardware-backed and whether the TEE or StrongBox holds it. The attestation challenge is the alias encoded as UTF-8.

Renewing the certificate

device.renewCertificate() signs a new CSR with the same keystore key and returns { certificatePem, csrPem, operationId }. Store the new certificate and build a new device with it. The key and the alias stay the same. See certificate renewal.

Errors from the native module

An answer from FDMS never becomes a native error. The module resolves with the status and body, and the core turns a non-2xx status into FdmsApiError, as on Node. The native module rejects only when it cannot get an answer or cannot use the key:

CodeWhenWhat your code sees
NETWORKAny IOException from OkHttp: connect, DNS, timeout, and a failed TLS handshake.TransportError. The offline queue journals the receipt, and reconcile() settles a lost answer.
HTTPAnything else while building the request: a malformed URL, a certificate PEM that does not parse, an alias with no key.The native error, unchanged.
KEYSTORECreating, finding, deleting or reading a key, including no StrongBox when requireStrongBox is set.The native error, unchanged.
SIGNSigning fails, for example because the alias has no key.The native error, unchanged.

A failed handshake counts as NETWORK. A key that cannot authenticate TLS therefore looks like a dead connection, and receipts pile up in the queue; see the DIGEST_NONE entry in spec gotchas. If the module is not linked, the first native call throws ZimraFdms native module not linked; rebuild the app after installing zimra-fdms-react-native.

Against the simulator

Start the simulator with --host 0.0.0.0 --san 10.0.2.2 and an emulator reaches it at https://10.0.2.2:8443, passed as baseUrl. OkHttpTransport trusts the system CA store and has no ca option, though, so the app must trust the simulator's CA certificate itself, for example through a debug network security config. That setup is untested. The emulator check runs the plain-Java twin of the module against the simulator instead, with the CA passed in directly.

Beta. The keystore and mutual-TLS logic has run on an Android 16 emulator through packages/react-native/android/check, a plain-Java twin of the Kotlin module built with the SDK tools alone. npm run android:check drives the real core through it against the simulator: a keystore key, a CSR it signed, RegisterDevice, mutual TLS with the keystore key, receipts, a lost answer settled by reconcile(), and a signed CloseDay. CI typechecks the TypeScript bindings against React Native's types. The Kotlin module itself has not had a Gradle build yet, so its first build in your app is its first build anywhere.

Certificate renewal#

Device certificates expire. getConfig() reports certificateValidTill; renew before it passes, because an expired certificate means no mTLS, which means no fiscalisation at all.

const config = await device.getConfig();
const validTill = new Date(config.certificateValidTill);
const daysLeft = (validTill.getTime() - Date.now()) / 86_400_000;

if (daysLeft < 30) {
  const { certificatePem } = await device.renewCertificate();   // same key, new certificate
  await saveCertificate(certificatePem);
  // Reconnect with a new FiscalDevice built from the new certificate.
}

renewCertificate() signs a new CSR with the device's existing key and calls IssueCertificate. The key does not change, which is what lets a key in Android Keystore or an HSM renew at all, so only the certificate needs storing. The existing instance keeps presenting the old certificate until you replace it.

On Node, renewWithNewKey() generates a fresh key pair and CSR instead, which is what renewCertificate() did in 0.3.x. Persist both new values together:

const { keys, certificatePem } = await device.renewWithNewKey();
await saveSecret("device-private-key.pem", keys.privateKeyPem);
await saveSecret("device-certificate.pem", certificatePem);

Renew outside a fiscal day. Swapping identities mid-day means the receipts before and after the swap were signed by different certificates. Close the day, renew, then open the next one.

FDMS also signals this itself: error code DEV02 means the certificate is about to expire.

Error handling#

Every non-2xx response becomes an FdmsApiError, which carries the HTTP status, ZIMRA's problem body, the correlation id, and — where the code is known — a plain-language hint.

import { FdmsApiError } from "zimra-fdms";

try {
  await device.submitReceipt(input);
} catch (err) {
  if (err instanceof FdmsApiError) {
    console.error(err.message);        // detail + (errorCode: X) + hint
    console.error(err.status);         // HTTP status
    console.error(err.problem);        // raw ApiProblemDetails from FDMS
    console.error(err.operationId);    // quote this to ZIMRA support
    console.error(err.hint);           // undefined for unmapped codes
  }
  throw err;
}

explain() and the support code

Every FdmsApiError also carries explain() and a supportCode. Validation errors on an accepted receipt go through explainValidationError():

import { FdmsApiError, explainValidationError } from "zimra-fdms";

const { colour, cause, fix, dayStillClosable, supportCode } = err.explain();
// colour: "Red" | "Yellow" | "Grey" | "Unknown"
// supportCode: "RCPT030-0HNOABB4T00A3", the thing a cashier reads out

for (const v of sale.response.validationErrors ?? []) {
  const ex = explainValidationError(v, sale.response.operationID);
  if (!ex.dayStillClosable) alert(`${ex.cause} ${ex.fix}`);
}

The catalogue

Exported as ERROR_CATALOGUE. Red: the receipt was accepted but the device can no longer close the day. Yellow: accepted, warning only. Grey: the request was refused. Entries marked observed were seen on the test environment; the rest come from the documentation.

CodeColourCauseObserved
RCPT010RedDevice signature does not verify: canonical string or hash chain differs.
RCPT011RedreceiptCounter not the next number in the day.
RCPT012RedreceiptGlobalNo not one more than the highest FDMS holds. Usually follows opening from an under-reported lastReceiptGlobalNo.yes
RCPT013RedTax ID not in the device tax table.
RCPT014YellowreceiptDate earlier than the day was opened.yes
RCPT020RedTotal does not equal the lines (plus tax when exclusive).
RCPT021RedPayments do not add up to the total.
RCPT030RedreceiptDate not later than the previous receipt's.yes
RCPT031YellowreceiptDate ahead of server time. Every later receipt must be dated after it.yes
DEV01GreyDevice not active: blacklisted, suspended or not yet approved.
DEV02GreyCertificate about to expire; renew.
FDC01GreyA fiscal day is already open.
FDC02GreyNo fiscal day is open.
BadCertificateSignatureGreyCloseDay signed over the wrong date. FDMS signs over the day's opening date, which GetStatus never reports.yes
ReceiptsWithValidationErrorsGreyA Red receipt is in the day; only ZIMRA can close it.yes

Errors thrown before the network

Some failures are caught locally and are plain Errors, not FdmsApiError:

  • Cannot open a fiscal day while status is …openDay() on a device that is not closed.
  • No fiscal day state. Call openDay(), or restoreState() … — submitting or closing without state.
  • Payments (x) do not equal receipt total (y) — the payment check.
  • lines[i].price: 1.25 has a fractional part — see Amounts.
  • PendingSubmitError — a previous submit is unresolved; call reconcile(). See Crash safety.
  • FDMS request timed out after 30000ms — transport timeout, tunable via timeoutMs.

A practical triage order

  1. Read err.hint — if it is set, it names the cause.
  2. Check getStatus(). Most day-related errors are a state mismatch.
  3. For RCPT010, compare your canonical string against receiptSigningString() and check the previous hash.
  4. Keep the operationId. It is the only handle ZIMRA support can trace.

FiscalDevice#

The high-level client. One instance represents one registered device and owns the fiscal-day state for it.

new FiscalDevice(device, identity, options?)

ParameterTypeNotes
deviceDeviceIdentitydeviceId, serialNumber, modelName, modelVersion. Model name and version are sent as request headers.
identityMtlsIdentity | FiscalDeviceDepsNode: { certificatePem, privateKeyPem } from registration or renewal. Core, on any platform: { signer, transport, storage?, clock? }. On Android, see createFiscalDevice.
options.environment"test" | "production"Default "test".
options.baseUrlstringOverrides the environment URL, for example the simulator.
options.timeoutMsnumberRequest timeout. Default 30000.
options.retriesnumberRetries for GET calls and Ping after a network failure, with jittered backoff. Default 2. SubmitReceipt, OpenDay and CloseDay are never retried.
options.stateDirstringNode only. A directory for FileStorage, which turns on crash safety.
options.signatureFormat"der" | "p1363"Default "der". Leave it alone unless you are debugging signatures.

Configuration & status

getConfig(): Promise<GetConfigResponse>async

Fetches taxpayer details, the applicable tax table, operating mode, certificate expiry and qrUrl. The response is cached on the instance and is what makes qrData available on submitted receipts — call it once at startup.

getStatus(): Promise<GetStatusResponse>async

Current fiscal day status, the server's counters, lastReceiptGlobalNo and lastFiscalDayNo. The source of truth when local state is missing or suspect.

ping(): Promise<PingResponse>async

Liveness check against FDMS. Also returns reportingFrequency, the server's view of how often this device should be reporting.

Fiscal day

openDay(fiscalDayNo?, opened?): Promise<OpenDayResponse>asyncthrows

Opens a fiscal day and initialises local state. Calls getStatus() first and throws if the device is not FiscalDayClosed.

ParameterTypeDefaultNotes
fiscalDayNonumber?nullOmit to let FDMS assign the next number.
openedDatenew Date()Opening timestamp; also sets fiscalDayDate.

closeDay(): Promise<CloseDayResponse>asyncthrows

Signs the accumulated counters and closes the day, then drops the in-memory state. A stored copy stays until clearPersistedState(). Zero-value counters are excluded from both the payload and the signing string. Throws if there is no fiscal day state. Remember that the close settles asynchronously — poll getStatus().

Receipts

submitReceipt(input): Promise<SubmittedReceipt>asyncthrows

Builds, signs and submits a receipt, then advances counters and the hash chain — but only after FDMS accepts it. See receipts & signing for the full input shape.

ReturnsTypeNotes
receiptReceiptThe complete signed receipt that was sent.
responseSubmitReceiptResponsereceiptID, serverDate, server signature, validationErrors.
qrDatastring?QR content. undefined unless getConfig() has been called.

State

getState(): FiscalDayState | undefined

A deep clone of the current day state, or undefined when no day is open. Persist this after every receipt.

restoreState(state): void

Restores persisted state on startup. Stores a clone, so the object you pass stays yours.

reconcile(): Promise<ReconcileResult>async

Loads stored day state and settles a submit that a crash or a lost answer left unresolved. action is "none", "confirmed", "resubmitted" or "rejected". Needs a Storage; without one it returns "none". See crash safety.

clearPersistedState(): Promise<void>async

Deletes the stored day state. closeDay() keeps it, because the close settles asynchronously and a rejected close needs the counters for a retry. Call this once getStatus() reports FiscalDayClosed.

Certificates

renewCertificate(signer?): Promise<{ certificatePem, csrPem, operationId }>async

Signs a new CSR with the device's key, or with signer when given, and calls IssueCertificate. The key pair stays the same, so a key in Android Keystore or an HSM survives renewal. Persist the certificate and rebuild the device with it. See certificate renewal.

renewWithNewKey(): Promise<{ keys, certificatePem, operationId }>async

Node only. Generates a fresh exportable key pair and CSR, as renewCertificate() did in 0.3.x. Persist both returned values.

Exported helpers

buildReceiptTaxes(lines, taxInclusive): ReceiptTax[]

Groups receipt lines into the tax summary FDMS expects, sorted by taxID. Called for you by submitReceipt(); exported for testing and for building receipts by hand.

accumulateCounters(counters, receipt): void

Folds a submitted receipt into the running fiscal-day counters, in place. Credit notes are accumulated negatively. Also exported for testing and manual day reconstruction.

registerDevice()#

The bootstrap calls. Neither requires a client certificate.

registerDevice(device, activationKey, options?): Promise<RegisteredDevice>asyncthrows

Node. Generates an exportable key pair and a CSR, registers the device, and returns the issued certificate with the keys.

ParameterTypeNotes
deviceDeviceIdentityModel name and version are sent as headers.
activationKeystring8 characters, case-insensitive, single use.
options.environment"test" | "production"Default "test".
options.clientPrefixstringCN prefix. Default "ZIMRA".
ReturnsTypeNotes
keysDeviceKeyPairprivateKeyPem, publicKeyPem, csrPem, commonName.
certificatePemstringThe issued device certificate.
operationIdstringZIMRA correlation id.

registerDevice(device, activationKey, signer, options?): Promise<RegisteredDevice>asyncthrows

The Signer form, for a key that already exists and may not be exportable: Android Keystore, an HSM, a cloud KMS. buildCsr writes the CSR and signer signs it. Returns { certificatePem, csrPem, commonName, operationId } with no keys. Exported from zimra-fdms/core, and from zimra-fdms as an overload.

OptionTypeNotes
environment"test" | "production"Default "test".
baseUrlstringOverrides the environment URL.
clientPrefixstringCN prefix. Default "ZIMRA".
transportTransportFor the Public endpoints, which need no client certificate. Default FetchTransport. The Android wrapper defaults to OkHttpTransport.

getServerCertificate(environment?, thumbprint?): Promise<string[]>async

Fetches the FDMS server certificate chain, used to validate the server's signatures on submitted receipts. environment defaults to "test"; pass a thumbprint to request a specific certificate.

OfflineReceiptQueue#

new OfflineReceiptQueue(device, journal?)

journal is a Journal (default new MemoryJournal()) or a 0.3.x QueueStorage, which is wrapped. Pending receipts are loaded lazily on the first operation; if the journal is ahead of the device state (a crash between append and commit), the device state is caught up.

oldestPendingAgeMs: number | undefined

Milliseconds since the oldest pending receipt was issued. Alert well before 72 hours.

size: number

Receipts currently waiting. Reflects what has been loaded into the instance — read it after an enqueue(), submitOrEnqueue() or flush() call.

enqueue(input): Promise<PreparedReceipt>async

Numbers, hash-chains and signs the receipt now, advances the device counters, and appends it to the journal. Does not attempt submission.

submitOrEnqueue(input): Promise<SubmittedReceipt | undefined>asyncthrows

Flushes anything already queued, then submits. Returns undefined when the receipt was queued instead of submitted. Only network-class failures queue — anything FDMS actively rejected is rethrown.

flush(): Promise<FlushResult>async

Submits queued receipts FIFO, stopping at the first failure, and commits the journal cursor after each success. A receipt whose previous attempt left a pending marker on the device is settled through reconcile() rather than sent twice.

ReturnsTypeNotes
submittedSubmittedReceipt[]Receipts accepted during this flush.
remainingnumberStill queued. 0 means fully drained.
errorunknown?Set only when the flush stopped early.

QueueStorage (0.3.x)

Still accepted; see Journal for the current interface.

interface QueueStorage

MethodContract
load()Returns pending receipts, oldest first.
save(pending)Replaces the stored snapshot with pending. An empty array clears the queue.

class MemoryQueueStorage

The default. Holds receipts in memory and loses them on restart. Fine for tests and for always-online deployments; replace it anywhere a crash could lose a sale.

Signing utilities#

The canonical-string layer. FiscalDevice uses these internally; they are exported because when FDMS returns RCPT010, being able to print the exact string you signed is the difference between a fix and a guess.

receiptSigningString(deviceId, receipt, previousReceiptHash?): string

Builds the canonical receipt string. receipt is a Receipt without its signature. Omit previousReceiptHash for the first receipt of a fiscal day.

fiscalDaySigningString(deviceId, fiscalDayNo, fiscalDayDate, counters): string

Builds the canonical day-close string. Filters zero counters, then sorts by counter-type priority, currency, tax id, and finally money type in enum order — Cash, Card, MobileWallet, Coupon, Credit, BankTransfer, Other.

concatenateReceiptTaxes(taxes): string

The tax block of the receipt string: sorted by taxID, each entry rendered as percent (2 dp, empty when exempt) + tax amount in cents + sales amount in cents.

signCanonicalString(signer, canonical, format?): Promise<SignatureData>async

Signs through signer and returns { hash, signature }, both base64. format defaults to "der". To sign with a PEM, wrap it: new PemSigner(privateKeyPem).

p1363ToDer(p1363): Uint8Array

Converts a raw r||s signature — what WebCrypto emits — into the ASN.1 DER encoding FDMS verifies.

sha256Base64(data): string

base64(SHA-256(utf8 data)) — the hash field of SignatureData, and the value that chains one receipt to the next.

Formatting helpers

FunctionReturnsPurpose
toCents(amount)numberInteger cents, guarded against float drift.
formatTaxPercent(percent)stringFixed 2 dp, e.g. "15.00".
fdmsDateTime(date?)stringYYYY-MM-DDTHH:mm:ss, local, no timezone.
fdmsDate(date?)stringYYYY-MM-DD, for fiscal day dates.

Times are local and unqualified. FDMS receives no timezone offset, so the server reads the timestamp in its own terms. Run fiscalising processes in Africa/Harare — a container on UTC will silently stamp receipts two hours early.

Debugging a signature rejection

import { receiptSigningString, sha256Base64 } from "zimra-fdms";

const canonical = receiptSigningString(12345, unsignedReceipt, previousHash);
console.log(JSON.stringify(canonical));   // quoted, so trailing spaces show
console.log(sha256Base64(canonical));

Crypto, QR & HTTP#

Crypto

generateDeviceCsr(serialNumber, deviceId, client?): Promise<DeviceKeyPair>async

Generates an ECDSA P-256 key pair and a CSR signed with ecdsa-with-SHA256. client defaults to "ZIMRA". Node only. Called for you by renewWithNewKey().

deviceCommonName(serialNumber, deviceId, client?): string

The exact CN FDMS requires: ZIMRA-MYPOS001-0000012345. Useful for verifying a certificate you were issued elsewhere.

QR

receiptQrData(params): string

Builds the QR content string. submitReceipt() calls this for you when getConfig() has run.

ParameterTypeNotes
qrUrlstringFrom getConfig(). A trailing slash is added if missing.
deviceIdnumberRendered zero-padded to 10 digits.
receiptDateDateRendered ddMMyyyy.
receiptGlobalNonumberRendered zero-padded to 10 digits.
deviceSignatureBase64stringThe receipt signature; its first 16 MD5 hex characters end the string.

The result is what a customer scans to verify the invoice on ZIMRA's portal — encode it into a QR image with any renderer you like:

https://fdmsapitest.zimra.co.zw/0000037367290720260000000123a1b2c3d4e5f60718

HTTP

class FdmsClient

Typed FDMS calls over any Transport: builds device paths and headers, parses JSON, turns problem-details bodies into FdmsApiError, and feeds response Date headers to the clock. Reach for it only when you need an endpoint the SDK does not wrap yet.

MemberSignatureNotes
constructor(device, transport, options?)options.baseUrl overrides the environment URL.
request<T>(method, path, body?) => Promise<T>"GET" or "POST". Throws FdmsApiError on non-2xx.
devicePath(endpoint) => stringBuilds /Device/v1/{deviceId}/{endpoint}.
publicPath(endpoint) => stringBuilds /Public/v1/{deviceId}/{endpoint}.
import { FdmsClient, NodeTransport } from "zimra-fdms";

const http = new FdmsClient(device, new NodeTransport({ certificatePem, privateKeyPem }));
const res = await http.request("POST", http.devicePath("Ping"));

Signer, Transport, Clock, Storage, Journal#

The five interfaces a platform implements. Exported from zimra-fdms/core; the Node implementations are exported from zimra-fdms.

interface Signer

MethodContract
sign(data: Uint8Array)ASN.1 DER ECDSA P-256 signature over SHA-256 of data. The implementation hashes. FDMS rejects raw P1363.
publicKeySpki()The public key as DER SubjectPublicKeyInfo, for the CSR.

Node: PemSigner(privateKeyPem), generatePemSigner(). Android: KeystoreSigner. Helpers: p1363ToDer, derToP1363, signCanonicalString(signer, canonical), buildCsr(signer, commonName).

interface Transport

request({ method, url, headers, body?, timeoutMs }) returns { status, headers, text } with lower-cased header names. The adapter owns TLS, including the client identity, which must be the same key as the Signer. Throw TransportError for connect, DNS and timeout failures so the offline queue can tell them from rejections. Node: NodeTransport(identity?, { ca?, keepAliveMs? }), one keep-alive agent per device. Any runtime with fetch: FetchTransport for the Public endpoints. Android: OkHttpTransport.

interface Clock

now(): Date. FiscalDevice wraps it in ServerCorrectedClock, which learns the offset from FDMS response dates; offsetMs and confidence are readable on device.clock.

interface Storage

get(key), set(key, value), delete(key) over strings. Each set must be atomic for its key. Node: FileStorage(dir). In memory: MemoryStorage. Tests: guardedStorage(inner, guard) to kill at a chosen write. Android: none shipped; Android sketches one over SQLite.

interface Journal

append(record), readFrom(cursor), commit(cursor), committed(). Append-only; records are single-line strings. Node: FileJournal(dir, name?) with compact(). In memory: MemoryJournal. A 0.3.x QueueStorage is wrapped by journalFromQueueStorage(). Android: none shipped.

zimra-fdms-react-native#

The Android adapter. Its own exports are the four below. It also re-exports all of zimra-fdms/core, so FiscalDevice, OfflineReceiptQueue, MemoryJournal, TransportError, cents and the types import from the same package. Its registerDevice replaces the core one. Native error codes are listed under Android.

class KeystoreSigner implements Signer

MemberSignatureNotes
ensurestatic (alias, options?) => Promise<KeystoreSigner>Creates an ECDSA P-256 key under alias if there is none. Safe to call on every launch. options.requireStrongBox, default false, fails instead of falling back to the TEE.
constructor(alias)Wraps a key that should already exist. Nothing is checked until the first native call.
aliasstringRead-only.
sign(data: Uint8Array) => Promise<Uint8Array>DER ECDSA signature over SHA-256 of data, computed inside the keystore.
publicKeySpki() => Promise<Uint8Array>DER SubjectPublicKeyInfo, for the CSR.
attestationChain() => Promise<string[]>PEM certificates, root last. Empty when the phone cannot attest.
delete() => Promise<void>Deletes the key and the cached HTTP clients for it. The certificate issued for the key becomes useless, and registering again needs a fresh activation key.

new OkHttpTransport(identity?)

identity is { alias, certificatePem }. With it, the keystore key and the FDMS certificate form the TLS client identity for the Device endpoints. Without it, the transport is a plain HTTPS client for the Public endpoints, which is what registerDevice uses. The module keeps one OkHttp client per alias, certificate and timeout so TLS sessions are reused, allows only OkHttp's MODERN_TLS connection spec, and trusts the system CA store. Connect, DNS, timeout and handshake failures throw TransportError.

registerDevice(device, activationKey, signer, options?): Promise<RegisteredDevice>asyncthrows

The core registerDevice with signer typed as KeystoreSigner and options.transport defaulting to new OkHttpTransport(). Returns { certificatePem, csrPem, commonName, operationId }.

createFiscalDevice(device, identity, options?): FiscalDevice

A core FiscalDevice with new KeystoreSigner(identity.alias) as signer and new OkHttpTransport(identity) as transport. identity is { alias, certificatePem }. options takes environment, baseUrl, timeoutMs, retries and signatureFormat. There is no storage or clock parameter. For either, construct FiscalDevice directly, as shown under Android.

Types & enums#

Every type below is exported from the package root. They are derived from ZIMRA's published OpenAPI specs, which live in spec/ in the repository.

Enums

TypeValues
FdmsEnvironment"test", "production"
ReceiptType"FiscalInvoice", "CreditNote", "DebitNote"
ReceiptLineType"Sale", "Discount"
MoneyType"Cash", "Card", "MobileWallet", "Coupon", "Credit", "BankTransfer", "Other"
FiscalDayStatus"FiscalDayClosed", "FiscalDayOpened", "FiscalDayCloseInitiated", "FiscalDayCloseFailed"
DeviceOperatingMode"Online", "Offline"
FiscalCounterType"SaleByTax", "SaleTaxByTax", "CreditNoteByTax", "CreditNoteTaxByTax", "DebitNoteByTax", "DebitNoteTaxByTax", "BalanceByMoneyType", "PayoutByTax", "PayoutTaxByTax"

FDMS_BASE_URLS is exported as a constant map from environment to base URL.

Core interfaces

TypeShape
DeviceIdentitydeviceId, serialNumber, modelName, modelVersion
MtlsIdentitycertificatePem, privateKeyPem
DeviceKeyPairprivateKeyPem, publicKeyPem, csrPem, commonName
SignatureDatahash, signature — both base64
SignatureDataExSignatureData + certificateThumbprint
TaxDefinitiontaxID, taxName, taxPercent, validFrom, validTill
FiscalDayCounterfiscalCounterType, fiscalCounterCurrency, fiscalCounterTaxPercent, fiscalCounterTaxID, fiscalCounterMoneyType, fiscalCounterValue
ReceiptLinereceiptLineType, receiptLineNo, receiptLineName, receiptLinePrice, receiptLineQuantity, receiptLineTotal, taxID, taxPercent, taxCode, receiptLineHSCode
ReceiptTaxtaxID, taxPercent, taxCode, taxAmount, salesAmountWithTax
PaymentmoneyTypeCode, paymentAmount
BuyerDatabuyerRegisterName, buyerTradeName, vatNumber, buyerTIN, buyerContacts, buyerAddress
CreditDebitNotereceiptID, deviceID, receiptGlobalNo, fiscalDayNo
ValidationErrorvalidationErrorCode, validationErrorColor, validationErrorDescription

Response types

TypeKey fields
GetConfigResponsetaxPayerName, taxPayerTIN, vatNumber, deviceSerialNo, deviceBranchName, deviceBranchAddress, deviceOperatingMode, taxPayerDayMaxHrs, applicableTaxes, certificateValidTill, qrUrl, taxpayerDayEndNotificationHrs
GetStatusResponsefiscalDayStatus, fiscalDayCounter, lastReceiptGlobalNo, lastFiscalDayNo, fiscalDayClosed, fiscalDayClosingErrorCode, fiscalDayServerSignature, fiscalDayDocumentQuantities
OpenDayResponseoperationID, fiscalDayNo
CloseDayResponseoperationID
SubmitReceiptResponseoperationID, receiptID, serverDate, receiptServerSignature, validationErrors
PingResponseoperationID, reportingFrequency

Errors

class FdmsApiError extends Error

PropertyTypeNotes
statusnumberHTTP status code.
problemApiProblemDetails?ZIMRA's response body, when it was JSON.
operationIdstring?From the operationid response header.
hintstring?Plain-language explanation for known codes.

FDMS_ERROR_HINTS is the exported code-to-hint map behind hint — see error handling.

CLI — overview & profile#

The package ships a zimra-fdms command. It is the fastest route to a first fiscalised receipt, and enough for ops work — checking device status, recovering a stuck fiscal day — without writing any code.

npx zimra-fdms register --device-id 12345 --serial MYPOS01 --activation-key AAAABBBB
npx zimra-fdms config                       # taxpayer info + valid taxIds
npx zimra-fdms day open
npx zimra-fdms submit --sample > receipt.json   # edit to match your taxes
npx zimra-fdms submit receipt.json          # prints receipt no + QR data
npx zimra-fdms day close

The profile directory

A CLI process dies between invocations, but the receipt hash chain and fiscal-day counters must not. Everything lives in a profile directory — ./.zimra by default, overridden with --profile <dir> or the ZIMRA_PROFILE environment variable.

FileContents
device.jsonDevice identity and environment.
device-certificate.pemThe mTLS certificate issued by FDMS.
device-private-key.pemThe matching private key, written 0600 where the OS supports it.
day-state.jsonThe open fiscal day: counters, hash chain, receipt numbers.
pending-submit.jsonA receipt whose submit is unresolved. Present only between a request and its answer, or after a crash; every command reconciles it first.
last-receipt-global-no.jsonHighest global number ever issued, kept across days.

Keep .zimra/ out of version control. It contains the device private key. Add it to .gitignore before the first register.

Receipts chain correctly across separate invocations, and the day-state file is validated against the profile's device id — a state file from another device is refused rather than silently used.

Global options

OptionNotes
--profile <dir>Profile directory. Default ./.zimra, env ZIMRA_PROFILE.
--jsonMachine-readable output. Supported by status, config and submit.
-h, --helpHelp for a command.
-v, --versionPrint the package version.

Environment variables

VariableNotes
ZIMRA_PROFILEProfile directory when --profile is not given.
ZIMRA_BASE_URLTalk to this server instead of the environment in device.json, e.g. https://127.0.0.1:8443 from the simulator.
ZIMRA_CAPEM file with extra CA certificates to trust. The simulator writes one.

CLI — command reference#

zimra-fdms register

Registers a device and writes the certificate, key and identity into the profile.

FlagDefaultNotes
--device-id <n>Required. Positive integer from the FDMS portal.
--serial <sn>Required. Device serial number.
--activation-key <key>Required. The 8-character portal key.
--model-name <name>ServerSent as a request header.
--model-version <v>v1Sent as a request header.
--env <env>testtest or production.
--forceoffRe-register over an existing profile. Needs a fresh activation key.

zimra-fdms status

Device and fiscal-day status: current state, last receipt global number, last fiscal day, and any closing error code. Supports --json.

zimra-fdms ping

Pings FDMS and reports the server's view of the device's operating mode.

zimra-fdms config

Taxpayer information, the applicable tax table and certificate expiry. This is where you find the valid taxId values for your receipts. Supports --json.

zimra-fdms day open

Opens a fiscal day and writes day-state.json.

zimra-fdms day close

Closes the fiscal day, then polls until the close settles — up to twelve checks, three seconds apart. If day-state.json is missing, it recovers by signing the counters FDMS itself reports, so a lost state file does not strand an open day. The signature covers the date the day was opened, which FDMS does not report, so pass --date YYYY-MM-DD when recovering a day opened on an earlier date. If FDMS rejects the close, the local state is kept for a retry.

zimra-fdms submit <receipt.json>

Submits a receipt and prints the receipt number and QR data. Run zimra-fdms submit --sample to print a starter file. The JSON is a ReceiptInput — payments must sum to the receipt total, and every taxId must appear in zimra-fdms config. Supports --json.

Sample receipt file

{
  "receiptType": "FiscalInvoice",
  "currency": "USD",
  "invoiceNo": "INV-0001",
  "linesTaxInclusive": true,
  "lines": [
    {
      "name": "Bread",
      "price": 2.5,
      "quantity": 2,
      "taxId": 1,
      "taxPercent": 15,
      "taxCode": "A"
    }
  ],
  "payments": [{ "moneyType": "Cash", "amount": 5.0 }]
}

MCP server — overview & setup#

The same binary runs a Model Context Protocol server, so Claude Code — or any MCP client — can register devices, run fiscal days and submit receipts through natural language. It implements the MCP 2026-07-28 revision (the stateless core) and still serves 2025-era clients from the same process.

# add it to Claude Code
claude mcp add zimra-fdms -- npx zimra-fdms mcp

# or run it directly for any other MCP client (stdio)
npx zimra-fdms mcp --profile ./.zimra

One profile, two front-ends

The MCP server operates on the exact profile directory the CLI uses — ./.zimra by default, overridden with --profile <dir>, ZIMRA_PROFILE, or a per-call profile argument on every tool. The state FDMS actually cares about — the receipt hash chain and fiscal-day counters — lives on disk in that directory, never in the MCP session. Open a day from the terminal, submit receipts from an agent, close from either: the chain is one and the same.

The private key never crosses the wire. Tools return receipt numbers, status and QR data — not key material. And register_device refuses to overwrite an existing certificate unless explicitly forced, because re-registering invalidates it.

Errors are in-band

FDMS failures surface as MCP tool errors carrying the same plain-language hints the CLI prints — DEV01, RCPT010 and friends — so the agent can read the hint and fix its own call instead of guessing.

MCP server — tool reference#

Eight tools mirror the CLI verbs. Every tool accepts an optional profile argument; read-only tools are annotated as such so clients can skip confirmation prompts for them.

register_device

Registers a device: generates ECDSA P-256 keys and ZIMRA's exact CSR, exchanges the activation key for an mTLS certificate, and writes the profile. Arguments: deviceId, serialNumber, activationKey (8 characters), optional modelName, modelVersion, environment (test/production) and force.

get_status

Device and fiscal-day status from FDMS, plus the local day state if one exists — the same view as zimra-fdms status. Read-only.

ping

Pings FDMS over mTLS, reporting latency and the server's reporting frequency. Read-only.

get_config

Taxpayer info, operating mode, certificate expiry and the applicable tax table — the source of valid taxId values for receipt lines. Read-only.

open_fiscal_day

Opens a fiscal day and persists the day state to the profile.

close_fiscal_day

Closes the day with the locally tracked counters, polling until the asynchronous close settles. With no local day state it recovers by signing the counters FDMS itself reports — the same recovery path as zimra-fdms day close. Takes an optional fiscalDayDate (YYYY-MM-DD) for recovering a day opened on an earlier date.

submit_receipt

Signs and submits a receipt on the open day, advancing the hash chain. Takes a receipt object with the full ReceiptInput schema — lines, payments, optional buyer data and credit/debit-note references — validated before anything is signed. Returns receipt numbers, receiptID, QR data and any server validation warnings.

sample_receipt

Returns a starter ReceiptInput template to adapt against the device tax table. Read-only, no network.

The whole loop — open, chained receipts, signed close — is verified live against the FDMS test environment through a spawned MCP server on the 2026-07-28 protocol: npm run test:e2e:mcp.

Spec gotchas#

Knowledge that cost real fiscal days to earn. All of it is already handled by the SDK — this is here so that when something breaks, you know where to look.

Signatures must be ASN.1 DER

FDMS rejects raw IEEE P1363 (r||s) ECDSA signatures with RCPT020 / BadCertificateSignature. WebCrypto emits P1363, so the SDK converts to DER by default. If you sign anything yourself, convert with p1363ToDer().

The CSR subject CN format is exact

ZIMRA-{serial}-{deviceId padded to 10 digits}, ECDSA P-256 preferred. A CN that is off by a single character fails registration with an unhelpful message.

Counter order is enum order, not alphabetical

Fiscal-day counters sort by counter-type priority, then currency, then taxID — and BalanceByMoneyType sorts by the money-type enum order (Cash, Card, MobileWallet, Coupon, Credit, BankTransfer, Other), not alphabetically. One swapped pair fails the whole day-close signature.

Zero-value counters are excluded

They appear in neither the close payload nor the signing string. Including them produces a valid JSON body with an invalid signature — the hardest kind of bug to see.

The first receipt of a day has no previous hash

The previousReceiptHash segment is omitted entirely, not sent as an empty string placeholder.

Timestamps are local and unqualified

YYYY-MM-DDTHH:mm:ss with no timezone offset. Run fiscalising processes in Africa/Harare; a UTC container stamps receipts two hours early.

Receipt dates must strictly increase — at one-second resolution

FDMS rejects any receipt whose receiptDate is not strictly greater than the previous receipt's (RCPT030, red), and the format only resolves to whole seconds — so two sales in the same second fail. The SDK nudges auto-generated dates forward by a second when needed; explicit receiptDate values are trusted as given. Worse: a red-flagged receipt still consumes its global number even though GetStatus doesn't count it, so the next day opens one number short and draws RCPT012 — and a day containing any red receipt can only be closed from the test portal, not the API.

Android Keystore keys used for TLS need DIGEST_NONE

A keystore key that signs receipts with SHA256withECDSA also needs KeyProperties.DIGEST_NONE in its digests before it can be a TLS client identity. Conscrypt hashes the handshake transcript itself and signs the hash with NONEwithECDSA. Without it the handshake fails with an SSLHandshakeException reading "I/O error during system call" on the phone and a socket hang-up on the server, and keystore2 logs "Digest 0 was specified, but not authorized by key". It looks like a network fault and is not one. KeystoreSigner sets both digests. A key created under the same alias by other code keeps the digests it was created with. Delete it, let ensure() make a new one, and register again.

The test portal can rescue you

fdmsops.zimra.co.zw/fdms-public/ can force-close a stuck fiscal day and reset device activation. Invaluable during development.

Testing#

npm test              # signing rules, core primitives, crash safety, simulator, vectors, CLI, MCP
npm run lint:core     # fails if src/core references node:*, Buffer, process or fetch
npm run test:e2e      # full live cycle against the FDMS test environment
npm run test:e2e:mcp  # the same live cycle, driven through the MCP server
ZIMRA_BASE_URL=https://127.0.0.1:8443 ZIMRA_CA=zimra-simulator-ca.pem npm run test:e2e   # same cycle, simulator
npm run android:check # keystore and mutual TLS on an Android emulator, against the simulator

The suite pins the canonical signing strings, DER conversion, tax computation, counter accumulation and QR format against the published vectors; checks the pure SHA-256, MD5 and base64 against node:crypto and the CSR builder against @peculiar/x509; runs a full fiscal day through dist/core inside a VM context with no Buffer, process, require or fetch; kills the device at every storage write and network call and reconciles; and drives the SDK and the compiled CLI against the simulator over real mutual TLS. Run it before every release.

Android emulator check

npm run android:check runs scripts/android-check.sh. It needs JDK 17 or newer, the Android SDK with build-tools and a platform, bash (Git Bash on Windows), and one booted emulator or device on adb. No Gradle, Android Gradle Plugin or React Native download is needed. The script:

  1. Compiles packages/react-native/android/check, a plain-Java twin of the Kotlin module, with javac and d8, then packages and signs it with aapt2, zipalign and apksigner.
  2. Installs and starts the APK, and forwards a local port to the small HTTP bridge inside it.
  3. Runs scripts/android-emulator-e2e.ts, which starts the simulator on 0.0.0.0 with a certificate for 10.0.2.2 and drives the real core with the emulator as Signer and Transport.

It passes when a key generated in Android Keystore signs a CSR that verifies, the issued certificate carries that key, GetConfig succeeds over mutual TLS with the key as client identity, two receipts are accepted, a dropped SubmitReceipt answer reconciles as confirmed, and CloseDay settles to FiscalDayClosed. The check app mirrors ZimraFdmsModule.kt, so change both together.

VariableDefault
ANDROID_HOME or ANDROID_SDK_ROOT$LOCALAPPDATA/Android/Sdk
JAVA_HOMEAndroid Studio's bundled JBR at its default Windows path
ADB_FORWARD_PORT9999
SIM_PORT8443
ANDROID_CHECK_OUTpackages/react-native/android/check/build

CI does not run it. The android-bindings job typechecks packages/react-native against React Native's types and does not compile the Kotlin.

Spec drift watching

ZIMRA can change the FDMS API without announcing it. npm run spec:drift compares the live Swagger specs against the baseline in spec/, and a scheduled GitHub Action runs it daily. Findings are split into two tiers:

  • BREAKING — touches an endpoint or type this SDK uses. Fails the check.
  • INFO — drift elsewhere in FDMS. Reported without failing.

Comparison is on the API contract — paths, parameters, required flags, enum values, maxLength — so formatting and key-order churn do not register as drift. Run npm run spec:drift -- --update to accept the current live state as the new baseline.

Simulator#

A local FDMS with real mutual TLS, for developing and testing without a registered device. It runs a generated certificate authority, issues device certificates from the same PKCS#10 request the SDK sends ZIMRA, and verifies every receipt and CloseDay signature with the same canonical-string rules.

npx zimra-fdms-simulator --port 8443              # writes zimra-simulator-ca.pem

export ZIMRA_BASE_URL=https://127.0.0.1:8443
export ZIMRA_CA=zimra-simulator-ca.pem
npx zimra-fdms register --device-id 1 --serial DEV1 --activation-key ABCD1234
npx zimra-fdms day open
npx zimra-fdms submit receipt.json
npx zimra-fdms day close

Use 127.0.0.1, not localhost. Node 18 resolves localhost to ::1 first, and the simulator listens on IPv4. sim.start() returns the 127.0.0.1 URL for the same reason.

For an Android emulator, listen on every interface and add the emulator's address for the host to the server certificate: npx zimra-fdms-simulator --host 0.0.0.0 --san 10.0.2.2, or FdmsSimulator.create({ host: "0.0.0.0", hosts: ["10.0.2.2"] }) in code. The emulator reaches it at https://10.0.2.2:8443. The React Native module does not trust the simulator's CA on its own yet; see Android.

In a test suite, start it in-process and inject faults:

import { FdmsSimulator } from "zimra-fdms/simulator";
import { FiscalDevice, registerDevice, NodeTransport } from "zimra-fdms";

const sim = await FdmsSimulator.create({ closeDelayMs: 50 });
const { url, caPem } = await sim.start();

const reg = await registerDevice(identity, "ABCD1234", {
  baseUrl: url,
  transport: new NodeTransport(undefined, { ca: caPem }),
});
const device = new FiscalDevice(identity, { certificatePem: reg.certificatePem, privateKeyPem: reg.keys.privateKeyPem }, { baseUrl: url, ca: caPem });

sim.faults.dropAfterSubmit = 1;      // FDMS takes the receipt; the client never sees the answer
await assert.rejects(device.submitReceipt(input));
assert.equal((await device.reconcile()).action, "confirmed");

await sim.stop();

What it replays

BehaviourTrigger
RCPT010 RedSignature or hash does not verify against the device certificate.
RCPT011 RedreceiptCounter not sequential within the day.
RCPT012 RedreceiptGlobalNo not one more than the highest held.
RCPT013 RedTax ID not in the device tax table.
RCPT014 YellowreceiptDate before the day was opened.
RCPT020, RCPT021 RedTotal does not match the lines; payments do not match the total.
RCPT030 RedreceiptDate not after the previous receipt.
RCPT031 YellowreceiptDate ahead of server time (tolerance configurable).
Asynchronous closeCloseDay answers at once; status goes FiscalDayCloseInitiated then FiscalDayClosed, or back to FiscalDayOpened with fiscalDayClosingErrorCode BadCertificateSignature (signed over the wrong date) or ReceiptsWithValidationErrors (a Red receipt in the day).
GetStatus under-reportslastReceiptGlobalNo is the receipt with the latest date, not the highest number, as observed live.
FDC01, FDC02, DEV01, 401Day already open; day not open; unknown device; missing or mismatched client certificate.

Faults

Set fields on sim.faults; counters are consumed as they fire.

FieldEffect
dropConnectionsDestroy the next N connections before answering (a TransportError).
serverErrorsAnswer the next N requests with HTTP 503.
delayMsDelay every response.
dateSkewMsOffset the Date header, to test the client's clock correction.
dropAfterSubmitProcess the next N SubmitReceipts, then drop the socket so the answer is lost. The crash-safety case.

It is not ZIMRA. Passing against the simulator means the SDK is consistent with itself. The canonical-string rules are the SDK's own; the conformance vectors pin those, and the nightly run against the real test environment is the authority.

Conformance vectors#

vectors/zimra-fdms-vectors.json (also import "zimra-fdms/vectors") holds canonical strings, SHA-256 hashes, DER signatures, tax summaries and QR payloads for a fixed P-256 test key: USD and ZWG, inclusive and exclusive tax, mixed rates with an exempt line, a credit note, fractional quantities, an amount near the 32-bit cents boundary, and fiscal-day counters across currencies and money types. The first receipt vector is receipt 24 on device 37367 as accepted by the FDMS test environment.

A port in any language proves itself with the runner. It pipes each vector to a command as one JSON object on stdin and reads one JSON object back:

npx zimra-fdms-conformance -- python3 my_responder.py
npx zimra-fdms-conformance -- ./build/kotlin-responder
npx zimra-fdms-conformance --vectors other.json -- node dist/conformance/reference.js
RequestExpected answer
{"kind":"receipt","deviceId","receipt","previousReceiptHash"}{"canonical","hash","signature"?}
{"kind":"fiscalDay","deviceId","fiscalDayNo","fiscalDayDate","counters"}{"canonical","hash","signature"?}
{"kind":"qr","qrUrl","deviceId","receiptDate","receiptGlobalNo","deviceSignatureBase64"}{"qrData"}
{"kind":"sha256","input"}{"sha256Base64"}
{"kind":"taxes","lines","taxInclusive"}{"taxes"}

Canonical strings and hashes must match byte for byte. Signatures are verified against the public key in the vectors file rather than compared, since ECDSA is randomised; a responder that does not sign leaves the field out and is checked on formatting only. The private key is in the file so a responder can sign with it (ZIMRA_VECTOR_KEY for the reference). src/conformance/reference.ts is a responder built on this SDK; port that file first.

Test environment#

The FDMS test environment is self-service. No paperwork, no approval queue — device credentials are issued in minutes, which makes it possible to go from install to a ZIMRA-validated invoice in one sitting.

PurposeURL
APIfdmsapitest.zimra.co.zw — Swagger at /swagger/index.html
Device portalfdmsops.zimra.co.zw/fdms-public/add-device
Invoice validationfdmstest.zimra.co.zw

Going to production

Live fiscalisation needs the human path: ZIMRA onboarding, sample-document approval, live device registration, ITF263 and ongoing compliance monitoring. The code is solved; the paperwork is not. Goko Consultancy & Training Services handles that path end to end.