StabisoDocs

REST API

Invoice a customer, send a QR-bill, receive the payment

This walkthrough follows a sales invoice from the customer's master data to the money in the bank: you create a Swiss customer, preview and book an invoice with the VAT data-vNext determines, hand the customer a QR-bill, record the receipt and check that nothing is open any more. It is for a client that issues invoices on behalf of a company, for example an invoicing app or a script.

Before you start

  • Scopes: read for the lookups, draft to create and change, post so that your own invoices and receipts are booked at once (with draft alone they wait for a second person's approval). The demo key $ERP_KEY has all of them.
  • The demo organization has the entity CH01 (Demo AG, CHF, Swiss chart, VAT 8.1% standard and 2.6% reduced) with its bank account 1020 registered with an IBAN and a QR-IBAN, and the entity DE01 (Demo GmbH, EUR). September 2026 is open for new documents.
  • Every POST that creates something needs an Idempotency-Key header; repeat a request with the same key and you get the first answer again instead of a second document.

1. Create the customer

A customer is identified by its kind and code inside an entity. The structured address (street, building_number, postal_code, town, country) is what the QR-bill prints as the debtor, so give it in full. A customer created by a person is active at once; only new vendors wait for a second person (see Customers and vendors).

curl -X POST "$ERP_URL/v1/entities/CH01/parties" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-customer-1" \
  -H "Content-Type: application/json" \
  -d '{"kind": "customer", "code": "C-MATTER", "name": "Matter Treuhand AG", "country": "CH", "currency": "CHF", "vat_number": "CHE-109.322.551", "language": "de", "street": "Bahnhofplatz", "building_number": "3", "postal_code": "3011", "town": "Bern", "email": "buchhaltung@matter-treuhand.example"}'
{"kind": "customer", "code": "C-MATTER", "status": "active", "currency": "CHF", "payment_terms_days": 30, "town": "Bern"}

The customer starts with payment terms of 30 days, which set the invoice's due date.

2. Preview the invoice

The preview answers what the invoice would look like, with the VAT of every line determined from the place of supply, and stores nothing. The body is the one you send to create the document, so a client can show live totals while the user types.

curl -X POST "$ERP_URL/v1/documents/preview" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-MATTER", "document_date": "2026-09-21", "lines": [{"description": "Consulting September", "quantity": "10", "unit_price": "100"}, {"description": "Books", "quantity": "5", "unit_price": "40", "rate_type": "reduced"}]}'
{
  "status": "draft",
  "number": null,
  "currency": "CHF",
  "due_date": "2026-10-21",
  "net_total": "1200.00",
  "tax_total": "86.20",
  "total": "1286.20",
  "lines": [
    {"line_no": 1, "net_amount": "1000.00", "tax_rate": "8.1", "tax_amount": "81.00", "tax": {"treatment": "domestic"}},
    {"line_no": 2, "net_amount": "200.00", "tax_rate": "2.6", "tax_amount": "5.20", "tax": {"treatment": "domestic"}}
  ]
}

Notice that you never send a tax rate: the consulting line is charged the standard rate of 8.1%, the line marked rate_type: reduced the reduced rate of 2.6%. Each line's tax object records the decision and the rule behind it. number is null because only a posted invoice is numbered.

3. Create and submit the invoice

POST /v1/documents creates the invoice and, by default, submits it through the approval policy in the same call. A person's invoice within the policy is posted and numbered at once.

curl -X POST "$ERP_URL/v1/documents" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-invoice-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-MATTER", "document_date": "2026-09-21", "lines": [{"description": "Consulting September", "quantity": "10", "unit_price": "100"}, {"description": "Books", "quantity": "5", "unit_price": "40", "rate_type": "reduced"}]}'
{
  "document": {
    "id": "<uuid>",
    "kind": "sales_invoice",
    "status": "posted",
    "number": "<any>",
    "party_code": "C-MATTER",
    "net_total": "1200.00",
    "tax_total": "86.20",
    "total": "1286.20",
    "open_amount": "1286.20"
  },
  "submit": {"status": "posted", "policy": {"decision": "allow"}}
}

Notice submit.policy.decision: allow means the invoice is posted; require_approval would leave it pending_approval until a second person decides. The invoice number (for example INV-2026-000006) depends on how many invoices the entity already has, so capture the id and use it in the next requests. open_amount is what the customer still owes.

4. Read the invoice and its ETag

Reading a document returns an ETag header that names its version. You send it back as If-Match when you change, submit or void a document, so that a change someone else made in the meantime is not overwritten (step 10 shows it).

curl -X GET "$ERP_URL/v1/documents/$INVOICE_ID" \
  -H "Authorization: Bearer $ERP_KEY"
{"status": "posted", "party_name": "Matter Treuhand AG", "total": "1286.20", "open_amount": "1286.20", "version": "<any>"}

5. Get the QR-bill

The QR-bill is the Swiss payment slip for the open amount of a posted invoice. data-vNext picks the registered bank account: with a QR-IBAN (the demo account has one) the reference is a QR reference derived from the invoice number, so the bank can report the payment by that reference; without one it is an ISO 11649 creditor reference. The default format is JSON.

curl -X GET "$ERP_URL/v1/invoices/$INVOICE_ID/qr-bill" \
  -H "Authorization: Bearer $ERP_KEY"
{
  "document_id": "<uuid>",
  "account": "1020",
  "open_amount": "1286.20",
  "payload": "<any>",
  "bill": {
    "iban": "CH6431961000004421557",
    "reference_type": "QRR",
    "reference": "<any>",
    "amount": "1286.20",
    "currency": "CHF",
    "creditor": {"name": "Demo AG", "town": "Zürich", "country": "CH"},
    "debtor": {"name": "Matter Treuhand AG", "postal_code": "3011", "town": "Bern", "country": "CH"}
  }
}

payload is the text that goes into the QR code (it starts with SPC, version 0200); bill holds the same data as fields. The debtor comes from the customer's structured address. The payment text is in English by default; add language=de, fr or it to change it.

6. Get the QR-bill as an image

With format=svg the answer is the payment part with the receipt (210 x 105 mm) ready to print; format=pdf gives the same as a PDF. Only the start of the SVG and two labels are shown here.

curl -X GET "$ERP_URL/v1/invoices/$INVOICE_ID/qr-bill?format=svg" \
  -H "Authorization: Bearer $ERP_KEY"
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
Payment part</text>
Receipt</text>

7. Record the receipt

When the customer pays, record a receipt: direction: incoming, the customer, the amount, and the allocations that say which invoice it settles. The allocations must add up to the amount. The bank account defaults to the entity's bank account (1020). Like an invoice, the receipt goes through the approval policy.

curl -X POST "$ERP_URL/v1/payments" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-receipt-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "direction": "incoming", "party": "C-MATTER", "payment_date": "2026-09-28", "amount": "1286.20", "reference": "QR-bill payment", "allocations": [{"document": "$INVOICE_ID", "amount": "1286.20"}]}'
{"payment_id": "<uuid>", "status": "posted", "submit": {"status": "posted", "policy": {"decision": "allow"}}}

