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) and terminalId; every POST sends a fresh Idempotency-Key.

Quickstart

  1. Create a test key in Settings → Developer API. It works before your device is enrolled.
  2. Ask for the taxpayer's tax table: GET /v1/ls/terminals/{id}/tax-codes, and use those tax_ids on your lines.
  3. 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.
  4. Swap in a live key. Print fiscal.verification_code and a QR of fiscal.qr_url on 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:read and usage:read. New keys get all five; a reporting tool needs only the read scopes. A call outside them is 403 insufficient_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 403 ip_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 with Retry-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.flagged or document.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 a flagged document.
  • 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": true in the payload.
  • Answers say "mode": "test" and "test": true. Test documents are never billed and never count as live use (GET /v1/usage shows them as test_documents).
  • Check without filing: add ?dry_run=true to POST /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 status validated and "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.

get/v1/changelogno key needed
Changelog and deprecation policy
200 Changelog
get/v1/countriesno key needed
List countries
200 Countries and whether each is available
get/v1/me
The key and account you are calling as
200 OK401 Error403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
get/v1/statusno key needed
Service status
200 Status
get/v1/usageusage:read
This month's billable documents and allowance
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
get/v1/{country}/invoicesdocuments:read
List documents (of the key's mode)
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)
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
post/v1/{country}/invoicesdocuments:write
File a sale, credit note or debit note
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.
Body: DocumentRequest
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"
    }
  ]
}
200 Idempotent replay of a document already filed with this number and content201 Filed and accepted (a live key at the tax authority, a test key at the practice gateway), or validated (a dry run)202 Accepted, not yet answered by the tax authority (status queued): the document is numbered and signed and will be filed; the outcome follows by webhook, or read the document again. Retrying with the same Idempotency-Key returns this answer, not a second document.400 The body's shape (a malformed decimal, an unknown field), idempotency_key_required, idempotency_key_invalid, dry_run_needs_test_key401 Error402 Account suspended, subscription lapsed, or allowance exhausted403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.409 idempotency_conflict, invoice_number_in_use, fiscal_day_* or terminal_not_ready, idempotency_in_progress415 The body was not sent as JSON (Content-Type: application/json).422 validation_failed, overpaid, total_mismatch, original_invalid, rejected_by_rulebook (with `rule`, e.g. RCPT035), idempotency_key_reused429 Error501 Country not yet available503 fiscalisation_unavailable — nothing was filed; retry later. test_terminal_unavailable — the test terminal on the practice gateway could not be made ready; nothing was filed: retry, or add ?dry_run=true.
get/v1/{country}/invoices/statsdocuments:read
Counts and totals by status and type (same filters as the list)
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
200 Counts and totals by document type for the period; totals and tax totals are decimal strings (Money), with currency.403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
get/v1/{country}/invoices/{id}documents:read
Get a document
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)
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.404 Error
get/v1/{country}/invoices/{id}/pdfdocuments:read
Printable fiscal document (80 mm)
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
id (path)
200 PDF403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
get/v1/{country}/terminalsterminals:read
List terminals
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
post/v1/{country}/terminalsterminals:write
Enrol a new fiscal device (live keys only)
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.
Body: object
201 Enrolled400 Error403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.409 idempotency_in_progress: a request with this Idempotency-Key is still being processed; retry in a few seconds.415 The body was not sent as JSON (Content-Type: application/json).422 idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours.502 authority_rejected
get/v1/{country}/terminals/{terminal_id}terminals:read
Get a terminal
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
get/v1/{country}/terminals/{terminal_id}/fiscal-dayterminals:read
The current (or last) fiscal day
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
post/v1/{country}/terminals/{terminal_id}/fiscal-day/closeterminals:write
Close the fiscal day (Z report). On an Offline device this builds and sends the day file.
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.
200 Closed400 Error403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.409 fiscal_day_close_failed422 idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours.
post/v1/{country}/terminals/{terminal_id}/fiscal-day/openterminals:write
Open a fiscal day (auto-day terminals do this themselves)
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.
201 Opened400 Error403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.409 fiscal_day_open422 idempotency_key_reused: this Idempotency-Key was used for a different request in the last 24 hours.
get/v1/{country}/terminals/{terminal_id}/fiscal-days/{day_no}/filesterminals:read
Offline mode: the day files and how the authority processed them
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
day_no (path)
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.
get/v1/{country}/terminals/{terminal_id}/tax-codesterminals:read
The taxpayer's tax table — the tax_id values a line may use
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
200 OK403 insufficient_scope: the key lacks this operation's scope. ip_not_allowed: the key has an IP allowlist and this request came from elsewhere.

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

