FiscalBridge API

Send invoices and credit notes from your POS, ERP or any HTTP client. FiscalBridge checks them, sends them to the Zambia Revenue Authority (ZRA) through the business's VSDC device, and tells you the result. Every example on this page is run against the real API by our tests, so the shapes you see are the shapes you get.

Sending is asynchronous. POST /api/v1/invoices never waits for ZRA: it answers 202 with a submissionId. You get the outcome by polling or with a webhook.

Base URL: this site's address followed by /api/v1. All requests and responses are JSON over HTTPS.

Getting access

  1. Apply for access with a few details about you and the system you are connecting.
  2. We contact you and give you a demo.
  3. If you go ahead, we create a test business for you. It talks to a simulated ZRA, so you can send as many invoices as you like while you build. Nothing reaches ZRA.
  4. When your customer is ready, their real business is set up with its own ZRA details and API key.

Authentication

Every request carries an API key:

Authorization: Bearer fb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • The business owner creates keys in the dashboard under Settings → API keys. Use one key per till or system, with a label you will recognise.
  • The full key is shown once. Store it as a secret; FiscalBridge keeps only a hash.
  • A key belongs to one business. The business, its TPIN and its VSDC device come from the key, never from the request.

A missing, invalid or revoked key

GET /api/v1/branches
Authorization: Bearer fb_live_…
→ 401
{
  "code": "UNAUTHORIZED",
  "message": "Invalid or revoked API key"
}

Rate limits

Limits are per API key, per minute, and well above what a busy till sends:

RequestsLimit
Writes (POST, DELETE)120 per minute
Reads (GET), such as polling a submission600 per minute
Requests with a missing or wrong key, per IP address30 per minute

Over the limit you get 429 with a Retry-After header in seconds. Stop sending for that business until then, rather than retrying every invoice in the queue; resend each one later with the same Idempotency-Key.

Too many requests (also sends a Retry-After header, in seconds)

POST /api/v1/invoices
Authorization: Bearer fb_live_…
→ 429
{
  "code": "RATE_LIMITED",
  "message": "Too many requests — retry after 12 seconds"
}

Submit an invoice

POST /api/v1/invoices with an Idempotency-Key header (use your invoice number). Field names follow the ZRA VSDC specification. Prices include VAT; FiscalBridge works out every tax field itself.

FieldNotes
cisInvcNoRequired. Your invoice number, up to 50 characters, unique for ever (see below).
rcptTyCdS sale (default) or R credit note.
salesTyCdDefault N (normal). Must be N for credit notes.
pmtTyCdPayment method, 01–08. Default 01.
salesDtyyyyMMdd, Zambian time. Default today.
bhfIdThe branch (3 digits). Optional with one active branch, required with several.
customer{ tpin?, name, phone?, email?, address? }. Leave out for walk-in sales.
lineItems1–1000 lines: itemCd, qty, prc (VAT-inclusive), optional dcRt (discount %). The first time an itemCd is sent, also itemClsCd, itemNm, pkgUnitCd, qtyUnitCd and vatCatCd.
totalsOptional cross-check { totAmt }: must match FiscalBridge's total within 0.01.

Submit a sale

POST /api/v1/invoices
Authorization: Bearer fb_live_…
Content-Type: application/json
Idempotency-Key: LSK01-INV-000123

{
  "cisInvcNo": "LSK01-INV-000123",
  "salesDt": "20261002",
  "pmtTyCd": "01",
  "customer": {
    "tpin": "2000000000",
    "name": "Acme Zambia Ltd"
  },
  "lineItems": [
    {
      "itemCd": "SKU-001",
      "itemClsCd": "50202306",
      "itemNm": "Bottled water 500ml",
      "pkgUnitCd": "BA",
      "qtyUnitCd": "BE",
      "vatCatCd": "A",
      "qty": 12,
      "prc": 10
    }
  ],
  "totals": {
    "totAmt": 120
  }
}
→ 202
{
  "submissionId": "cmuqx41td000cvb4gzb686e5k",
  "cisInvcNo": "LSK01-INV-000123",
  "status": "QUEUED",
  "pollUrl": "/api/v1/submissions/cmuqx41td000cvb4gzb686e5k"
}

