StabisoDocs

REST API

Issue and send an invoice

This walkthrough takes a posted invoice to the customer: the invoice as a PDF in the customer's language (with the Swiss QR-bill payment part for a Swiss company), the German hybrid invoice (ZUGFeRD / Factur-X: a PDF with the invoice data inside), and sending it by e-mail with a log of what was sent. It is for a client that issues invoices on behalf of a company. Posting the invoice itself is in Invoice a customer, send a QR-bill, receive the payment.

Before you start

  • Scopes: read for the PDF and the sent log, post to send. Only a person sends: an agent key answers 403 (agents draft and post; the customer hears from a human).
  • The demo organization has CH01 (Demo AG, Swiss, with a registered QR-IBAN account) and DE01 (Demo GmbH, German, with its address, contact and bank account), whose August invoice to the customer C-HH is used below.
  • Sending is off until the server has a mail server. On the test server used for these pages one is configured; on a deployment without one POST .../send answers 409 with sending is not configured, and sending_configured in the sent log (step 5) is false, so a client can offer the download instead. How to switch sending on is in the deployment guide.

1. Create a customer who reads French

The customer's language decides the language of the PDF and of the e-mail (de, fr, it or en; Romansh is written in German). Without one, the language of the customer's country is used.

curl -X POST "$ERP_URL/v1/entities/CH01/parties" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-customer-1" \
  -H "Content-Type: application/json" \
  -d '{"kind": "customer", "code": "C-DUPONT", "name": "Dupont & Fils SA", "country": "CH", "currency": "CHF", "language": "fr", "street": "Rue du Lac", "building_number": "12", "postal_code": "1003", "town": "Lausanne", "email": "compta@dupont.example"}'
{"kind": "customer", "code": "C-DUPONT", "status": "active", "language": "fr"}

2. Post an invoice

The customer's purchase order goes in order_reference (BT-13); it is printed on the invoice.

curl -X POST "$ERP_URL/v1/documents" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-invoice-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-DUPONT", "document_date": "2026-09-21", "order_reference": "PO-8812", "lines": [{"description": "Conseil septembre", "quantity": "10", "unit_price": "100"}, {"description": "Livres", "quantity": "5", "unit_price": "40", "rate_type": "reduced"}]}'
{"document": {"status": "posted", "total": "1286.20"}}

3. Download the PDF

GET /v1/documents/{id}/pdf prints a posted sales invoice or credit note on A4. It shows the company's name, address, VAT number and bank account, the customer's address and references (customer number, buyer reference, the purchase order), the lines with their VAT rate, the VAT per rate, the totals, the payment terms with a cash discount if the customer has one, and the notes. The labels, numbers and dates follow the customer's language, here French, and the amounts are written the Swiss way (1'286.20).

For a Swiss company an invoice that still has an open amount also gets the QR-bill payment part (Swiss Implementation Guidelines 2.3): at the foot of the last page when 105 mm are free there, otherwise on a page of its own. qr_bill=false leaves it out; a credit note and a settled invoice have none. The PDF carries the document date as its creation date and nothing from the clock, so asking again returns the same bytes (X-Content-SHA256 says which). The body below is a PDF; only its first line is shown.

curl -X GET "$ERP_URL/v1/documents/$INVOICE_ID/pdf?download=true" \
  -H "Authorization: Bearer $ERP_KEY"
%PDF-1.

language=de|fr|it|en overrides the customer's language, for example for an invoice to a subsidiary abroad.

4. Download the German invoice as ZUGFeRD

The August invoice of DE01 to C-HH is a German invoice. With format=zugferd the same layout is written as a hybrid PDF: the people read the pages, and a program reads the EN 16931 invoice embedded as factur-x.xml. Not yet verified for PDF/A-3 (see the end of this page), so a send uses the plain PDF unless you ask for zugferd. The profile is en16931 (default, the EN 16931 profile) or xrechnung (XRechnung 3.0, which needs a buyer reference or Leitweg-ID and is refused with 422 and the violated rules otherwise). A ZUGFeRD PDF has no QR-bill part.

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", "total": "5950.00"}]
curl -X GET "$ERP_URL/v1/documents/$HH_INVOICE_ID/pdf?format=zugferd" \
  -H "Authorization: Bearer $ERP_KEY"
%PDF-1.7
factur-x.xml

The embedded XML is the same invoice GET /v1/invoices/{invoice}/xrechnung issues, with the cash discount added as payment terms (BT-20) when there is one, so that the XML says what the pages say. POST /v1/einvoices/check reads a ZUGFeRD PDF back and checks it.

5. See how it can be sent

