Documentation

Webhooks

Receive and verify fiscalisation events instead of polling.

Webhooks

Webhooks let your POS/ERP integration react to fiscalisation outcomes without polling. When a sale is fiscalised by ZRA — including sales that were queued while a terminal was offline and uploaded later — we POST a signed event to the endpoint(s) you register.

They are the async truth for the one case polling handles badly: a sale accepted locally while ZRA was unreachable, then fiscalised minutes or hours later by our sync engine.

This needs a partner key. Every endpoint on this page authenticates with an sk_… partner secret. A merchant holding only a bearer token from POST /api/v1/tenants/{tenant}/tokens cannot register a receiver — that token answers 401 here. Poll GET /api/v1/sales/{sale} for the pending → uploaded transition instead, or ask whoever provisioned you to register one: a single partner endpoint receives events for every merchant on your book.


1. Register an endpoint

Authenticate with your partner secret key (Authorization: Bearer sk_live_…). Requires the webhooks:write scope.

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

{
  "url": "https://your-app.example/webhooks/zra",
  "description": "Production receiver",
  "events": ["invoice.fiscalised", "invoice.failed"]
}

Response (201) — the signing secret is shown once:

{
  "secret": "whsec_9f2c…",
  "endpoint": {
    "id": 12,
    "url": "https://your-app.example/webhooks/zra",
    "events": ["invoice.fiscalised", "invoice.failed"],
    "status": "enabled",
    "createdAt": "2026-07-19T09:00:00+00:00"
  },
  "message": "Store this signing secret now — it will not be shown again."
}

Store secret securely — you need it to verify every incoming delivery, and we never show it again. One endpoint registered on your partner account receives events for all merchants you provision.

In production the url must be https://. We sign every payload, but fiscal data is never sent over cleartext.


2. Events

Event Fires when
invoice.fiscalised A sale received its ZRA fiscal signature — either online at sale time, or later when an offline-queued sale was uploaded.
invoice.failed A queued sale was terminally rejected by ZRA (bad data) and will not be fiscalised. Requires operator action.

Payload

Every delivery has the same envelope; data.invoice carries the invoice.

{
  "id": "01JABCXYZEVENTULID",
  "event": "invoice.fiscalised",
  "created": "2026-07-19T09:00:01+00:00",
  "data": {
    "invoice": {
      "id": 4821,
      "reference": "POS-1042",
      "clientInvoiceNo": "POS-1042",
      "invoiceNo": 941,
      "zraInvoiceNo": "INV000000000941/424",
      "status": "uploaded",
      "tpin": "1002101584",
      "branch": "000",
      "customer": { "name": "Walk-in", "tpin": null },
      "totals": { "net": 100.0, "vat": 16.0, "total": 116.0, "currency": "ZMW" },
      "fiscal": {
        "receiptNo": 424,
        "internalData": "…",
        "receiptSignature": "…",
        "sdcId": "SDC0010000001",
        "qrCodeUrl": "https://…",
        "publishedAt": "2026-07-19T09:00:00+00:00"
      },
      "resultCode": "000",
      "lastError": null,
      "fiscalisedAt": "2026-07-19T09:00:01+00:00"
    }
  }
}

For invoice.failed, status is failed, the fiscal.* fields are null, and lastError explains why (e.g. [894] Invalid item).

Two timestamps, and the obvious one is the wrong one. fiscal.publishedAt is when ZRA fiscalised the document — null exactly when it has not been filed. fiscalisedAt at the top level is the record's last-modified time despite the name: it moves on any later write to the row, and it is non-null even on a failed invoice that never filed. Use fiscal.publishedAt for anything you print, report on, or reconcile against.

Request headers

Header Meaning
X-SmartInvoicing-Signature t=<unix>,v1=<hmac> — verify this (below).
X-Webhook-Event The event name.
X-Webhook-Id The event ULID (== payload id). Use it to dedupe.
X-Webhook-Delivery The delivery row's id. It stays the same across every retry of that delivery, so it identifies the delivery, not the attempt — do not use it as a primary key for an attempts table.

3. Verify the signature

The header is t=<timestamp>,v1=<hex> where hex = HMAC_SHA256(secret, "<timestamp>.<raw body>"). Because the timestamp is inside the signed material, a captured delivery can't be replayed with a new timestamp — reject deliveries whose timestamp is outside a tolerance (we recommend 300 s).

Verify on the raw request body, before JSON parsing.

PHP