Idempotency and invoice numbers

  • Same key, same body returns the original submission with its current status. Retrying after a timeout is always safe.
  • Same key, different body is refused with 409: it almost always means a stale key was reused for a new invoice.
  • Every cisInvcNo must be unique for the business, for ever. Never reset your numbering after a reinstall, a new year or a restore. Prefix numbers per till (LSK01-INV-000123) and use a separate series for credit notes. A reused number is refused with 409 (ZRA code 924).

Retry with the same key and body: the original submission, nothing new

POST /api/v1/invoices
Authorization: Bearer fb_live_…
Content-Type: application/json
Idempotency-Key: LSK01-INV-000123

{
  "cisInvcNo": "LSK01-INV-000123",
  "salesDt": "20261002",
  "pmtTyCd": "01",
  "customer": {
    "tpin": "2000000000",
    "name": "Acme Zambia Ltd"
  },
  "lineItems": [
    {
      "itemCd": "SKU-001",
      "itemClsCd": "50202306",
      "itemNm": "Bottled water 500ml",
      "pkgUnitCd": "BA",
      "qtyUnitCd": "BE",
      "vatCatCd": "A",
      "qty": 12,
      "prc": 10
    }
  ],
  "totals": {
    "totAmt": 120
  }
}
→ 200
{
  "submissionId": "cmuqx41td000cvb4gzb686e5k",
  "cisInvcNo": "LSK01-INV-000123",
  "status": "QUEUED",
  "pollUrl": "/api/v1/submissions/cmuqx41td000cvb4gzb686e5k"
}

Same key, different body: refused

POST /api/v1/invoices
Authorization: Bearer fb_live_…
Content-Type: application/json
Idempotency-Key: LSK01-INV-000123

{
  "cisInvcNo": "LSK01-INV-000123",
  "salesDt": "20261002",
  "pmtTyCd": "01",
  "customer": {
    "tpin": "2000000000",
    "name": "Acme Zambia Ltd"
  },
  "lineItems": [
    {
      "itemCd": "SKU-001",
      "itemClsCd": "50202306",
      "itemNm": "Bottled water 500ml",
      "pkgUnitCd": "BA",
      "qtyUnitCd": "BE",
      "vatCatCd": "A",
      "qty": 13,
      "prc": 10
    }
  ],
  "totals": {
    "totAmt": 130
  }
}
→ 409
{
  "error": "Idempotency key already used with a different payload",
  "originalSubmissionId": "cmuqx41td000cvb4gzb686e5k"
}

Track the result

A submission moves QUEUED → SUBMITTING → SUBMITTED, or ends FAILED. Poll every 1–2 seconds for the first minute, then back off, or use a webhook. Print rcptNo, rcptSign, sdcId, mrcNo, vsdcRcptPbctDate and a QR code of qrCodeUrl on the receipt.

Network problems and VSDC codes 838 and 894 are retried automatically for about 17 minutes; the status returns to QUEUED in between. Every other ZRA code fails at once, because resending an invalid invoice cannot succeed. A failed invoice keeps its cisInvcNo: resending that number is refused with 409. Either the business presses Retry on it in the dashboard (after fixing the cause, such as its device), or you send the corrected invoice under a new number.

Get one submission

GET /api/v1/submissions/{submissionId}
Authorization: Bearer fb_live_…
→ 200
{
  "submissionId": "cmuqx41td000cvb4gzb686e5k",
  "cisInvcNo": "LSK01-INV-000123",
  "source": "api",
  "bhfId": "000",
  "branchName": "Lusaka Main",
  "status": "SUBMITTED",
  "attempts": 1,
  "createdAt": "2026-10-02T12:06:57.073Z",
  "updatedAt": "2026-10-02T12:06:57.113Z",
  "submittedAt": "2026-10-02T12:06:57.111Z",
  "rcptNo": 50001,
  "rcptSign": "KEKNLL3BK6CAH3H",
  "qrCodeUrl": "https://sandboxportal.zra.org.zm/common/link/ebm/receipt/indexEbMReceiptData?Data=...",
  "vsdcRcptPbctDate": "20261002120003",
  "sdcId": "SDC0010000553",
  "mrcNo": "WIS00001092",
  "invoiceId": "cmuqx41t70008vb4gvafmaij1",
  "invoicePdfUrl": "/invoices/cmuqx41t70008vb4gvafmaij1/pdf",
  "lastError": null,
  "lastResultCd": "000",
  "needsDeviceReinit": false,
  "attemptsLog": [
    {
      "attempt": 1,
      "startedAt": "2026-10-02T12:06:57.108Z",
      "resultCd": "000",
      "resultMsg": "It is succeeded"
    }
  ]
}