buyerBuyerOmit for a walk-in sale. With a TIN (and name) the document is a tax invoice.
currency"LSL" Default "LSL".
expected_totaldecimal string | numberIf given, the document is refused (total_mismatch) unless the lines total exactly this
invoice_number*stringYour document number. Unique across your account; the idempotency key.
issued_atstringInformational; the fiscal date is the moment of filing
lines*Line[]
metadataobjectStored and returned as-is
original_invoice_numberstringNotes: the sale being corrected
paymentsPayment[]What was paid. Anything unpaid is filed as Credit (owed). Send the amount applied, not tendered.
prices_include_taxbooleanfalse: unit prices are converted to tax-inclusive, rounded to the cent Default true.
reasonstringNotes: required
settlementMoneyTypeNotes: how the correction was settled. Default Credit (against the account).
terminal_idstringOptional when the key (or account) has a single terminal
type"sale" | "credit_note" | "debit_note" Default "sale".

Line

descriptionstring
discountdecimal string | numberLine discount, in money. Must divide evenly by quantity.
hs_codestring
is_servicebooleanChooses between the goods and services variants of a rate
leviesLineLevy[]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_lineintegerNotes: the original's line (1-based); its description and tax are inherited
quantity*decimal string | numberUp to four decimals Must be greater than 0.
skustring
tax_amountnumberThe 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_codestring
tax_idintegerPreferred: an id from the terminal's tax-codes
tax_ratenumberWith or instead of tax_category, e.g. 15
unit_price*decimal string | numberMust be greater than 0.

Payment

amount*decimal string | numberMust be greater than 0.
method*MoneyType
referencestring

Buyer

Omit for a walk-in sale. With a TIN (and name) the document is a tax invoice.

addressstring
emailstring
namestring
phonestring
tinstring

Document

countrystring
created_atstring
currencystring
dry_runbooleanPresent and true on the answer to a dry run (?dry_run=true): nothing was kept.
errorstring | null
fiscalobject
idstring
invoice_numberstring
metadataobject | 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_numberstring | null
previewobjectTest 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_totaldecimal string | null
taxesobject[]
terminal_idstring
testboolean
totaldecimal string | null
type"sale" | "credit_note" | "debit_note"
updated_atstring

Terminal

api_terminalboolean
auto_dayboolean
device_idstring | null
enrolledboolean
fiscal_dayFiscalDay
idstring
locationstring
namestring
operating_mode"Online" | "Offline"
taxpayerobject

FiscalDay

closed_atstring | null
closes_bystring
errorstring | null
failed_receiptsinteger
filesobject[]Offline devices only
fiscal_day_nointeger
opened_atstring
pending_receiptsinteger
receiptsinteger
status"open" | "closing" | "closed" | "close_failed"

TaxCode

categorystring
kind"tax" | "levy"
namestring
ratenumber | null
tax_idinteger
typestring
validboolean

Usage

currencystring
documentsinteger
hard_limitboolean
includedinteger | null
meteredboolean
overageinteger
overage_amountdecimal stringMoney in the API's answers: a decimal string with two places, never a binary float.
overage_pricedecimal string | null
period_endstring
period_startstring
plan_namestring | null
test_documentsintegerDocuments filed with test keys this month. Never billed and never counted in documents.

Error

