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 fromPOST /api/v1/tenants/{tenant}/tokenscannot register a receiver — that token answers 401 here. PollGET /api/v1/sales/{sale}for thepending → uploadedtransition 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
urlmust behttps://. 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
3xxresponse 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(scopewebhooks: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 withGET /api/v1/salesafter 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; watchlastFailureAtmoving 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.