Listing takes status, source, bhfId, since, until, q, limit (1–200) and cursor. Pass nextCursor back as cursor for the next page.

List submissions

GET /api/v1/submissions?status=SUBMITTED&limit=50
Authorization: Bearer fb_live_…
→ 200
{
  "data": [
    {
      "submissionId": "cmuqx41td000cvb4gzb686e5k",
      "cisInvcNo": "LSK01-INV-000123",
      "source": "api",
      "bhfId": "000",
      "branchName": "Lusaka Main",
      "status": "SUBMITTED",
      "attempts": 1,
      "createdAt": "2026-10-02T12:06:57.073Z",
      "updatedAt": "2026-10-02T12:06:57.113Z",
      "submittedAt": "2026-10-02T12:06:57.111Z",
      "rcptNo": 50001,
      "rcptSign": "KEKNLL3BK6CAH3H",
      "qrCodeUrl": "https://sandboxportal.zra.org.zm/common/link/ebm/receipt/indexEbMReceiptData?Data=...",
      "vsdcRcptPbctDate": "20261002120003",
      "sdcId": "SDC0010000553",
      "mrcNo": "WIS00001092",
      "invoiceId": "cmuqx41t70008vb4gvafmaij1",
      "invoicePdfUrl": "/invoices/cmuqx41t70008vb4gvafmaij1/pdf",
      "lastError": null,
      "lastResultCd": "000",
      "needsDeviceReinit": false,
      "attemptsLog": [
        {
          "attempt": 1,
          "startedAt": "2026-10-02T12:06:57.108Z",
          "resultCd": "000",
          "resultMsg": "It is succeeded"
        }
      ]
    }
  ],
  "nextCursor": null
}

ZRA result codes

lastResultCd and lastError show what ZRA answered. The ones you are most likely to meet:

CodeMeaningWhat your system should do
000AcceptedPrint the receipt fields.
838, 894VSDC communication problemNothing: retried automatically. Act only if the submission ends FAILED.
836Device out of step with ZRANot retried. needsDeviceReinit is true; the business re-initializes its device from the dashboard, which re-queues the invoice.
899Client errorGive up on this request: lastError names the problem. Fix the data and send the corrected invoice under a new cisInvcNo.
924Invoice number already usedNever reuse numbers. After a timed-out attempt the first one may have been accepted; check before resending.
930, 931, 934Credit note: original not found, amount or quantity too highFix the credit note. Usually caught earlier as a 400.

Other codes appear exactly as ZRA returns them.

Credit notes

Send rcptTyCd: "R" with the original's ZRA receipt number in originalRcptNo, a reason in rfdRsnCd (01 wrong products, 02 wrong price, 03 damaged goods, 04 wrong customer, 05 duplicate, 06 excess supplies, 07 other with a remark), and the lines being credited. With several branches also send originalCisInvcNo (or orgSdcId), because receipt numbers are counted per branch device.

Before queueing, FiscalBridge checks across all earlier credit notes for the same original that each item was on it and that neither quantities nor the total exceed it; a failed check is a 400 straight away.

Submit a credit note

POST /api/v1/invoices
Authorization: Bearer fb_live_…
Content-Type: application/json
Idempotency-Key: LSK01-CN-000001