A partial payment is allowed too: allocate less than the open amount and the rest stays open (and the next QR-bill shows only the rest).

8. Check that nothing is open

The invoice now has an open_amount of 0.00, and its allocations list the receipt.

curl -X GET "$ERP_URL/v1/documents/$INVOICE_ID" \
  -H "Authorization: Bearer $ERP_KEY"
{"status": "posted", "total": "1286.20", "open_amount": "0.00"}
curl -X GET "$ERP_URL/v1/documents/$INVOICE_ID/allocations" \
  -H "Authorization: Bearer $ERP_KEY"
[{"kind": "payment", "amount": "1286.20"}]

The customer card shows what the customer owes in total. Pass as_of so that the overdue amounts are counted to a day you choose (the default is today); there is nothing open, so balances is empty.

curl -X GET "$ERP_URL/v1/entities/CH01/parties/C-MATTER?kind=customer&as_of=2026-09-30" \
  -H "Authorization: Bearer $ERP_KEY"
{"code": "C-MATTER", "status": "active", "balances": []}

A paid invoice is also found by its status in the document list:

curl -X GET "$ERP_URL/v1/documents?entity=CH01&kind=sales_invoice&status=paid&party=C-MATTER" \
  -H "Authorization: Bearer $ERP_KEY"
[{"party_code": "C-MATTER", "total": "1286.20", "open_amount": "0.00"}]

9. Look at the aging report

The aging report lists every open posted document of the entity with its open amount, its days overdue and its bucket (current, 1-30, 31-60, 61-90, 90+ days past due), with totals per currency. Settled documents are left out, so the invoice from this page is not in it any more. Had you asked before step 7, it would have been in the list with a shape like this (the number is the one your entity assigned):

{
  "number": "INV-2026-000006",
  "party_code": "C-MATTER",
  "kind": "sales_invoice",
  "due_date": "2026-10-21",
  "total": "1286.20",
  "open": "1286.20",
  "days_overdue": 0,
  "bucket": "current"
}
curl -X GET "$ERP_URL/v1/entities/CH01/reports/aging?side=receivables&as_of=2026-09-30" \
  -H "Authorization: Bearer $ERP_KEY"
{"entity": "CH01", "side": "receivables", "as_of": "2026-09-30"}

