Introduction
The ThatFiscal API lets you send invoices and credit notes to the ZIMRA revenue authority for fiscal processing from your own application or any accounting system. One POST in, a fiscalised receipt out.
Authentication
Every request carries your tenant API key in the X-Api-Key header. Find it in Portal → Settings & Branding. Keys start with tf_.
X-Api-Key: tf_Kx9……your_key…… The key is issued the moment your account goes Live. Before that, the /portal/* endpoints — including device registration — also accept a signed-in portal session in the X-Portal-Session header, which is how you register your first device and unlock the key.
Errors & Status Codes
| HTTP | Meaning |
|---|---|
200 | Request accepted — this does not mean ZIMRA accepted the receipt. Always read status in the body (fiscalised, fiscalised_yellow, queued, resending, error) and, when it is error, error_code. |
200 + skipped | Connector webhooks only. The source document is valid but is not a customer sale (an Odoo vendor bill or journal entry, for example), so it was deliberately not fiscalised. Body: {"ok":true,"skipped":true,"fiscalised":false,"reason":"…"}. Deliberately not a 4xx, so the sending system does not retry it forever. |
401 | Missing or invalid X-Api-Key. |
402 | Subscription suspended, or the endpoint needs a plan feature you do not have. |
404 | No such receipt, or it belongs to another tenant. |
409 | Returned by /portal/receipts/{id}/resend when the receipt is already fiscalised, and by /portal/devices/{id}/close-day when the fiscal day cannot be closed. |
413 | Request body over the size limit — split the CSV or shrink the logo. |
422 | The invoice failed validation. The detail string names every problem found, not just the first. Also returned when a connector payload cannot be read at all. |
invoice_no + receipt_type to the same device returns 200 with the receipt that was already fiscalised, rather than creating a second one. Retrying after a timeout is safe.ZIMRA validation errors are surfaced as error_code (RCPT047, RCPT015, RCPT043, …) with a plain-language error_detail. Platform-side holds use their own codes: PAYMENT_REQUIRED, PLAN_LIMIT, FISCAL_DAY, MAPPING. The Knowledge Base Error Table explains each fix.
Webhooks (Inbound Connectors)
Pre-built receivers translate each system's native payload into the canonical invoice — no mapping work on your side. All of them take the same X-Api-Key header and return the same receipt summary as /api/invoices.
| System | Endpoint | Send |
|---|---|---|
| Odoo | POST /webhooks/odoo | The payload built by odoo_automated_action.py. Only move_type out_invoice and out_refund in state posted are fiscalised; everything else returns 200 with skipped: true. |
| Zoho Books | POST /webhooks/zoho | The raw Zoho invoice or credit-note JSON, all fields — custom fields arrive in custom_field_hash / custom_fields / item_custom_fields, never as root cf_* keys. |
| QuickBooks Online | POST /webhooks/quickbooks | The raw Accounting API v3 Invoice or CreditMemo entity, wrapped or unwrapped. Include TxnTaxDetail — that is the only place QuickBooks carries the tax rate. |
| Excel / CSV | POST /webhooks/excel | Multipart form field file, UTF-8 CSV. Returns {"processed", "rejected", "results", "errors"} — a bad row does not abandon the batch. |
| Anything else | POST /webhooks/custom/{map_name} | Your own JSON, translated by a field map you save at POST /portal/fieldmaps. |
tf_device_id in the payload to pick which registered device fiscalises the document. Omit it and your first active device is used. /webhooks/excel always uses the first active device.Submit Invoice
Submit a canonical invoice for fiscalisation. Synchronous: the response tells you whether ZIMRA accepted it.
{
"invoice_no": "INV-2026-0042",
"receipt_type": "FiscalInvoice",
"currency": "USD",
"lines_tax_inclusive": true,
"buyer": {"name": "Company ABC Ltd", "tin": "1987012311"},
"lines": [{
"name": "Consulting", "hs_code": "9983",
"qty": 1, "price": 115.50, "total": 115.50,
"tax_percent": 15.5, "tax_id": 3
}],
"payments": [{"money_type": "BankTransfer", "amount": 115.50}]
} Validation
The body is validated before anything is written or sent. A failure returns 422 with a detail string listing every problem at once:
| Field | Rule |
|---|---|
invoice_no | Required, non-blank, 50 characters or fewer (the FDMS limit). |
currency | Required, exactly 3 letters. Upper-cased for you. |
receipt_type | One of FiscalInvoice, CreditNote, DebitNote. Defaults to FiscalInvoice when omitted. |
lines | Required, a non-empty list of objects. Each line needs a name (or description) and a total or a price. Any of total, price, qty, tax_percent that is present must parse as a number. |
payments | Optional. If present it must be a list, and each entry needs a numeric amount. Omit it and the whole invoice is settled to Cash. |
credited_invoice | Required when receipt_type is CreditNote or DebitNote. See Submit Credit Note. |
total on every line. price alone passes validation but is not yet enough to build the receipt, and the submission then fails with error_code: "MAPPING". Send both price and total and we reconcile them for you (ZIMRA's RCPT024 demands total == price × qty exactly, so the unit price is re-derived from the total when the two disagree).Response
{
"id": "9f2c…", "status": "fiscalised",
"invoice_no": "INV-2026-0042", "type": "FiscalInvoice",
"source": "api", "currency": "USD", "total": 115.00,
"receipt_global_no": 482, "receipt_counter": 17,
"verification_code": "C0DE-4F92-A1B7-22E8",
"qr_url": "https://invoice.zimra.co.zw/…",
"error_code": null, "attempts": 1,
"fiscalised_at": "2026-08-16T09:14:02", "created_at": "2026-08-16T09:14:01"
} status is fiscalised or fiscalised_yellow when ZIMRA accepted it (Yellow means accepted with a non-blocking warning), and error when it did not — in which case error_code is populated and the receipt can be re-submitted with Resend. qr_url and verification_code are null until the receipt is fiscalised.
RCPT047.Submit Credit Note
Same endpoint, with receipt_type: "CreditNote" plus a credited_invoice object referencing the original document:
{
"invoice_no": "CN-2026-0007",
"receipt_type": "CreditNote",
"credited_invoice": {"invoiceNo": "INV-2026-0042"},
"currency": "USD",
"notes": "Goods returned — damaged in transit",
"lines": [ …the lines being credited, as positive amounts… ],
"payments": [{"money_type": "BankTransfer", "amount": 115.00}]
} Identifying the Original
credited_invoice accepts any one of these shapes. We resolve whichever you send into the reference FDMS requires (§4.7) — a receiptID, or deviceID + receiptGlobalNo + fiscalDayNo together:
| Shape | Use When |
|---|---|
{"invoiceNo": "INV-2026-0042"} | Normal case — your own invoice number, matched against receipts already fiscalised on this device. |
{"receiptID": 5001} | You already hold ZIMRA's receipt ID for the original. Used as-is, no lookup. |
{"sourceRef": "146"} | You know the source system's internal ID rather than the printed number (this is how QuickBooks credit memos link, via LinkedTxn). |
error_code: "RCPT015" and the message "Invoice … has not been fiscalised on this device". Fiscalise the original first, or supply its ZIMRA receiptID directly.notes with the reason (an empty note is RCPT034), and send your line amounts and payments as positive numbers — the negation across lines, taxes and payments is applied for you. Sending negatives as well double-negates the document. A credit note may reuse the invoice number it reverses.ThatFiscal links it to the original receipt and nets it off in all reports. Debit notes: receipt_type: "DebitNote", with the same credited_invoice requirement.
Check Status
Full detail for one receipt: everything in the submit response plus payload (the canonical invoice as we stored it), server_signature, device_hash and error_detail.
List receipts newest first. status filters on one receipt status; q searches the invoice number.
Resend Receipt
Re-fiscalise after fixing an error. A receipt that is already fiscalised or fiscalised_yellow returns 409 rather than being sent twice. The receipt keeps its originally reserved global number and counter on retry, so no gap appears in the device's sequence.
Download QR
The ZIMRA QR as a PNG, ready to embed in your own invoice template. Returns 404 until the receipt is fiscalised and has a QR URL.
Period Summary
Total fiscalised invoices, credit notes, errors and net value by currency for any month range — e.g. January and February together. Credit notes are subtracted from the value totals. Omit dfrom / dto for everything on record:
{
"from": "2026-01", "to": "2026-02",
"total_fiscalised": 214, "total_errors": 3,
"net_value_by_currency": {"USD": 48210.55, "ZWG": 812400.00},
"by_source": {"odoo": 180, "api": 31, "excel": 3},
"by_month": {
"2026-01": {"invoices": 98, "credit_notes": 4, "errors": 1, "value": {"USD": 22110.20}},
"2026-02": {"invoices": 109, "credit_notes": 3, "errors": 2, "value": {"USD": 26100.35}}
}
} VAT Report
Output VAT by rate and currency — taxable, VAT and gross — with credit notes netted. These are the figures for the VAT7 return.
Audit Export
One CSV row per receipt: date, invoice number, type, status, currency, total, global number, verification code, source, error code.
Register Device
{ "zimra_device_id": 12345, "activation_key": "XXXX-XXXX",
"serial_no": "TFVFD-2026-00001", "environment": "prod" } // serial_no is optional — defaults to your assigned TFVFD serial Runs the full ZIMRA §2.1 flow: verify taxpayer → keygen (ECC P-256) → CSR → registerDevice → getConfig. The certificate and private key are held server-side and are never returned by the API. environment is test or prod; a missing zimra_device_id or activation_key returns 422.
Close Fiscal Day
Closes the open fiscal day with the fiscal-day device signature (§13.3.1) and produces the Z-report. Returns 409 when receipts inside the day are still blocking the close — clear or resend those first, or pass ?force=true when support tells you to. A day left open past the device's maximum day length (taxPayerDayMaxHrs from getConfig, usually 24 hours) is closed automatically by the platform.
Live FDMS device and fiscal-day status: operating mode, certificate expiry, the open day's number, when it must close by, hours remaining, and the receipts currently blocking a close.
The Z-report for a closed day, or the X-report for the day still open. fmt=json for the structured model, fmt=text for the 48-column printed layout.
Branding Settings
Read or update the tenant's invoice branding: logo_data_url, bank_name, bank_branch, account_name, account_number_usd, account_number_zwg, swift_code, invoice_notes, invoice_footer, address, phone. POST only the fields you are changing. A logo over 300 KB returns 413; setting a logo requires the branding feature (Standard and Enterprise, or as an add-on) and returns 402 otherwise.
Billing (Paynow)
{ "plan": "standard", "period": "monthly",
"method": "ecocash", "phone": "0771234567" } Card payments (method: "web") return a redirect_url to Paynow checkout. Ecocash/OneMoney push a prompt to the phone; poll GET /billing/paynow/poll/{payment_id} until status: "paid". The plan activates the moment Paynow confirms.
Current plan, price, invoice usage this month, and last payment.
Canonical Invoice Schema
| Field | Type | Required | Notes |
|---|---|---|---|
invoice_no | string | Yes | Your document number, 50 characters maximum. Re-posting the same number with the same receipt_type to the same device returns the existing receipt instead of fiscalising twice. |
receipt_type | string | No | FiscalInvoice (default) | CreditNote | DebitNote. Any other value is rejected with 422. |
currency | string | Yes | ISO 4217, exactly 3 letters — USD, ZWG, ZAR… Upper-cased for you. |
lines_tax_inclusive | bool | No | Whether line totals already include VAT. Defaults to true when omitted. |
date | string | No | YYYY-MM-DDTHH:MM:SS. Leave it out — we stamp the current local time, which is what keeps the receipt inside the open fiscal day. A back-dated value trips RCPT014 / RCPT030. |
notes | string | Credit/debit notes | Printed on the receipt. Mandatory on CreditNote and DebitNote (RCPT034). |
credited_invoice | object | Credit/debit notes | {"invoiceNo": "…"}, {"receiptID": 123} or {"sourceRef": "…"}. The original must already be fiscalised on this device. |
buyer.name / buyer.tin / buyer.vat | string | No | TIN = 10 digits. ZIMRA requires both name and TIN if a buyer is declared at all (RCPT043); a name with no TIN is moved into the receipt notes rather than blocking the sale. |
buyer.contacts / buyer.address | object | No | {"email", "phoneNo"} and {"province", "city", "street", "houseNo"}. |
lines[].name | string | Yes | Item description, 200 characters maximum. |
lines[].hs_code | string | No | Digits only, 4 or 8 (8 required for VAT payers). Auto-derived when omitted. |
lines[].total | number | Yes | The line total. Validation accepts price instead, but submission needs total — always send it. |
lines[].qty / price | number | No | qty defaults to 1. price is re-derived from total ÷ qty when the two disagree (RCPT024). |
lines[].type | string | No | Sale (default) or Discount. A negative total is treated as a Discount line. |
lines[].tax_percent / tax_id / tax_code | number/str | Recommended | Must match your device's applicable taxes (getConfig), or the receipt fails RCPT025. |
payments[].money_type | string | No | Cash | Card | MobileMoney | BankTransfer | Coupon | Credit | Other. Defaults to Cash. |
payments[].amount | number | With payments | Must sum to the receipt total (RCPT039). Rounding gaps up to 5c are absorbed into the largest payment; a real discrepancy is refused. |
source_ref | string | No | Your system's internal ID for the document. This is what credited_invoice.sourceRef matches against later. |
tf_device_id | string | No | Which of your registered devices fiscalises this document. Defaults to your first active device. |
Version Notes
- 16 August 2026 — Connector Overhaul
- Strict validation on
/api/invoices: a malformed payload now returns422naming every problem, instead of a500.credited_invoiceis enforced on credit and debit notes. - Credit notes work on every connector.
credited_invoiceacceptsinvoiceNo,receiptIDorsourceRefand is resolved to the FDMS §4.7 reference; previously the invoice number was dropped and every credit note failedRCPT015. - Odoo: only
out_invoiceandout_refundin statepostedare fiscalised. Vendor bills, vendor credit notes, journal entries and receipts return200withskipped: trueinstead of becoming error receipts that block the Z report. Lines are read tax-exclusive fromprice_subtotal. - Zoho Books: buyer TIN/VAT and line HS codes are read from custom fields via
custom_field_hash,custom_fieldsanditem_custom_fields. The date is no longer used to stamp the receipt (it causedRCPT014/RCPT030on every invoice after the first of the day). Percentage discounts and shipping charges are carried correctly. - QuickBooks Online: tax rates are read from
TxnTaxDetail.TaxLine[].TaxLineDetail.TaxPercent; zero-rated andNONlines stay at 0%. Credit memos are detected even when posted unwrapped, and link throughLinkedTxn. - Excel / CSV: a blank
tax_inclusivecolumn now means tax-inclusive. An unknownreceipt_typeis rejected before submission, and one bad row no longer abandons the rest of the upload.
- Strict validation on
- 10 August 2026
- Added
/portal/reports/summary,/portal/reports/vat,/portal/reports/export.csv— period reporting by month range. - Added Paynow billing endpoints (
/billing/paynow/*) with Ecocash, OneMoney and card. - Added branding settings (
/portal/settings): logo, banking details, invoice notes. - Added reseller application API (
/api/resellers/apply).
- Added
- April 2026
- Receipt signature & hash chaining verified against every worked example in ZIMRA spec v7.2.
- Credit-note linkage to original global number.
- Initial release
- Device registration flow, submitReceipt, fiscal counters, openDay/closeDay, QR & verification codes, Odoo/Zoho/QuickBooks/Excel connectors.