function verify(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
    parse_str(str_replace(',', '&', $header), $p);      // t=…&v1=…
    if (abs(time() - (int) ($p['t'] ?? 0)) > $tolerance) {
        return false;                                   // stale / replay
    }
    $expected = hash_hmac('sha256', $p['t'] . '.' . $payload, $secret);

    return hash_equals($expected, $p['v1'] ?? '');
}

// Laravel controller
$raw = $request->getContent();
abort_unless(
    verify($raw, $request->header('X-SmartInvoicing-Signature', ''), config('services.zra.webhook_secret')),
    400
);

Node.js (Express)

const crypto = require('crypto');

function verify(raw, header, secret, tolerance = 300) {
  // Every guard below returns false rather than throwing. timingSafeEqual
  // raises on unequal buffer lengths, and a missing header would throw on
  // .split — either one escapes as a 500, which we treat as retryable, so a
  // stream of forged requests would drive your endpoint toward auto-disable.
  if (typeof header !== 'string') return false;

  const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;

  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${raw}`).digest('hex');
  const given = Buffer.from(parts.v1 || '', 'utf8');
  const mine = Buffer.from(expected, 'utf8');

  return given.length === mine.length && crypto.timingSafeEqual(mine, given);
}

// Mount with the RAW body, not the parsed one:
app.post('/webhooks/zra', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body.toString(), req.get('X-SmartInvoicing-Signature'), process.env.WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString());
  // … handle event.event / event.data.invoice …
  res.sendStatus(200);   // 2xx = accepted
});

Respond 2xx to acknowledge. Any other status (or a timeout) is treated as a failure and retried.


4. Delivery, retries & idempotency

  • At-least-once. We retry non-2xx / timeouts with exponential backoff: 1 m, 5 m, 30 m, 2 h, 6 h (6 attempts total), then mark the delivery failed.
  • Deduplicate on X-Webhook-Id — a retry re-sends the same event id. Make your handler idempotent.
  • Order is not guaranteed across events; use the payload rather than arrival order.
  • Return quickly (we time out after 10 s). Do slow work asynchronously after acknowledging.
  • Redirects are not followed. A 3xx response counts as a failure — point us at the final URL.
  • Auto-disable. After 10 consecutive failed deliveries with no success in between, the endpoint is disabled and stops receiving events. Recover with POST /api/v1/webhooks/{id}/enable (scope webhooks:write) — it re-enables the endpoint and clears the failure streak while keeping the same signing secret, so no receiver redeploy is needed. What it cannot recover: events fired while the endpoint was disabled were never queued and cannot be resent — no delivery record exists for them. Reconcile the gap with GET /api/v1/sales after re-enabling, and alert on consecutive failures at your end rather than discovering this at ten.
  • A failure is not always six attempts. A receiver that times out or errors works through the whole ladder first, so ten of those take days. But a URL the SSRF guard refuses at send time — the hostname now resolves to a private address, or stopped resolving at all — is terminal on the first attempt, with no retries. Ten events then disable the endpoint in the time it takes to file ten invoices. A DNS change is the realistic way this happens, and it can take a receiver from healthy to disabled between two polls of status; watch lastFailureAt moving instead.

Inspect & resend

GET  /api/v1/webhooks/{id}/deliveries          # paginated log: status, attempts, next retry
POST /api/v1/webhooks/deliveries/{id}/resend   # restart one delivery's retry chain immediately

Each delivery row records status (pending/delivered/failed), attempts, responseStatus, error, and nextRetryAt. A resend resets the attempt counter, so the full retry schedule applies again. Delivery log rows are retained for 90 days.


5. Manage endpoints

GET    /api/v1/webhooks           # list yours          (webhooks:read)
GET    /api/v1/webhooks/{id}      # one endpoint        (webhooks:read)
DELETE /api/v1/webhooks/{id}      # remove              (webhooks:write)

Full request/response schemas are in openapi.yaml.


6. Security checklist

  • Verify every signature; reject stale timestamps.
  • Use the raw body for verification — re-serialising changes the bytes and breaks the HMAC.
  • Serve your endpoint over HTTPS, on a public hostname (no trailing dot) and a standard port (80/443/8080/8443). URLs that resolve to private, loopback, or link-local addresses are rejected in production. We resolve your hostname once and pin the delivery connection to that address, so DNS changes mid-delivery can't redirect us.
  • Treat handlers as idempotent (dedupe on X-Webhook-Id).
  • Rotate by registering a new endpoint and deleting the old one.