GET /v1/documents/{id}/dispatches is the sent log and what a send would do: whether this server sends e-mail at all, the address a send without to would use (the customer's e-invoice address when it is an e-mail address, a contact whose role says billing or accounting, then the customer's e-mail address), allowed_recipients (the customer's own addresses: a send may go to no others), the default format (pdf) and whether the XRechnung XML would be attached.

curl -X GET "$ERP_URL/v1/documents/$HH_INVOICE_ID/dispatches" \
  -H "Authorization: Bearer $ERP_KEY"
{
  "sending_configured": true,
  "suggested_recipient": "eingangsrechnungen@hamburg-handel.example",
  "default_format": "pdf",
  "allowed_recipients": ["eingangsrechnungen@hamburg-handel.example"],
  "zugferd_available": true,
  "xrechnung_attached": false,
  "items": []
}

6. Send it

POST /v1/documents/{id}/send e-mails the PDF (format is pdf by default, zugferd the hybrid) from the company's name, with the company's contact address as Reply-To. A customer with a Leitweg-ID, a German public body, also gets the XRechnung XML as a second attachment. Every field of the body is optional: to (one to five addresses), message (at most 1,000 characters, placed before the standard text) and format. The mail goes to to, or to the customer's e-invoice or billing address. Only a person sends (the policy action send_document is denied to agents), and an organization sends at most 60 messages an hour (429 beyond that).

curl -X POST "$ERP_URL/v1/documents/$HH_INVOICE_ID/send" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-send-1" \
  -H "Content-Type: application/json" \
  -d '{"message": "Bitte geben Sie die Bestellnummer bei der Zahlung an."}'
{
  "recipients": ["eingangsrechnungen@hamburg-handel.example"],
  "format": "pdf",
  "subject": "<any>",
  "message_id": "<any>",
  "attachments": [{"content_type": "application/pdf", "sha256": "<any>"}],
  "sent_by": {"type": "human"}
}

The answer records what the mail server accepted: when, to whom, the subject, the Message-ID (to find the mail in a mailbox) and each attachment with its size and SHA-256. The same record is in the document's sent log and, as document.send, in its history.

A retry with the same Idempotency-Key returns this answer and sends nothing. A message the mail server refuses answers 502 (mail_failed) and records nothing, so the request can be repeated.

curl -X POST "$ERP_URL/v1/documents/$HH_INVOICE_ID/send" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-send-1" \
  -H "Content-Type: application/json" \
  -d '{"message": "Bitte geben Sie die Bestellnummer bei der Zahlung an."}'
{"recipients": ["eingangsrechnungen@hamburg-handel.example"], "format": "pdf"}

to may only name the customer's own addresses: its e-invoice address, its e-mail address or the e-mail address of one of its contacts. Anyone else is refused, so that the API cannot be used to mail strangers:

curl -X POST "$ERP_URL/v1/documents/$HH_INVOICE_ID/send" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-send-stranger" \
  -H "Content-Type: application/json" \
  -d '{"to": ["einkauf@hamburg-handel.example"]}'
{"error": {"code": "not_customer_address"}}

Add the address to the customer first (an audited contact of the customer), then send. Here the invoice goes to the new contact as the ZUGFeRD hybrid, a new key making it a new send:

curl -X POST "$ERP_URL/v1/contacts" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-contact" \
  -H "Content-Type: application/json" \
  -d '{"entity": "DE01", "party": "C-HH", "kind": "customer", "last_name": "Einkauf", "role": "Einkauf", "email": "einkauf@hamburg-handel.example"}'
{"party_code": "C-HH", "email": "einkauf@hamburg-handel.example"}
curl -X POST "$ERP_URL/v1/documents/$HH_INVOICE_ID/send" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-send-2" \
  -H "Content-Type: application/json" \
  -d '{"to": ["einkauf@hamburg-handel.example"], "format": "zugferd"}'
{"recipients": ["einkauf@hamburg-handel.example"], "format": "zugferd"}

7. Read the sent log

The retry sent nothing, so the log has two entries: the first send and the one just made.

curl -X GET "$ERP_URL/v1/documents/$HH_INVOICE_ID/dispatches" \
  -H "Authorization: Bearer $ERP_KEY"
{
  "items": [
    {"recipients": ["eingangsrechnungen@hamburg-handel.example"], "format": "pdf"},
    {"recipients": ["einkauf@hamburg-handel.example"], "format": "zugferd"}
  ]
}

The log cannot be changed or deleted, not even by the database role the API runs as.

What can go wrong

ZUGFeRD is for German companies. A Swiss invoice cannot be output as one:

curl -X GET "$ERP_URL/v1/documents/$INVOICE_ID/pdf?format=zugferd" \
  -H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "zugferd_german_only"}}

