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.

Document Version: 1.1.0
API Base: https://api.thatfiscal.com
ZIMRA Spec: Fiscal Device Gateway API v7.2
Document Date: 16 August 2026

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……
Treat the key like a password. It authorises fiscalisation on your registered devices. Rotate it from the portal if it leaks.

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

HTTPMeaning
200Request 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 + skippedConnector 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.
401Missing or invalid X-Api-Key.
402Subscription suspended, or the endpoint needs a plan feature you do not have.
404No such receipt, or it belongs to another tenant.
409Returned 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.
413Request body over the size limit — split the CSV or shrink the logo.
422The 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.
Duplicates are not an error. Re-posting the same 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.

SystemEndpointSend
OdooPOST /webhooks/odooThe 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 BooksPOST /webhooks/zohoThe 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 OnlinePOST /webhooks/quickbooksThe raw Accounting API v3 Invoice or CreditMemo entity, wrapped or unwrapped. Include TxnTaxDetail — that is the only place QuickBooks carries the tax rate.
Excel / CSVPOST /webhooks/excelMultipart form field file, UTF-8 CSV. Returns {"processed", "rejected", "results", "errors"} — a bad row does not abandon the batch.
Anything elsePOST /webhooks/custom/{map_name}Your own JSON, translated by a field map you save at POST /portal/fieldmaps.
All webhook endpoints accept an optional 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

POST/api/invoices

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:

FieldRule
invoice_noRequired, non-blank, 50 characters or fewer (the FDMS limit).
currencyRequired, exactly 3 letters. Upper-cased for you.
receipt_typeOne of FiscalInvoice, CreditNote, DebitNote. Defaults to FiscalInvoice when omitted.
linesRequired, 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.
paymentsOptional. 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_invoiceRequired when receipt_type is CreditNote or DebitNote. See Submit Credit Note.
Send 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.

HS codes are mandatory on every line for VAT-registered taxpayers (8 digits; 4 or 8 for non-VAT taxpayers). You do not have to send them — missing codes are auto-derived from your Items Master, our Zimbabwe classifier, then your default — but a code we could not derive returns RCPT047.

Submit Credit Note

POST/api/invoices

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:

ShapeUse 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).
The original must already be fiscalised on the same device. If it is not, the credit note is refused with error_code: "RCPT015" and the message "Invoice … has not been fiscalised on this device". Fiscalise the original first, or supply its ZIMRA receiptID directly.
Two more requirements ZIMRA enforces on credit and debit notes: send 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

GET/portal/receipts/{id}

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.

GET/portal/receipts?status=&q=&limit=50&offset=0

List receipts newest first. status filters on one receipt status; q searches the invoice number.

Resend Receipt

POST/portal/receipts/{id}/resend

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

GET/portal/receipts/{id}/qr.png

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

GET/portal/reports/summary?dfrom=2026-01&dto=2026-02

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

GET/portal/reports/vat?dfrom=2026-01&dto=2026-03

Output VAT by rate and currency — taxable, VAT and gross — with credit notes netted. These are the figures for the VAT7 return.

Audit Export

GET/portal/reports/export.csv?dfrom=&dto=

One CSV row per receipt: date, invoice number, type, status, currency, total, global number, verification code, source, error code.

Register Device

POST/portal/devices/register
{ "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

POST/portal/devices/{device_id}/close-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.

GET/portal/devices/{device_id}/status

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.

GET/portal/devices/{device_id}/report?fiscal_day_no=&fmt=json

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

GET/portal/settings  ·  POST/portal/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)

POST/billing/paynow/initiate
{ "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.

GET/billing/subscription

Current plan, price, invoice usage this month, and last payment.

Canonical Invoice Schema

FieldTypeRequiredNotes
invoice_nostringYesYour 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_typestringNoFiscalInvoice (default) | CreditNote | DebitNote. Any other value is rejected with 422.
currencystringYesISO 4217, exactly 3 letters — USD, ZWG, ZAR… Upper-cased for you.
lines_tax_inclusiveboolNoWhether line totals already include VAT. Defaults to true when omitted.
datestringNoYYYY-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.
notesstringCredit/debit notesPrinted on the receipt. Mandatory on CreditNote and DebitNote (RCPT034).
credited_invoiceobjectCredit/debit notes{"invoiceNo": "…"}, {"receiptID": 123} or {"sourceRef": "…"}. The original must already be fiscalised on this device.
buyer.name / buyer.tin / buyer.vatstringNoTIN = 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.addressobjectNo{"email", "phoneNo"} and {"province", "city", "street", "houseNo"}.
lines[].namestringYesItem description, 200 characters maximum.
lines[].hs_codestringNoDigits only, 4 or 8 (8 required for VAT payers). Auto-derived when omitted.
lines[].totalnumberYesThe line total. Validation accepts price instead, but submission needs total — always send it.
lines[].qty / pricenumberNoqty defaults to 1. price is re-derived from total ÷ qty when the two disagree (RCPT024).
lines[].typestringNoSale (default) or Discount. A negative total is treated as a Discount line.
lines[].tax_percent / tax_id / tax_codenumber/strRecommendedMust match your device's applicable taxes (getConfig), or the receipt fails RCPT025.
payments[].money_typestringNoCash | Card | MobileMoney | BankTransfer | Coupon | Credit | Other. Defaults to Cash.
payments[].amountnumberWith paymentsMust sum to the receipt total (RCPT039). Rounding gaps up to 5c are absorbed into the largest payment; a real discrepancy is refused.
source_refstringNoYour system's internal ID for the document. This is what credited_invoice.sourceRef matches against later.
tf_device_idstringNoWhich of your registered devices fiscalises this document. Defaults to your first active device.

Version Notes