{
  "cisInvcNo": "LSK01-CN-000001",
  "rcptTyCd": "R",
  "salesTyCd": "N",
  "originalRcptNo": 50001,
  "originalCisInvcNo": "LSK01-INV-000123",
  "rfdRsnCd": "03",
  "customer": {
    "tpin": "2000000000",
    "name": "Acme Zambia Ltd"
  },
  "lineItems": [
    {
      "itemCd": "SKU-001",
      "qty": 6,
      "prc": 10
    }
  ]
}
→ 202
{
  "submissionId": "cmuqx9cn0000vb4g",
  "cisInvcNo": "LSK01-CN-000001",
  "status": "QUEUED",
  "pollUrl": "/api/v1/submissions/cmuqx9cn0000vb4g"
}

Webhooks

Register an HTTPS URL to be told when a submission succeeds or fails. The URL must resolve to public addresses only; redirects are not followed. The secret is shown only when you create the webhook: store it.

Register a webhook

POST /api/v1/webhooks
Authorization: Bearer fb_live_…
Content-Type: application/json

{
  "url": "https://pos.example.com/zra-callback",
  "events": [
    "submission.succeeded",
    "submission.failed"
  ]
}
→ 201
{
  "id": "cmwhk0001",
  "tenantId": "cmtnt0001",
  "url": "https://pos.example.com/zra-callback",
  "events": [
    "submission.succeeded",
    "submission.failed"
  ],
  "active": true,
  "createdAt": "2026-10-02T12:00:00.000Z",
  "secret": "whsec_9b1f0c…"
}

Each delivery is a POST of the submission (the same shape as getting one) with these headers:

X-FiscalBridge-Signature: t=1790942400,v1=5f0c…  (hex HMAC-SHA256)
X-FiscalBridge-Event: submission.succeeded
X-FiscalBridge-Delivery: <delivery id, for de-duplication>

Verify every delivery. The signature is HMAC_SHA256(secret, "<t>.<raw body>") over the raw body bytes, before any JSON parsing. Reject anything older than 5 minutes:

import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // replay
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

Answer with any 2xx within 10 seconds. Other answers, 408, 429 and network errors are retried 8 times over about 21 hours; any other 4xx stops retries for that delivery. Deliveries can arrive twice or out of order, so treat getting the submission as the source of truth.

List webhooks

GET /api/v1/webhooks
Authorization: Bearer fb_live_…
→ 200
[
  {
    "id": "cmwhk0001",
    "tenantId": "cmtnt0001",
    "url": "https://pos.example.com/zra-callback",
    "events": [
      "submission.succeeded",
      "submission.failed"
    ],
    "active": true,
    "createdAt": "2026-10-02T12:00:00.000Z"
  }
]

Delivery attempts for a webhook

GET /api/v1/webhooks/{webhookId}/deliveries?limit=20
Authorization: Bearer fb_live_…
→ 200
{
  "data": [
    {
      "id": "cmdlv0001",
      "webhookId": "cmwhk0001",
      "submissionId": "cmuqx41td000cvb4gzb686e5k",
      "event": "submission.succeeded",
      "requestBody": {},
      "responseStatus": 200,
      "responseBody": "ok",
      "attempts": 1,
      "nextRetryAt": null,
      "deliveredAt": "2026-10-02T12:06:58.000Z",
      "failedAt": null,
      "createdAt": "2026-10-02T12:06:57.200Z"
    }
  ],
  "nextCursor": null
}

Deactivate a webhook

DELETE /api/v1/webhooks/{webhookId}
Authorization: Bearer fb_live_…
→ 204 (empty body)

Branches

The business and its ZRA branches, so your system can map its own shops to them. Only active branches whose device is initialized accept invoices.

List your branches

GET /api/v1/branches
Authorization: Bearer fb_live_…
→ 200
{
  "business": {
    "name": "Ken Local Traders"
  },
  "branches": [
    {
      "bhfId": "000",
      "name": "Lusaka Main",
      "active": true,
      "initialized": true
    }
  ]
}

Testing

While you build, you use a test business. It works exactly like a real one, but it talks to a simulated ZRA.

Receipts from a test business are not fiscal. Their receipt numbers, signatures and QR codes are made up. Never give one to a customer. Make sure your system cannot print them as real receipts.

Ready to start? Apply for access.