error*object
request_idstring

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_key401No key, or not one of ours.
key_revoked / key_expired401Issue a new key in Settings → Developer API.
account_suspended / subscription_lapsed / allowance_exhausted402Billing stops live filing. Test keys keep working.
idempotency_key_required / idempotency_key_invalid400Every POST needs an Idempotency-Key header (a UUID per operation, at most 255 characters).
dry_run_needs_test_key400?dry_run=true works with test keys only. A live key always files with RSL.
insufficient_scope403The key lacks this operation's scope; give it the scope in Settings → Developer API.
ip_not_allowed403The key is limited to other addresses; add this one to the key's allowed list.
terminal_not_allowed403The key is limited to other terminals.
live_key_required403Enrolling a device needs a live key.
idempotency_in_progress409The first request with this Idempotency-Key is still running. Retry with the same key in a few seconds.
idempotency_conflict409That invoice_number was filed with different content. Use a new number, or correct with a note.
invoice_number_in_use409Another terminal of the same taxpayer used that number; RSL requires numbers unique per taxpayer.
terminal_not_ready409The terminal is not enrolled with RSL (use a test key meanwhile).
fiscal_day_closed / fiscal_day_expiring / fiscal_day_closing409Only on terminals whose days your system manages.
validation_failed422See details[]: one entry per field.
idempotency_key_reused422That Idempotency-Key was used for a different request in the last 24 hours. Use a new key for a new operation.
overpaid422Payments exceed the total — send the amount applied, not the cash tendered.
total_mismatch422The lines do not add up to expected_total.
original_invalid422A note's original_invoice_number was not found, or never cleared.
rejected_by_rulebook422RSL would refuse the document; rule names the RSL rule (e.g. RCPT035). Nothing was filed.
unsupported_media_type415Send the body as JSON with Content-Type: application/json.
rate_limited429600 requests a minute per key. Wait Retry-After seconds; the RateLimit headers say how many remain.
country_not_available501That country is planned, not yet live.
fiscalisation_unavailable503Nothing was filed. Retry with the same Idempotency-Key and invoice_number: it is safe to resend.
test_terminal_unavailable503The 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.

CodeMeaningWhat to doStopped before filing
RCPT010 RedThe currency is not maloti (LSL).Send currency LSL (the default).Yes
RCPT011 RedThe receipt counter is out of sequence.The API numbers documents itself — contact support with the request id.—
RCPT012 RedThe global receipt number is out of sequence.As RCPT011: contact support.—
RCPT013 RedThe 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 YellowThe document is dated before its fiscal day opened.Check issued_at, or leave it out to use the time of filing.—
RCPT015 RedA credit or debit note does not name the document it corrects.Send original_invoice_number.Yes
RCPT016 RedThe document has no lines.Send at least one line.Yes
RCPT017 RedThe document has no tax entries.Give every line a tax_id from the terminal's tax codes.Yes
RCPT018 RedThe document has no payments.Send payments, or leave them out to record the total as on account.Yes
RCPT019 RedThe 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 RedThe device signature does not verify.The API signs documents itself — contact support.—
RCPT021 RedVAT 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 RedA line's quantity is not positive.Send a positive quantity; a credit note reverses amounts by itself.Yes
RCPT025 RedA 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 RedA tax amount does not match its rate.The API calculates tax; contact support with the request id.—
RCPT030 RedThe document is not dated later than the previous one on the device.Do not back-date issued_at before a document already filed.—
RCPT031 YellowThe document is dated in the future.Check your system's clock and issued_at.—
RCPT032 RedThe document being credited was not filed by this taxpayer.Check original_invoice_number.—
RCPT033 RedThe document being credited is more than 12 months old.RSL allows notes within 12 months only.—
RCPT034 RedA credit or debit note gives no reason.Send reason.Yes
RCPT035 RedA credit note credits more than is left of the original.Credit no more than the original's remaining value.—
RCPT036 RedA note uses a tax that is not on the original document.Use the original's tax_ids.—
RCPT037 RedThe document total does not match its lines and taxes.As RCPT019.—
RCPT040 RedThe 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 YellowThe 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 RedA note's currency differs from the original's.Use the original's currency (LSL).—
RCPT043 RedBuyer details need both a name and a TIN.Send buyer.name with buyer.tin (the API checks this).—
RCPT046 RedA tax invoice (FiscalInvoice) has no buyer.Send buyer.name and buyer.tin; without them the document is filed as a receipt.Yes
RCPT049 RedA levy on a line is missing from the document's taxes.The API builds the taxes; check the line's tax_id.Yes
RCPT050 RedA 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 RedThe receipt counter was already used in the fiscal day.Contact support.—
RCPT053 RedThe 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.

Odoo 18

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.

Dynamics 365

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.

Lightspeed

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.