An address must be a plain name@domain.tld:

curl -X POST "$ERP_URL/v1/documents/$INVOICE_ID/send" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-send-bad" \
  -H "Content-Type: application/json" \
  -d '{"to": ["not an address"]}'
{"error": {"code": "not_email_address"}}

Only a posted invoice or credit note is output. A draft has no number yet:

curl -X POST "$ERP_URL/v1/documents?submit=false" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-draft-1" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-DUPONT", "document_date": "2026-09-22", "lines": [{"description": "Conseil octobre", "unit_price": "100"}]}'
{"document": {"status": "draft", "number": null}}
curl -X GET "$ERP_URL/v1/documents/$DRAFT_ID/pdf" \
  -H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "invoice_not_posted_output"}}

What stops an invoice: the content the law requires

An invoice can be drafted, previewed and posted with incomplete records, but it is not output (PDF, e-mail, ZUGFeRD, XRechnung) until it states what the law requires of it: for a Swiss company the names and places of both parties and, when the company is VAT-registered, its UID (Art. 26 para. 2 MWSTG); for a German company the full addresses of both parties, its Steuernummer or USt-IdNr and, for a reverse charge or an intra-Community supply, the customer's USt-IdNr (§ 14 para. 4 and § 14a UStG); for any other country both names and addresses. The rules and their sources are data (src/erp/seed/invoice_content.yaml). Here the customer has no town:

curl -X POST "$ERP_URL/v1/entities/CH01/parties" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-customer-notown" \
  -H "Content-Type: application/json" \
  -d '{"kind": "customer", "code": "C-NOTOWN", "name": "Ohne Ort AG", "country": "CH", "currency": "CHF"}'
{"kind": "customer", "code": "C-NOTOWN", "status": "active"}

missing_content on the document (also on a draft and on POST /v1/documents/preview, where it only warns) lists what is missing, each field with its legal basis and the id of the record to complete: seller is the entity, buyer the customer.

curl -X POST "$ERP_URL/v1/documents" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Idempotency-Key: ci-output-invoice-notown" \
  -H "Content-Type: application/json" \
  -d '{"entity": "CH01", "kind": "sales_invoice", "party": "C-NOTOWN", "document_date": "2026-09-23", "lines": [{"description": "Beratung", "unit_price": "100"}]}'
{"document": {"status": "posted", "missing_content": [{"party": "buyer", "field": "town", "basis": "Art. 26 Abs. 2 Bst. b: Ort des Leistungsempfängers", "record_id": "<any>"}]}}

Printing, sending and the XRechnung answer 409 with the code invoice_content_incomplete and the same list as missing, so a client can tell the user exactly what to fill in:

curl -X GET "$ERP_URL/v1/documents/$NOTOWN_ID/pdf" \
  -H "Authorization: Bearer $ERP_KEY"
{"error": {"code": "invoice_content_incomplete", "missing": [{"party": "buyer", "field": "town"}]}}

Complete the customer (or, for the seller, the entity: PATCH /v1/entities/{entity}) and the same invoice prints:

curl -X PATCH "$ERP_URL/v1/entities/CH01/parties/C-NOTOWN?kind=customer" \
  -H "Authorization: Bearer $ERP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"town": "Bern", "postal_code": "3011"}'
{"code": "C-NOTOWN", "town": "Bern"}
curl -X GET "$ERP_URL/v1/documents/$NOTOWN_ID/pdf" \
  -H "Authorization: Bearer $ERP_KEY"
%PDF-1.

What is verified, and what is not

The pages, the VAT lines and the totals are checked by reading the text back out of the PDF. The hybrid is checked structurally: the XML is embedded as factur-x.xml with the relationship Alternative, the media type text/xml and a modification date; the XMP metadata declares PDF/A-3b and the Factur-X profile; there is an sRGB output intent; every font is embedded; the trailer has file identifiers; and the XML, read back with our own reader, is the same invoice and passes the EN 16931 rules (and the XRechnung rules for the xrechnung profile).

Not verified: full PDF/A-3 conformance. No PDF/A validator (veraPDF) runs in the pipeline, so a strict validator may still find something. That is why zugferd is not the default of a send; it becomes the default for German companies once veraPDF (or Mustang) validates the hybrids in CI (backlog B-55). The ZUGFeRD XML is checked with our own rules, not the KoSIT validator. The font is DejaVu Sans (bundled, with its licence), which covers Latin, Greek and Cyrillic: Polish, Czech, Romanian and similar names print as written. A character it lacks (for example Chinese) is printed as a question mark.

Reference

Stabiso, the Enterprise System of Record · generated from the code and its guides · release 20b7dd5b0c4d