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:
readfor the PDF and the sent log,postto send. Only a person sends: an agent key answers403(agents draft and post; the customer hears from a human). - The demo organization has
CH01(Demo AG, Swiss, with a registered QR-IBAN account) andDE01(Demo GmbH, German, with its address, contact and bank account), whose August invoice to the customerC-HHis 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 .../sendanswers409withsending is not configured, andsending_configuredin the sent log (step 5) isfalse, 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.