Marshal Fiscal API
Fiscalise invoices with the tax authority from any system. Version 1.0.0.
Send an invoice; Marshal validates it against the taxpayer's own tax table, numbers and signs it on your fiscal device, files it with the tax authority and returns the fiscal details (receipt numbers, verification code, QR link).
Keys. mk_test_… keys are a full sandbox: documents go through the real validation, numbering and signing, then are rolled back — nothing reaches the tax authority and they are never billed. mk_live_… keys file for real.
Idempotency. Your invoice_number is the idempotency key, per terminal and mode. Re-sending the same number with the same content returns the original document (HTTP 200, header Idempotent-Replay: true); the same number with different content is refused with 409 idempotency_conflict. It is always safe to retry after a timeout.
Corrections. An issued invoice can never be changed. Correct it with a credit_note or debit_note that names it in original_invoice_number.
Errors always have the shape {"error": {"code", "message", "details"?}, "request_id"}.
Webhooks are signed: Marshal-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>.
Quickstart
- Create a test key in Settings → Developer API. It works before your device is enrolled.
- Ask for the taxpayer's tax table:
GET /v1/ls/terminals/{id}/tax-codes, and use thosetax_ids on your lines. - Post an invoice. In test mode you get back exactly what would be filed, with status
validated. - Swap in a live key. Print
fiscal.verification_codeand a QR offiscal.qr_urlon the receipt.
curl -X POST https://dev-pos-backoffice.marshal.co.ls/v1/ls/invoices \
-H "X-Api-Key: $MARSHAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"invoice_number":"INV-1001","lines":[{"description":"Maize meal 12.5kg","quantity":2,"unit_price":115,"tax_id":7},{"description":"Delivery","quantity":1,"unit_price":50,"tax_id":1,"is_service":true}],"payments":[{"method":"Cash","amount":280}]}'Endpoints
Base URL https://dev-pos-backoffice.marshal.co.ls. Send the key as X-Api-Key or Authorization: Bearer.
/v1/countriesno key needed/v1/me/v1/usage/v1/{country}/invoices| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| status (query) | Comma-separated: validated, queued, fiscalized, flagged, failed |
| type (query) | Comma-separated: sale, credit_note, debit_note |
| terminal_id (query) | |
| invoice_number (query) | |
| from (query) | Date (2026-09-01) or RFC 3339 time, inclusive |
| to (query) | Date or time, exclusive |
| limit (query) | |
| offset (query) |
/v1/{country}/invoices| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
Example: Tax invoice on credit to a registered buyer
{
"buyer": {
"name": "Acme (Pty) Ltd",
"tin": "200012345-6"
},
"invoice_number": "INV-1002",
"lines": [
{
"description": "Consulting, September",
"is_service": true,
"quantity": 1,
"tax_category": "standard",
"unit_price": 5750
}
]
}Example: Credit one unit back
{
"invoice_number": "CN-0001",
"lines": [
{
"original_line": 1,
"quantity": 1,
"unit_price": 115
}
],
"original_invoice_number": "INV-1001",
"reason": "Damaged bag returned",
"settlement": "Cash",
"type": "credit_note"
}Example: Cash sale, two lines
{
"invoice_number": "INV-1001",
"lines": [
{
"description": "Maize meal 12.5kg",
"quantity": 2,
"tax_id": 7,
"unit_price": 115
},
{
"description": "Delivery",
"is_service": true,
"quantity": 1,
"tax_id": 1,
"unit_price": 50
}
],
"payments": [
{
"amount": 280,
"method": "Cash"
}
]
}/v1/{country}/invoices/stats| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
/v1/{country}/invoices/{id}| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| id (path) | The document id, or your invoice_number (add ?terminal_id= if two terminals share a number) |
/v1/{country}/invoices/{id}/pdf| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| id (path) |
/v1/{country}/terminals| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
/v1/{country}/terminals| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
/v1/{country}/terminals/{terminal_id}| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
/v1/{country}/terminals/{terminal_id}/fiscal-day| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
/v1/{country}/terminals/{terminal_id}/fiscal-day/close| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
/v1/{country}/terminals/{terminal_id}/fiscal-day/open| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
/v1/{country}/terminals/{terminal_id}/fiscal-days/{day_no}/files| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) | |
| day_no (path) |
/v1/{country}/terminals/{terminal_id}/tax-codes| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
Objects
DocumentRequest
| buyer | Buyer | Omit for a walk-in sale. With a TIN (and name) the document is a tax invoice. |
| currency | "LSL" | Default "LSL". |
| expected_total | number | If given, the document is refused (total_mismatch) unless the lines total exactly this |
| invoice_number* | string | Your document number. Unique across your account; the idempotency key. |
| issued_at | string | Informational; the fiscal date is the moment of filing |
| lines* | Line[] | |
| metadata | object | Stored and returned as-is |
| original_invoice_number | string | Notes: the sale being corrected |
| payments | Payment[] | What was paid. Anything unpaid is filed as Credit (owed). Send the amount applied, not tendered. |
| prices_include_tax | boolean | false: unit prices are converted to tax-inclusive, rounded to the cent Default true. |
| reason | string | Notes: required |
| settlement | MoneyType | Notes: how the correction was settled. Default Credit (against the account). |
| terminal_id | string | Optional when the key (or account) has a single terminal |
| type | "sale" | "credit_note" | "debit_note" | Default "sale". |
Line
| description | string | |
| discount | number | Line discount, in money. Must divide evenly by quantity. |
| hs_code | string | |
| is_service | boolean | Chooses between the goods and services variants of a rate |
| original_line | integer | Notes: the original's line (1-based); its description and tax are inherited |
| quantity* | number | Up to four decimals |
| sku | string | |
| tax_category | "standard" | "zero_rated" | "exempt" | |
| tax_code | string | |
| tax_id | integer | Preferred: an id from the terminal's tax-codes |
| tax_rate | number | With or instead of tax_category, e.g. 15 |
| unit_price* | number |
Payment
| amount* | number | |
| method* | MoneyType | |
| reference | string |
Buyer
Omit for a walk-in sale. With a TIN (and name) the document is a tax invoice.
| address | string | |
| string | ||
| name | string | |
| phone | string | |
| tin | string |
Document
| country | string | |
| created_at | string | |
| currency | string | |
| error | string | null | |
| fiscal | object | |
| id | string | |
| invoice_number | string | |
| metadata | object | null | |
| mode | "live" | "test" | |
| object | "fiscal_document" | |
| original_invoice_number | string | null | |
| preview | object | Test mode only: the exact receipt that would have been filed |
| source | "api" | "portal" | |
| status | "validated" | "queued" | "fiscalized" | "flagged" | "failed" | validated: test mode, would have been filed. queued: signed and numbered, awaiting the authority. fiscalized: accepted. flagged: accepted with validation errors. failed: refused after retries. |
| tax_total | number | |
| taxes | object[] | |
| terminal_id | string | |
| test | boolean | |
| total | number | |
| type | "sale" | "credit_note" | "debit_note" | |
| updated_at | string |
Terminal
| api_terminal | boolean | |
| auto_day | boolean | |
| device_id | string | null | |
| enrolled | boolean | |
| fiscal_day | FiscalDay | |
| id | string | |
| location | string | |
| name | string | |
| operating_mode | "Online" | "Offline" | |
| taxpayer | object |
FiscalDay
| closed_at | string | null | |
| closes_by | string | |
| error | string | null | |
| failed_receipts | integer | |
| files | object[] | Offline devices only |
| fiscal_day_no | integer | |
| opened_at | string | |
| pending_receipts | integer | |
| receipts | integer | |
| status | "open" | "closing" | "closed" | "close_failed" |
TaxCode
| category | string | |
| kind | "tax" | "levy" | |
| name | string | |
| rate | number | null | |
| tax_id | integer | |
| type | string | |
| valid | boolean |
Usage
| currency | string | |
| documents | integer | |
| hard_limit | boolean | |
| included | integer | null | |
| metered | boolean | |
| overage | integer | |
| overage_amount | number | |
| overage_price | number | null | |
| period_end | string | |
| period_start | string | |
| test_documents | integer |
Error
| error* | object | |
| request_id | string |
Webhooks
Add endpoints in Settings → Developer API. Each event is a POST with Marshal-Event, Marshal-Delivery and Marshal-Signature: t=…,v1=…. Answer 2xx within 10 seconds; anything else is retried with backoff for about two days. The same event can arrive twice — use the delivery id to ignore repeats.
Events: document.fiscalized, document.flagged, document.failed, document.validated (test mode), fiscal_day.closed, fiscal_day.close_failed, offline_file.processed, ping.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a Buffer or string)
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Errors
{
"error": {
"code": "validation_failed",
"message": "The document has 1 problem(s); see details.",
"details": [{ "field": "lines[0].tax_id", "message": "tax_id 99 is not in this taxpayer's tax table" }]
},
"request_id": "5f25c421-3e42-4eac-8bc2-ab3e62e065ad"
}| missing_api_key / invalid_api_key | 401 | No key, or not one of ours. |
| key_revoked / key_expired | 401 | Issue a new key in Settings → Developer API. |
| account_suspended / subscription_lapsed / allowance_exhausted | 402 | Billing stops live filing. Test keys keep working. |
| terminal_not_allowed | 403 | The key is limited to other terminals. |
| live_key_required | 403 | Opening/closing real fiscal days and enrolling devices need a live key. |
| idempotency_conflict | 409 | That invoice_number was filed with different content. Use a new number, or correct with a note. |
| invoice_number_in_use | 409 | Another terminal of the same taxpayer used that number; RSL requires numbers unique per taxpayer. |
| terminal_not_ready | 409 | The terminal is not enrolled with RSL (use a test key meanwhile). |
| fiscal_day_closed / fiscal_day_expiring / fiscal_day_closing | 409 | Only on terminals whose days your system manages. |
| validation_failed | 422 | See details[]: one entry per field. |
| overpaid | 422 | Payments exceed the total — send the amount applied, not the cash tendered. |
| total_mismatch | 422 | The lines do not add up to expected_total. |
| original_invalid | 422 | A note's original_invoice_number was not found, or never cleared. |
| rejected_by_rulebook | 422 | RSL would refuse the document; rule names the RSL rule (e.g. RCPT035). Nothing was filed. |
| rate_limited | 429 | 600 requests a minute per key. Wait Retry-After seconds. |
| country_not_available | 501 | That country is planned, not yet live. |
| fiscalisation_unavailable | 503 | Nothing was filed. Retry — the same invoice_number is safe to resend. |