10. Void a draft with If-Match

A draft invoice can still be changed or withdrawn. Create one with submit=false, read it to get its ETag, and let another person change it before you void it. Your void names the old version, so it is refused instead of discarding the other person's work.

curl -X POST "$ERP_URL/v1/documents?submit=false" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-draft-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-MATTER", "document_date": "2026-09-21", "lines": [{"description": "Consulting October", "quantity": "10", "unit_price": "100"}]}'
{"document": {"status": "draft", "number": null, "total": "1081.00"}, "submit": null}
curl -X GET "$ERP_URL/v1/documents/$DRAFT_ID" \
  -H "Authorization: Bearer $ERP_KEY"
{"status": "draft", "open_amount": null}

The other person changes the note on the draft (PATCH replaces only what you send). The answer carries the new ETag.

curl -X PATCH "$ERP_URL/v1/documents/$DRAFT_ID" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"notes": "Please quote order PO-8812"}'
{"status": "draft", "notes": "Please quote order PO-8812"}

Voiding with the ETag you read earlier is refused with 412; nothing changes.

curl -X POST "$ERP_URL/v1/documents/$DRAFT_ID/void" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-void-stale" \
  -H 'If-Match: $OLD_ETAG' \
  -H "Content-Type: application/json" \
  -d '{"reason": "Customer withdrew the order"}'
{"error": {"code": "precondition_failed"}}

Read the document again (or use the ETag the PATCH returned) and void it. A voided draft keeps no number and posts nothing.

curl -X POST "$ERP_URL/v1/documents/$DRAFT_ID/void" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-void-current" \
  -H 'If-Match: $NEW_ETAG' \
  -H "Content-Type: application/json" \
  -d '{"reason": "Customer withdrew the order"}'
{"status": "void", "number": null}

11. Issue a German invoice as XRechnung

The entity DE01 (Demo GmbH) issues invoices to German customers, and German public buyers expect them as XRechnung, a structured XML invoice. The demo has an August invoice to the customer C-HH; list it to find its id.

curl -X GET "$ERP_URL/v1/documents?entity=DE01&kind=sales_invoice&party=C-HH" \
  -H "Authorization: Bearer $ERP_KEY"
[{"party_code": "C-HH", "status": "posted", "currency": "EUR", "net_total": "5000.00", "tax_total": "950.00", "total": "5950.00", "buyer_reference": "4500012345"}]

The XRechnung is generated from the posted invoice and checked against the EN 16931 and XRechnung rules before it is returned (an invoice that breaks a fatal rule answers 422; format=json shows the check). The file here is the invoice's XML; only two of its lines are shown.

curl -X GET "$ERP_URL/v1/invoices/$HH_INVOICE_ID/xrechnung" \
  -H "Authorization: Bearer $ERP_KEY"
urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0
<ram:BuyerReference>4500012345</ram:BuyerReference>

The buyer reference 4500012345 is the customer's order number on the invoice. A customer that is a public buyer carries a Leitweg-ID instead, which becomes the buyer reference.

What can go wrong

A credit note cannot credit more than was invoiced, less earlier credit notes. The invoice from step 3 is far smaller than this credit, so it is refused with 422 and nothing is stored. (A credit note within that limit is accepted even for a paid invoice: what it credits beyond the open amount stays open on the credit note, to refund or apply to another invoice.)

curl -X POST "$ERP_URL/v1/documents" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-credit-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_credit_note", "party": "C-MATTER", "document_date": "2026-09-30", "credits": "$INVOICE_ID", "lines": [{"description": "Goodwill", "quantity": "1", "unit_price": "1000000"}]}'
{"error": {"code": "credit_more_than_invoiced"}}

A settled invoice has no QR-bill; there is nothing left to pay.

curl -X GET "$ERP_URL/v1/invoices/$INVOICE_ID/qr-bill" \
  -H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "qr_bill_settled"}}

A posted invoice cannot be voided; it is corrected with a credit note.

curl -X POST "$ERP_URL/v1/documents/$INVOICE_ID/void" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-void-posted" \
  -H "Content-Type: application/json" \
  -d '{"reason": "Entered by mistake"}'
{"error": {"code": "document_not_draft"}}

A receipt cannot allocate more than the open amount (here nothing is open any more).

curl -X POST "$ERP_URL/v1/payments" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-receipt-2" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "direction": "incoming", "party": "C-MATTER", "payment_date": "2026-09-29", "amount": "100.00", "allocations": [{"document": "$INVOICE_ID", "amount": "100.00"}]}'
{"error": {"code": "more_than_open"}}

Reference

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 6f03a5e846b3