Marshal Fiscal API
Fiscalise invoices with the tax authority from any system. Version 1.3.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 file for real at the Lekuka practice gateway, never at the tax authority. Each of your terminals has a test terminal of its own there, so a test document never takes a number from a live receipt sequence or fiscal day. Test documents are kept (list, get and PDF work) and carry the practice gateway's receipt numbers, verification code and QR link; the gateway answers with the authority's validation codes, so flagged documents can be tested too. Webhooks for them say test: true. They are never billed or counted as live use. Add ?dry_run=true to a test filing to check a document without keeping anything. mk_live_… keys file for real.
Idempotency. Every POST sends an Idempotency-Key header: a unique value per operation, e.g. a UUID (400 idempotency_key_required otherwise). Retry with the same key after a timeout and, within 24 hours, you get the first answer back (same status and body, header Idempotent-Replay: true); the work is never done twice. The same key with a different body is refused (422 idempotency_key_reused). Your invoice_number stays unique too: the same number with the same content returns the original document (200), with different content 409 idempotency_conflict.
Amounts are decimal strings in every answer ("230.00"), never binary floats. In requests, send quantities and amounts as decimal strings (recommended) or numbers.
202 Accepted. When the tax authority has not answered yet, a filed document comes back 202 with status queued; the result follows by webhook (document.fiscalized, document.flagged, document.failed) or a later read.
Scopes and allowed addresses. A key holds scopes (documents:write, documents:read, terminals:write, terminals:read, usage:read; all of them by default) and may be limited to IP addresses or ranges. A call outside them is refused with 403 insufficient_scope or ip_not_allowed. Rotate a key from the developer area: the new key works at once and the old one keeps working for an overlap you choose (7 days by default, up to 30) before it expires.
Corrections. An issued invoice can never be changed. Correct it with a credit_note or debit_note that names it in original_invoice_number.
Webhooks are signed: Marshal-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>. For 24 hours after you rotate an endpoint's secret the header carries a v1= for the new secret and one for the old: accept the delivery when any v1 matches.
Limits. 600 requests a minute per key. Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and RateLimit-Policy; over the limit the answer is 429 with Retry-After.
Requests. Bodies are JSON with Content-Type: application/json (otherwise 415), at most 2 MB. Unknown fields are refused (400), never ignored: a misspelt field would otherwise file the wrong document.
Errors. One envelope: {"error": {"code", "message", "details", "fields", "requestId"}, "request_id"} — our stated equivalent of RFC 9457 problem details. Send X-Request-Id to set the id; it is echoed back.
Environments. The test address is https://dev-pos-backoffice.marshal.co.ls; documents filed there go to RSL's test gateway and do not count with RSL. The production address will be announced.
Environments
- Test:
https://dev-pos-backoffice.marshal.co.ls— documents filed with live keys go to RSL's test gateway and do not count with RSL. Build and test your integration here. - Production: the address will be announced. Your code changes only by its base URL and key.
- Status: /developers/status (or
GET /v1/status). We aim for 99.5% availability each month and announce planned maintenance in advance; there is no formal service-level agreement yet. - What changed: changelog. Security for your IT team: security summary.
- Postman: download the collection, made from this specification. Set
apiKey(a test key to start) andterminalId; every POST sends a fresh Idempotency-Key.
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. With a test key it is filed at the Lekuka practice gateway and comes back
fiscalized, with receipt numbers, a verification code and a QR link. Nothing reaches RSL. - Swap in a live key. Print
fiscal.verification_codeand a QR offiscal.qr_urlon the receipt.
# One Idempotency-Key per invoice; resend the SAME key to retry.
IDEM=$(uuidgen)
curl -X POST https://dev-pos-backoffice.marshal.co.ls/v1/ls/invoices \
-H "X-Api-Key: $MARSHAL_API_KEY" \
-H "Idempotency-Key: $IDEM" \
-H "Content-Type: application/json" \
-d '{"invoice_number":"INV-1001","lines":[{"description":"Maize meal 12.5kg","quantity":"2","unit_price":"115.00","tax_id":7},{"description":"Delivery","quantity":"1","unit_price":"50.00","tax_id":1,"is_service":true}],"payments":[{"method":"Cash","amount":"280.00"}]}'Keys
- A business creates its keys in Settings → Developer API.
mk_test_…keys file at the Lekuka practice gateway and never reach RSL (see Test mode);mk_live_…keys file for real. - A key is shown once. Store it in your system's secret store, never in code or in a browser. We keep only its fingerprint.
- A key can be limited to chosen terminals and given an expiry. The developer settings show when and from where each key was last used.
- Scopes. A key holds only what it needs:
documents:write(file),documents:read,terminals:write(enrol, open and close days),terminals:readandusage:read. New keys get all five; a reporting tool needs only the read scopes. A call outside them is 403insufficient_scope. Each endpoint below names its scope. - Allowed addresses. A key can be limited to IP addresses or ranges (e.g.
203.0.113.0/24), such as your office or your servers' outbound addresses. From anywhere else it is refused with 403ip_not_allowed, even with the right key. - Rotating. Press Rotate on the key: the new key is shown once and works at once, and the old one keeps working for the overlap you choose (7 days by default, up to 30), then answers 401
key_expired. Switch your system over within the overlap. Revoke stops a key immediately.
Retries and duplicates
Every POST sends an Idempotency-Key header: a new value for each operation (a UUID), kept with the invoice in your system. If the call times out or the connection drops, send it again with the same key: within 24 hours you get the first answer back (same status and body, with Idempotent-Replay: true) and nothing is filed twice. The same key with a different body is refused (422 idempotency_key_reused); a retry while the first request is still running gets 409 idempotency_in_progress: wait a few seconds and retry. A server error (5xx) is not kept, so retrying after one runs the request afresh.
Your invoice_number is the document's identity on its terminal. Sending the same document again — after a timeout, say — returns the original (200), never a second filing. The same number with different content is refused (409 idempotency_conflict): correct a filed document with a credit or debit note instead. RSL needs invoice numbers unique per taxpayer, so a number another terminal of the same business used is refused too (409 invoice_number_in_use).
Limits and timeouts
- 600 requests a minute per key. Every response says where you stand:
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset(seconds). Over the limit: 429 withRetry-After. - Bodies are JSON (
Content-Type: application/json), at most 2 MB. Unknown fields are refused, never ignored. - Filing waits for RSL. If RSL is slow or down the answer is 202 Accepted with status
queued: the document is numbered and signed and is filed as soon as RSL answers. Listen for the webhook (document.fiscalized,document.flaggedordocument.failed) or read the document again. Do not post it again as a new invoice. Give your HTTP client a timeout of 30 seconds or more.
Test mode
A test key files for real at the Lekuka practice gateway, a copy of RSL's gateway that Marshal runs. Nothing reaches RSL. Each of your terminals has a test terminal of its own there, so a test document never takes a number from a real receipt sequence or fiscal day. Test keys work before the business's fiscal device is enrolled.
- The document is kept. List, get, stats and the PDF work with the test key, and name your terminal's id. A live key never sees test documents, and a test key never sees live ones.
- It gets receipt numbers, a verification code and a QR link from the practice gateway. The QR opens a check at business.marshal.co.ls/practice-receipt, the way RSL's opens RSL's portal. The PDF is marked as a test.
- The practice gateway answers with RSL's validation codes (Red and Yellow
RCPT…), so you can test how your system handles aflaggeddocument. - Credit and debit notes, fiscal days (open, close, and automatic days) and tax codes all work with the test key, on the test terminal.
- Webhooks reach your endpoints whose mode is test or both, with
"test": truein the payload. - Answers say
"mode": "test"and"test": true. Test documents are never billed and never count as live use (GET /v1/usageshows them astest_documents). - Check without filing: add
?dry_run=truetoPOST /v1/ls/invoices. The real checks, numbering and signing run and are then rolled back, with a sandbox key. Nothing is kept, and the answer has statusvalidatedand"dry_run": true. Test keys only. - Test terminals always file online. Offline day files cannot be tested with a test key.
Endpoints
Base URL https://dev-pos-backoffice.marshal.co.ls. Send the key as X-Api-Key or Authorization: Bearer.
/v1/changelogno key needed/v1/countriesno key needed/v1/me/v1/statusno key needed/v1/usageusage:read/v1/{country}/invoicesdocuments:read| 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}/invoicesdocuments:write| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| Idempotency-Key (header) | Required on every POST: a unique value per operation (a UUID is ideal), at most 255 characters. Resend the same key to retry safely: within 24 hours the first answer comes back (same status and body, with `Idempotent-Replay: true`) and the work is not done twice. The same key with a different body is refused (422 `idempotency_key_reused`); while the first request is still running a retry gets 409 `idempotency_in_progress`. A 5xx is not kept, so a retry after one runs afresh. |
| dry_run (query) | Test keys only. `true` checks the document without keeping it: the real checks, numbering and signing run and are then rolled back, the receipt is signed with a sandbox key, nothing is filed and nothing is kept. The answer has status `validated` and `dry_run: true`. With a live key it is refused (400 `dry_run_needs_test_key`), so a check is never filed for real. |
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.00"
}
]
}Example: Credit one unit back
{
"invoice_number": "CN-0001",
"lines": [
{
"original_line": 1,
"quantity": "1",
"unit_price": "115.00"
}
],
"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.00"
},
{
"description": "Delivery",
"is_service": true,
"quantity": "1",
"tax_id": 1,
"unit_price": "50.00"
}
],
"payments": [
{
"amount": "280.00",
"method": "Cash"
}
]
}/v1/{country}/invoices/statsdocuments:read| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
/v1/{country}/invoices/{id}documents:read| 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}/pdfdocuments:read| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| id (path) |
/v1/{country}/terminalsterminals:read| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
/v1/{country}/terminalsterminals:write| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| Idempotency-Key (header) | Required on every POST: a unique value per operation (a UUID is ideal), at most 255 characters. Resend the same key to retry safely: within 24 hours the first answer comes back (same status and body, with `Idempotent-Replay: true`) and the work is not done twice. The same key with a different body is refused (422 `idempotency_key_reused`); while the first request is still running a retry gets 409 `idempotency_in_progress`. A 5xx is not kept, so a retry after one runs afresh. |
/v1/{country}/terminals/{terminal_id}terminals:read| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
/v1/{country}/terminals/{terminal_id}/fiscal-dayterminals:read| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
/v1/{country}/terminals/{terminal_id}/fiscal-day/closeterminals:write| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) | |
| Idempotency-Key (header) | Required on every POST: a unique value per operation (a UUID is ideal), at most 255 characters. Resend the same key to retry safely: within 24 hours the first answer comes back (same status and body, with `Idempotent-Replay: true`) and the work is not done twice. The same key with a different body is refused (422 `idempotency_key_reused`); while the first request is still running a retry gets 409 `idempotency_in_progress`. A 5xx is not kept, so a retry after one runs afresh. |
/v1/{country}/terminals/{terminal_id}/fiscal-day/openterminals:write| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) | |
| Idempotency-Key (header) | Required on every POST: a unique value per operation (a UUID is ideal), at most 255 characters. Resend the same key to retry safely: within 24 hours the first answer comes back (same status and body, with `Idempotent-Replay: true`) and the work is not done twice. The same key with a different body is refused (422 `idempotency_key_reused`); while the first request is still running a retry gets 409 `idempotency_in_progress`. A 5xx is not kept, so a retry after one runs afresh. |
/v1/{country}/terminals/{terminal_id}/fiscal-days/{day_no}/filesterminals:read| 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-codesterminals:read| country (path) | ISO country code. `ls` (Lesotho) is available; others answer 501 until launched. |
| terminal_id (path) |
Objects
Amounts. Every amount in an answer is a decimal string with two places ("230.00"), never a binary float, so totals reconcile to the cent in any language. In requests, send quantities and amounts as decimal strings (recommended) or numbers, with at most four decimals. Tax rates are numbers (15 = 15%).
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 | decimal string | 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 | decimal string | 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 |
| levies | LineLevy[] | Levies on the line: the alcohol, tobacco or plastic levy. Each is part of the line's price, as on a till receipt; with prices_include_tax false it is added to the price (a percentage levy on the net price, a fixed levy per unit). A credit note line with original_line takes the original line's levies. |
| original_line | integer | Notes: the original's line (1-based); its description and tax are inherited |
| quantity* | decimal string | number | Up to four decimals Must be greater than 0. |
| sku | string | |
| tax_amount | number | The line's VAT as money, as most accounting systems export it (e.g. 49.85). With no tax_id, tax_code, tax_category or tax_rate, the rate is worked out from it against the taxpayer's table; with one of them, it is checked and a line where the two disagree is refused. Always positive: a note's direction comes from its type. |
| 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* | decimal string | number | Must be greater than 0. |
Payment
| amount* | decimal string | number | Must be greater than 0. |
| 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 | |
| dry_run | boolean | Present and true on the answer to a dry run (?dry_run=true): nothing was kept. |
| error | string | null | |
| fiscal | object | |
| id | string | |
| invoice_number | string | |
| metadata | object | null | |
| mode | "live" | "test" | live: filed with the tax authority. test: filed with a test key at the Lekuka practice gateway (or checked by a dry run). |
| object | "fiscal_document" | |
| original_invoice_number | string | null | |
| preview | object | Test documents only: the receipt as built — as filed at the practice gateway, or what a dry run would have filed |
| source | "api" | "portal" | |
| status | "validated" | "queued" | "fiscalized" | "flagged" | "failed" | validated: checked but not filed — what would have been filed (a dry run keeps nothing). queued: signed and numbered, awaiting the authority (or, for a test document, the practice gateway). fiscalized: accepted. flagged: accepted with validation errors. failed: refused after retries. |
| tax_total | decimal string | null | |
| taxes | object[] | |
| terminal_id | string | |
| test | boolean | |
| total | decimal string | null | |
| 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 | decimal string | Money in the API's answers: a decimal string with two places, never a binary float. |
| overage_price | decimal string | null | |
| period_end | string | |
| period_start | string | |
| plan_name | string | null | |
| test_documents | integer | Documents filed with test keys this month. Never billed and never counted in documents. |
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.
Rotating an endpoint's secret keeps the old one signing for 24 hours: the header then carries a v1= for each secret. Accept a delivery when any v1 matches (as below), and switch your stored secret within the day.
Events: document.fiscalized, document.flagged, document.failed, document.validated (a test key on a server without the practice gateway), fiscal_day.closed, fiscal_day.close_failed, offline_file.processed, ping. An endpoint receives test events when its mode is test or both.
import crypto from "node:crypto";
// rawBody: the request body exactly as received (a Buffer or string).
// While a secret is being rotated the header carries one v1 per secret:
// accept the delivery when any of them matches.
function verify(rawBody, header, secret) {
const pairs = header.split(",").map((p) => p.split("="));
const t = pairs.find(([k]) => k === "t")?.[1];
const sigs = pairs.filter(([k]) => k === "v1").map(([, v]) => v);
const expected = Buffer.from(crypto.createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex"));
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return fresh && sigs.some((v) => v.length === expected.length && crypto.timingSafeEqual(expected, Buffer.from(v)));
}Errors
One shape for every error — our stated equivalent of RFC 9457 problem details. code is for your program, message for a person, fields maps each wrong field to its problem. Quote requestId to support; send your own X-Request-Id to set it.
{
"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" }],
"fields": { "lines[0].tax_id": "tax_id 99 is not in this taxpayer's tax table" },
"requestId": "5f25c421-3e42-4eac-8bc2-ab3e62e065ad"
},
"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. |
| idempotency_key_required / idempotency_key_invalid | 400 | Every POST needs an Idempotency-Key header (a UUID per operation, at most 255 characters). |
| dry_run_needs_test_key | 400 | ?dry_run=true works with test keys only. A live key always files with RSL. |
| insufficient_scope | 403 | The key lacks this operation's scope; give it the scope in Settings → Developer API. |
| ip_not_allowed | 403 | The key is limited to other addresses; add this one to the key's allowed list. |
| terminal_not_allowed | 403 | The key is limited to other terminals. |
| live_key_required | 403 | Enrolling a device needs a live key. |
| idempotency_in_progress | 409 | The first request with this Idempotency-Key is still running. Retry with the same key in a few seconds. |
| 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. |
| idempotency_key_reused | 422 | That Idempotency-Key was used for a different request in the last 24 hours. Use a new key for a new operation. |
| 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. |
| unsupported_media_type | 415 | Send the body as JSON with Content-Type: application/json. |
| rate_limited | 429 | 600 requests a minute per key. Wait Retry-After seconds; the RateLimit headers say how many remain. |
| country_not_available | 501 | That country is planned, not yet live. |
| fiscalisation_unavailable | 503 | Nothing was filed. Retry with the same Idempotency-Key and invoice_number: it is safe to resend. |
| test_terminal_unavailable | 503 | The test terminal on the practice gateway could not be made ready. Nothing was filed: retry, or add ?dry_run=true. |
RSL's codes
RSL's own codes are passed through unchanged: in fiscal.validation_errors when RSL accepted a document with an error (status flagged), or in error.rule when the API stopped the document before filing (rejected_by_rulebook — nothing was numbered or filed). A Red document is corrected with a credit note; Yellow is a warning.
| Code | Meaning | What to do | Stopped before filing |
|---|---|---|---|
| RCPT010 Red | The currency is not maloti (LSL). | Send currency LSL (the default). | Yes |
| RCPT011 Red | The receipt counter is out of sequence. | The API numbers documents itself — contact support with the request id. | — |
| RCPT012 Red | The global receipt number is out of sequence. | As RCPT011: contact support. | — |
| RCPT013 Red | The invoice number was already used by this taxpayer. | Use a new invoice_number. The API usually catches this first (409 invoice_number_in_use). | — |
| RCPT014 Yellow | The document is dated before its fiscal day opened. | Check issued_at, or leave it out to use the time of filing. | — |
| RCPT015 Red | A credit or debit note does not name the document it corrects. | Send original_invoice_number. | Yes |
| RCPT016 Red | The document has no lines. | Send at least one line. | Yes |
| RCPT017 Red | The document has no tax entries. | Give every line a tax_id from the terminal's tax codes. | Yes |
| RCPT018 Red | The document has no payments. | Send payments, or leave them out to record the total as on account. | Yes |
| RCPT019 Red | The tax totals do not add up for a tax group. | The API calculates totals; check for prices or discounts in fractions of a cent. | — |
| RCPT020 Red | The device signature does not verify. | The API signs documents itself — contact support. | — |
| RCPT021 Red | VAT is charged, but the taxpayer is not registered for VAT. | Use a non-VAT tax_id, or register the business for VAT first. | Yes |
| RCPT023 Red | A line's quantity is not positive. | Send a positive quantity; a credit note reverses amounts by itself. | Yes |
| RCPT025 Red | A tax ID or rate is not in the taxpayer's tax table. | Use the tax_ids from GET /v1/ls/terminals/{id}/tax-codes. | Yes |
| RCPT026 Red | A tax amount does not match its rate. | The API calculates tax; contact support with the request id. | — |
| RCPT030 Red | The document is not dated later than the previous one on the device. | Do not back-date issued_at before a document already filed. | — |
| RCPT031 Yellow | The document is dated in the future. | Check your system's clock and issued_at. | — |
| RCPT032 Red | The document being credited was not filed by this taxpayer. | Check original_invoice_number. | — |
| RCPT033 Red | The document being credited is more than 12 months old. | RSL allows notes within 12 months only. | — |
| RCPT034 Red | A credit or debit note gives no reason. | Send reason. | Yes |
| RCPT035 Red | A credit note credits more than is left of the original. | Credit no more than the original's remaining value. | — |
| RCPT036 Red | A note uses a tax that is not on the original document. | Use the original's tax_ids. | — |
| RCPT037 Red | The document total does not match its lines and taxes. | As RCPT019. | — |
| RCPT040 Red | The total has the wrong sign (a negative invoice, or a positive credit note). | Send amounts as positive numbers; the document type sets the sign. | Yes |
| RCPT041 Yellow | The document is dated after its fiscal day's 24-hour limit. | Terminals with automatic days close in time; on others, close the day sooner. | — |
| RCPT042 Red | A note's currency differs from the original's. | Use the original's currency (LSL). | — |
| RCPT043 Red | Buyer details need both a name and a TIN. | Send buyer.name with buyer.tin (the API checks this). | — |
| RCPT046 Red | A tax invoice (FiscalInvoice) has no buyer. | Send buyer.name and buyer.tin; without them the document is filed as a receipt. | Yes |
| RCPT049 Red | A levy on a line is missing from the document's taxes. | The API builds the taxes; check the line's tax_id. | Yes |
| RCPT050 Red | A tax is used in the wrong place (a levy as the main tax, or VAT as an additional one). | Use VAT, non-VAT or exempt tax_ids for lines. | Yes |
| RCPT052 Red | The receipt counter was already used in the fiscal day. | Contact support. | — |
| RCPT053 Red | The global receipt number was already used in the fiscal day. | Contact support. | — |
Notes for integrators
The pattern is the same everywhere: when your system posts a sale document, send it here with your own document number as invoice_number; keep the verification_code and qr_url that come back and print them on the document. Map your taxes once to the terminal's tax_ids. Only Lesotho (ls) is live; send only Lesotho companies' documents.
A small module: on posting a customer invoice (account.move), call the API with the move's name as invoice_number; store the verification code and QR link on the move and add them to the invoice report. A reversal (credit note) is type credit_note with the reversed move's name as original_invoice_number. Multi-company: file only for companies registered in Lesotho.
Call the API when an invoice is posted — from an extension in Business Central, or from an Azure Function or Logic App fed by your Finance & Operations events. Use the invoice number as invoice_number and write the verification code and QR link back to the invoice. A credit memo is a credit_note naming the original invoice.
Pick up completed sales with Lightspeed's webhooks or API in a small service, and file each one here with the Lightspeed sale number as invoice_number — resending after a failure is then safe. Returns are credit notes naming the original sale. Show the verification code and QR link on the receipt or the customer's copy.
Versioning
The version is in the path (/v1). Within a version changes are additive only — new endpoints, optional fields, response fields and error codes. Anything retiring is announced at least 6 months ahead: its responses carry a Deprecation header from the announcement and a Sunset header with the date it stops (RFC 8594). Every change is listed in the changelog.