Receive a vendor bill as an e-invoice
A German supplier sends an XRechnung (the German e-invoice standard, in the UBL or CII XML
syntax) instead of a PDF. This walkthrough checks the file, turns it into a draft purchase bill in
the German entity DE01 without any manual typing, shows why the bill of a new supplier waits for a
second person and how that person releases it, submits the bill (with If-Match) and reads it
back, and then receives a bill from a vendor that is already known. It is for accounts-payable
integrations and for finance people who want to see what the ledger does with an e-invoice.
Before you start
- Scopes:
readto check a file,draftto receive it, to submit the bill and to request a vendor's activation,approveto decide an activation request. - Two people. A new vendor becomes payable only when a second person confirms it, so the
examples use two keys that belong to two people:
$ALICE_KEY(scopesread,draft,post,approve,admin) receives the invoice, and$BOB_KEY(read,draft,post,approve) is the second person. An API key counts as the person who created it. Alice and Bob are users who were invited (POST /v1/users), signed in once and created their key with their own session (POST /v1/api-keys); in this demo that is already done.$ERP_KEY, the organization's first key, was issued by the system when the organization was created. It belongs to no person, so it can set things up and create documents, but it can never approve, activate a vendor, release a payment file or finalise VAT, and neither can a key created with it. It appears below only in a refusal. - The demo organization: DE01 (Demo GmbH, EUR, VAT id
DE811569869) has the vendorsV-RENT,V-NLandV-IE, all active. September 2026 is open. - The sample files are in
files/:einvoice-papierhaus.xml(a CII XRechnung from a supplier DE01 does not know),einvoice-rent.xml(the rent for September from the existing vendorV-RENT),einvoice-invalid.xml(an invoice without the buyer reference German public XRechnung rules require) andnot-an-invoice.xml. They are synthetic.
0. Set the controls mode to four-eyes
New organizations default to the AI reviewer, which runs checks and auto-activates vendors that pass. This walkthrough shows the four-eyes path (a second person decides), so switch first:
curl -X PUT "$ERP_URL/v1/settings/controls" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"controls": "four_eyes"}'
{"controls": "four_eyes"}
1. Check an invoice (nothing is stored)
POST /v1/einvoices/check parses the file, runs the EN 16931 and XRechnung rules and answers
valid and the invoice as read. It needs only the read scope and stores nothing, so a client
can use it as a pre-flight check. The file goes in the multipart field file.
curl -X POST "$ERP_URL/v1/einvoices/check" \
-H "Authorization: Bearer $ALICE_KEY" \
-F "file=@files/einvoice-papierhaus.xml"
{
"valid": true,
"label": "e-invoice (XRechnung 3.0 CII)",
"profile": "XRechnung 3.0",
"syntax": "CII",
"container": "xml",
"violations": [],
"invoice": {
"number": "RE-2026-0815",
"currency": "EUR",
"totals": {"tax_exclusive": "167.50", "tax_total": "31.83", "tax_inclusive": "199.33"}
}
}
valid is true when no fatal rule is broken. The same call on an invoice that breaks a rule
answers 200 as well, with valid: false and the violations by rule id. This invoice lacks the
buyer reference (the Leitweg-ID or order number) that XRechnung requires, rule BR-DE-15:
curl -X POST "$ERP_URL/v1/einvoices/check" \
-H "Authorization: Bearer $ALICE_KEY" \
-F "file=@files/einvoice-invalid.xml"
{
"valid": false,
"violations": [{"rule": "BR-DE-15", "severity": "fatal"}]
}
Receiving an invalid invoice (the call of step 2) keeps the file as a document but books nothing;
the answer has status: invalid and the same violations, so you can send them back to the
supplier.
2. Receive it as a draft bill
POST /v1/documents/einvoice stores the original file and creates a draft purchase bill from it,
without a language model: the lines, VAT categories, due date, buyer reference and the supplier's
IBAN come from the XML. The form fields are entity (required), file (required), vendor
(a vendor code; default: found by the seller's VAT id, then by IBAN), create_vendor (default
true) and submit (default false). The call needs an Idempotency-Key header.
This supplier is unknown to DE01, so the vendor is created from the seller's details as a
draft vendor, created by whoever imports the invoice (Alice). With submit=true the bill is
submitted too, but a bill of a draft vendor is never posted: it stays a draft, and a warning says
so.
curl -X POST "$ERP_URL/v1/documents/einvoice" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: einvoice-papierhaus-1" \
-F "entity=DE01" \
-F "submit=true" \
-F "file=@files/einvoice-papierhaus.xml"
{
"status": "draft",
"valid": true,
"label": "e-invoice (XRechnung 3.0 CII)",
"syntax": "CII",
"violations": [],
"warnings": ["new vendor V-00001 created as draft from the e-invoice; review it", "..."],
"bill_id": "<uuid>",
"bill_kind": "purchase_bill",
"vendor": {
"code": "V-00001",
"name": "Papierhaus Schmidt GmbH",
"matched_by": "created",
"status": "draft"
},
"total": "199.33",
"currency": "EUR"
}
Notice vendor.matched_by: created and vendor.status: draft. The last warning reads
not submitted: vendor V-00001 is a draft until a second person activates it. The new vendor
code is built from the seller's name; the call captured it as $VENDOR.
3. Read the draft bill
The bill is an ordinary purchase bill: the supplier's invoice number is external_number, the
file is attached as source_file, and provenance records how it came in.
curl "$ERP_URL/v1/documents/$BILL_ID" \
-H "Authorization: Bearer $ALICE_KEY"
{
"id": "<uuid>",
"kind": "purchase_bill",
"status": "draft",
"party_code": "V-00001",
"external_number": "RE-2026-0815",
"document_date": "2026-09-15",
"due_date": "2026-10-15",
"currency": "EUR",
"net_total": "167.50",
"tax_total": "31.83",
"total": "199.33",
"buyer_reference": "04011000-1234512345-06",
"number": null,
"lines": [{"description": "Kopierpapier A4", "net_amount": "45.00"}, "..."]
}
A draft has no document number and no journal entry yet: nothing is in the ledger.
4. The new vendor must be activated by a second person
A vendor that comes in from an e-invoice is a plain draft: nobody has asked for its activation
yet. The vendor card shows its state and what is waiting.
curl "$ERP_URL/v1/entities/DE01/parties/$VENDOR?kind=vendor&as_of=2026-09-30" \
-H "Authorization: Bearer $ALICE_KEY"
{
"code": "V-00001",
"status": "draft",
"country": "DE",
"vat_number": "DE123456788",
"iban": "DE91370400440532099100",
"pending_actions": [],
"pending": []
}
This is a control, not a formality. A fraudulent invoice from an unknown supplier is the classic way to get money out of a company; the rule is that the person who brought a vendor in cannot also make it payable. The vendor's bank details came from the invoice, so the second person should compare them with an independent source before activating. Until then the vendor's bills cannot be submitted:
curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
-H "Authorization: Bearer $ALICE_KEY"
{"error": {"code": "party_not_active", "party_status": "draft"}}
POST /v1/entities/DE01/parties/{code}/activate activates the vendor at once when the caller is a
person with the approve scope who did not create it. For anyone else it files an activation
request for a second person. Alice created the vendor, so her call only files the request: the
card then shows pending_actions: ["activate_party"] and the request's approval_id, which the
call captures.
curl -X POST "$ERP_URL/v1/entities/DE01/parties/$VENDOR/activate?kind=vendor" \
-H "Authorization: Bearer $ALICE_KEY"
{
"code": "V-00001",
"status": "draft",
"pending_actions": ["activate_party"],
"pending": [{"action": "activate_party"}]
}
The request is decided with POST /v1/approvals/{id}/approve (or .../reject) by a person who
is neither the requester nor the creator of the vendor. The comparison is between people, not
keys: Alice's other keys and her browser session are Alice too. Two refusals show it. Alice
cannot approve her own request, and the organization's first key, which belongs to nobody, cannot
decide at all. Both answer 403.
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "approving my own request"}'
{"error": {"code": "four_eyes", "action": "approve"}}
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $ERP_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "the first key is nobody"}'
{"error": {"code": "forbidden"}}
Bob, a second person with the approve scope, checks the vendor's details and approves. The
activation happens in the same call.
curl -X POST "$ERP_URL/v1/approvals/$APPROVAL_ID/approve" \
-H "Authorization: Bearer $BOB_KEY" \
-H "Content-Type: application/json" \
-d '{"comment": "IBAN and VAT id checked against the contract"}'
{"object_type": "party", "action": "activate_party", "status": "approved"}
The card now shows who activated the vendor, and payment_blockers is empty: payments to it can
be released.
curl "$ERP_URL/v1/entities/DE01/parties/$VENDOR?kind=vendor&as_of=2026-09-30" \
-H "Authorization: Bearer $BOB_KEY"
{
"code": "V-00001",
"status": "active",
"activated_by": {"type": "human"},
"pending_actions": [],
"pending": [],
"payment_blockers": []
}
5. Submit the bill
Now the bill can be submitted. Read it to get its current ETag, then submit with If-Match. The
header makes the submit apply only to the version you looked at (a changed bill answers 412).
POST /v1/documents/{id}/submit sends the bill through the approval policy: by default a human
posts it at once, an agent key posts routine amounts (up to 5000), and anything the policy holds
back becomes pending_approval for a second person.
curl "$ERP_URL/v1/documents/$BILL_ID" \
-H "Authorization: Bearer $ALICE_KEY"
{"status": "draft", "external_number": "RE-2026-0815", "total": "199.33"}
curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
-H "Authorization: Bearer $ALICE_KEY" \
-H 'If-Match: $ETAG'
{
"document": {"status": "posted", "total": "199.33"},
"submit": {"status": "posted", "policy": {"decision": "allow"}}
}
submit.policy.decision is allow: the entry was posted. With require_approval the document
would be pending_approval and submit.approval_id would name the request.
6. Read the posted bill
Posting gave the bill its document number and a journal entry. open_amount is what is still
unpaid; the bill now appears in the payables, and the payment-run workflow
(From a bill to a payment file) can pay it.
curl "$ERP_URL/v1/documents/$BILL_ID" \
-H "Authorization: Bearer $ALICE_KEY"
{
"status": "posted",
"number": "<any>",
"journal_entry_id": "<uuid>",
"external_number": "RE-2026-0815",
"party_code": "V-00001",
"net_total": "167.50",
"tax_total": "31.83",
"total": "199.33",
"open_amount": "199.33",
"source_file": {"filename": "einvoice-papierhaus.xml", "content_type": "application/xml"}
}
GET /v1/documents/{id}/file returns the stored original byte for byte, with its SHA-256 in the
X-Content-SHA256 header, so the XML an auditor wants to see is always the one that came in.
7. A bill from a known vendor
When the vendor is already active, none of the above is needed. The rent invoice for September
comes from the supplier DE01 already has as V-RENT. The seller's details in the file do not
identify it (its VAT id is not on the vendor record), so the call names the vendor with the
vendor field. Without vendor, the seller's VAT id and then its IBAN pick the vendor; an unknown
seller creates a draft vendor as in step 2, and create_vendor=false answers needs_review
instead. With submit=true the bill is posted at once.
curl -X POST "$ERP_URL/v1/documents/einvoice" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: einvoice-rent-1" \
-F "entity=DE01" \
-F "vendor=V-RENT" \
-F "submit=true" \
-F "file=@files/einvoice-rent.xml"
{
"status": "posted",
"valid": true,
"bill_kind": "purchase_bill",
"vendor": {"code": "V-RENT", "matched_by": "given", "status": "active"},
"total": "2380.00",
"currency": "EUR"
}
The 19% VAT printed in the invoice (380.00 on 2000.00) is checked against the VAT the ledger
determines for a domestic German purchase on that date; a difference would be listed in
warnings.
The invoice names the IBAN DE02120300000000202051 for payment, and V-RENT has no IBAN on file.
The bill carries it as its payment_iban; before this bill can be paid, that account has to be
added to the vendor and confirmed by a second person (see
From a bill to a payment file).
What can go wrong
The same file again is refused, whatever the Idempotency-Key (the same key with the same file
replays the first answer instead). So is a second file with the same vendor invoice number from the
same vendor. The refusal names the bill that already exists.
curl -X POST "$ERP_URL/v1/documents/einvoice" \
-H "Authorization: Bearer $ALICE_KEY" \
-H "Idempotency-Key: einvoice-papierhaus-2" \
-F "entity=DE01" \
-F "file=@files/einvoice-papierhaus.xml"
{"error": {"code": "file_already_captured"}}
A file that is not an e-invoice at all (or is larger than 20 MB) is a 422:
curl -X POST "$ERP_URL/v1/einvoices/check" \
-H "Authorization: Bearer $ALICE_KEY" \
-F "file=@files/not-an-invoice.xml"
{"error": {"code": "einvoice_unreadable"}}
An If-Match for a version that is no longer current is refused with 412 before anything else is
checked. Read the document again and retry:
curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
-H "Authorization: Bearer $ALICE_KEY" \
-H 'If-Match: "document-999"'
{"error": {"code": "precondition_failed"}}
A bill that is already posted cannot be submitted again (409):
curl -X POST "$ERP_URL/v1/documents/$BILL_ID/submit" \
-H "Authorization: Bearer $ALICE_KEY"
{"error": {"code": "document_not_draft"}}
Not shown: bill capture
POST /v1/agents/bill-capture takes a PDF or an image of a bill, or an XML file, and creates the
same kind of draft bill. When the upload is an XML e-invoice or a ZUGFeRD / Factur-X PDF with the
XML embedded, it uses the XML and no model (source: einvoice). Only a plain PDF or image is read
by a language model, which is why that call is not executed here. Its draft vendors and bills
follow the same rules as above; a vendor created by an agent is activated by a human who is not
the person who ran the agent.
Reference
POST /v1/einvoices/checkPOST /v1/documents/einvoiceGET /v1/documents/{document_id}POST /v1/documents/{document_id}/submitGET /v1/documents/{document_id}/fileGET /v1/entities/{entity}/parties/{party}POST /v1/entities/{entity}/parties/{party}/activatePOST /v1/approvals/{approval_id}/approvePOST /v1/agents/bill-capture