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

  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. In test mode you get back exactly what would be filed, with status validated.
  4. Swap in a live key. Print fiscal.verification_code and a QR of fiscal.qr_url on 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.

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 Error
get/v1/usage
This month's billable documents and allowance
200 OK
get/v1/{country}/invoices
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 OK
post/v1/{country}/invoices
File a sale, credit note or debit note
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
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
    }
  ]
}
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"
    }
  ]
}
200 Idempotent replay of a document already filed with this number and content201 Filed (live) or validated (test)400 Error401 Error402 Account suspended, subscription lapsed, or allowance exhausted409 idempotency_conflict, invoice_number_in_use, fiscal_day_* or terminal_not_ready422 validation_failed, overpaid, total_mismatch, original_invalid, rejected_by_rulebook (with `rule`, e.g. RCPT035)429 Error501 Country not yet available503 fiscalisation_unavailable — nothing was filed; retry later
get/v1/{country}/invoices/stats
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 OK
get/v1/{country}/invoices/{id}
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 OK404 Error
get/v1/{country}/invoices/{id}/pdf
Printable fiscal document (80 mm)
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
id (path)
200 PDF
get/v1/{country}/terminals
List terminals
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
200 OK
post/v1/{country}/terminals
Enrol a new fiscal device (live keys only)
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
Body: object
201 Enrolled502 authority_rejected
get/v1/{country}/terminals/{terminal_id}
Get a terminal
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
200 OK
get/v1/{country}/terminals/{terminal_id}/fiscal-day
The current (or last) fiscal day
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
200 OK
post/v1/{country}/terminals/{terminal_id}/fiscal-day/close
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)
200 Closed409 fiscal_day_close_failed
post/v1/{country}/terminals/{terminal_id}/fiscal-day/open
Open a fiscal day (live keys; auto-day terminals do this themselves)
country (path)ISO country code. `ls` (Lesotho) is available; others answer 501 until launched.
terminal_id (path)
201 Opened409 fiscal_day_open
get/v1/{country}/terminals/{terminal_id}/fiscal-days/{day_no}/files
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 OK
get/v1/{country}/terminals/{terminal_id}/tax-codes
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 OK

Objects

DocumentRequest

buyerBuyerOmit for a walk-in sale. With a TIN (and name) the document is a tax invoice.
currency"LSL" Default "LSL".
expected_totalnumberIf 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
discountnumberLine discount, in money. Must divide evenly by quantity.
hs_codestring
is_servicebooleanChooses between the goods and services variants of a rate
original_lineintegerNotes: the original's line (1-based); its description and tax are inherited
quantity*numberUp to four decimals
skustring
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*number

Payment

amount*number
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
errorstring | null
fiscalobject
idstring
invoice_numberstring
metadataobject | null
mode"live" | "test"
object"fiscal_document"
original_invoice_numberstring | null
previewobjectTest 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_totalnumber
taxesobject[]
terminal_idstring
testboolean
totalnumber
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_amountnumber
overage_pricenumber | null
period_endstring
period_startstring
test_documentsinteger

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.

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_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.
terminal_not_allowed403The key is limited to other terminals.
live_key_required403Opening/closing real fiscal days and enrolling devices need a live key.
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.
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.
rate_limited429600 requests a minute per key. Wait Retry-After seconds.
country_not_available501That country is planned, not yet live.
fiscalisation_unavailable503Nothing was filed. Retry — the same invoice_number is safe to resend.