Overview
Base URL: https://api.normapi.de. Requests and responses are UTF-8; errors are RFC 9457 problem JSON. No authentication during the beta — the fair-use limits apply. Design partners get API keys with their own quota (Authorization: Bearer nk_live_…): kontakt@normapi.de.
| Endpoint | Purpose |
|---|---|
POST /v1/validate | Validate one invoice: XRechnung XML (UBL or CII) or a ZUGFeRD/Factur-X PDF. |
POST /v1/invoices | Generate a validated XRechnung (UBL) from invoice data as JSON. |
POST /v1/validate
The document is the raw request body — no multipart, no base64. The file type is detected from content, never from the Content-Type header. For ZUGFeRD/Factur-X PDFs the embedded invoice is extracted and validated.
curl -X POST https://api.normapi.de/v1/validate \
-H 'Content-Type: application/xml' \
-H 'X-Document-Name: invoice.xml' \
--data-binary @invoice.xml{
"acceptable": false,
"wellFormed": true,
"schemaValid": true,
"schematronValid": false,
"businessRulesEvaluated": true,
"rulesetVersion": "v2026-01-31",
"scenario": "EN16931 XRechnung (UBL Invoice)",
"findings": [
{
"code": "BR-CO-16",
"severity": "ERROR",
"text": "[BR-CO-16]-Amount due for payment (BT-115) = ...",
"origin": "SCHEMATRON",
"location": "/Invoice[1]/cac:LegalMonetaryTotal[1]/...",
"line": null, "column": null,
"test": "(- 1 * ..."
}
]
}What the fields mean — three of them are routinely misread:
| Field | Meaning |
|---|---|
acceptable | The verdict. The one answer an accept/reject decision should rest on. |
schematronValid | Not a verdict. “The business rules reported nothing at all.” The rule set emits advisories on perfectly valid invoices — any advisory sets this false while acceptable stays true. |
businessRulesEvaluated | False when a schema failure stopped the run early. An empty findings list then means "not checked", never "nothing wrong". |
scenario | Which rule scenario judged the document — “EN16931 XRechnung (UBL Invoice)”, “EN16931 (CII)” for plain EN 16931 profiles (typical B2B ZUGFeRD). null: no scenario matched, nothing was checked, and acceptable false is a refusal to judge, not a judgement. |
findings[] | Every message with its rule code, severity (ERROR / WARNING / INFORMATION), rule text, XPath location and the failed test. Every BR-DE code has an explained error page. |
POST /v1/invoices
Invoice data as JSON in, a validated XRechnung 3.0.2 back — UBL by default, UN/CEFACT CII with ?syntax=cii, or a complete ZUGFeRD hybrid PDF (PDF/A-3 with the invoice embedded and a human-readable page) with ?syntax=zugferd. The server computes every total — line nets, the VAT breakdown, document totals — in decimal arithmetic, so the BR-CO sum rules hold by construction. The classic e-invoicing failure is caller-side float arithmetic; an API that accepted precomputed totals would reproduce exactly that bug.
No invoice leaves unvalidated: every generated document runs through the official rule set before the response. A 200 therefore carries a document that passed — if your JSON describes an invoice the rules do not permit, you get a 422 with the findings.
curl -X POST https://api.normapi.de/v1/invoices \
-H 'Content-Type: application/json' \
--data-binary @invoice.json -o invoice.xml
# UN/CEFACT CII instead of UBL:
curl -X POST 'https://api.normapi.de/v1/invoices?syntax=cii' ...
# ZUGFeRD hybrid PDF (application/pdf):
curl -X POST 'https://api.normapi.de/v1/invoices?syntax=zugferd' \
-H 'Content-Type: application/json' \
--data-binary @invoice.json -o invoice.pdf{
"invoiceNumber": "RE-2026-0815",
"issueDate": "2026-08-13",
"dueDate": "2026-09-12",
"deliveryDate": "2026-08-10",
"currency": "EUR",
"buyerReference": "04011000-12345-03",
"seller": {
"name": "Muster Software GmbH",
"vatId": "DE123456789",
"electronicAddress": "rechnung@muster-software.de",
"address": { "street": "Beispielstraße 12", "city": "Berlin",
"postcode": "10115", "country": "DE" },
"contact": { "name": "Maria Muster", "phone": "+49 30 1234567",
"email": "maria@muster-software.de" }
},
"buyer": {
"name": "Beispiel Handel AG",
"electronicAddress": "einkauf@beispiel-handel.de",
"address": { "street": "Handelsweg 3", "city": "Hamburg",
"postcode": "20095", "country": "DE" }
},
"payment": { "meansCode": "58", "iban": "DE02120300000000202051",
"reference": "RE-2026-0815" },
"paymentTerms": "Payable within 30 days net.",
"lines": [
{ "name": "Software licence, annual", "quantity": 3, "unit": "C62",
"unitPrice": 199.00, "vatCategory": "S", "vatRate": 19 }
]
}The response body is the XML itself (Content-Type application/xml) — redirect it to a file and you are done. Two headers carry provenance:
| Header | Content |
|---|---|
X-Normapi-Ruleset | The rule set that judged the document, e.g. v2026-01-31 |
X-Normapi-Scenario | "EN16931 XRechnung (UBL Invoice)" — the rules the document passed |
Field reference: generating
Required fields are marked. Everything else is decided by the rule set itself — a seller with neither VAT id nor tax number gets a 422 naming BR-DE-16, not a made-up error code of ours.
| Field | Type | Meaning |
|---|---|---|
| invoiceNumber * | string | Invoice number (BT-1) |
| issueDate * | YYYY-MM-DD | Issue date (BT-2) |
| dueDate | YYYY-MM-DD | Due date (BT-9). Without dueDate and paymentTerms, BR-CO-25 fails |
| deliveryDate | YYYY-MM-DD | Delivery date (BT-72); omitting it draws an advisory from the rule set |
| currency * | ISO 4217 | Currency (BT-5), e.g. EUR |
| buyerReference * | string | Buyer reference (BT-10) — the Leitweg-ID for public-sector buyers |
| note | string | Free-text note (BT-22) |
| seller * | Party | Seller (BG-4), see below |
| buyer * | Party | Buyer (BG-7), see below |
| payment * | Payment | Payment instructions (BG-16) |
| paymentTerms | string | Payment terms (BT-20) |
| lines * | Line[] | At least one line (BG-25) |
Party — the same shape for seller and buyer; which side needs what is the rule set's call (the seller needs a contact and tax identity, the buyer does not):
| Field | Type | Meaning |
|---|---|---|
| name * | string | Legal name (BT-27 / BT-44) |
| identifier | string | Any party identifier (BT-29). Matters for sellers without a VAT id: the tax number satisfies BR-DE-16 but only an identifier satisfies BR-CO-26 — repeating the tax number here is fine |
| vatId | string | VAT id (BT-31), e.g. DE123456789 |
| taxNumber | string | German tax number (BT-32) |
| electronicAddress | string | Electronic address (BT-34 / BT-49), usually an email address |
| electronicAddressScheme | EAS code | Scheme of the address; default EM (email) |
| address * | Address | street (optional), city, postcode, country (ISO 3166-1 alpha-2) — city and postcode are mandatory |
| contact | Contact | name, phone, email — mandatory on the seller (BR-DE-2 through 7), with plausibility checks (BR-DE-27/28) |
Payment and Line:
| Field | Type | Meaning |
|---|---|---|
| payment.meansCode * | UNTDID 4461 | 58 SEPA credit transfer, 30 credit transfer — other means coming |
| payment.iban | string | Payee account (BT-84); required by BR-DE-23-a for codes 58/30 |
| payment.reference | string | Remittance information (BT-83) |
| line.name * | string | Item name (BT-153) |
| line.description | string | Item description (BT-154) |
| line.quantity * | number | Quantity (BT-129), up to 6 decimals |
| line.unit * | UN/ECE Rec 20 | Unit (BT-130): C62 piece, HUR hour, DAY day … |
| line.unitPrice * | number | Net unit price (BT-146), up to 4 decimals |
| line.vatCategory * | UNTDID 5305 | S standard · Z zero-rated · E exempt · AE reverse charge · K intra-community · G export · O/L/M |
| line.vatRate | percent | VAT rate (BT-152); required for S |
| line.vatExemptionReason | string | Exemption reason (BT-120) — demanded by the rule set for E/AE/K/G, e.g. "Kleinunternehmer gemäß § 19 UStG" |
Error responses
Errors are RFC 9457 problem JSON with type, title, detail and status. The status codes carry meaning:
| Status | Meaning | Reaction |
|---|---|---|
| 400 | Request structurally unusable — detail names every missing field | Fix the request |
| 413 | Document over 5 MB | Do not retry |
| 415 | Body is neither XML nor PDF | Do not retry |
| 422 | Readable but not permitted: a PDF without an embedded invoice — or, when generating, data the rule set rejects (findings[] names each rule) | Fix the data |
| 429 | Your request budget is exhausted; Retry-After says how many seconds to wait | Wait, then retry |
| 503 | All validation slots busy — load on our side, not your fault | Retry after Retry-After |
{
"type": "https://normapi.de/problems/invoice-not-permitted",
"title": "Invoice not permitted by the rule set",
"status": 422,
"rulesetVersion": "v2026-01-31",
"findings": [
{ "code": "BR-DE-16", "severity": "ERROR",
"text": "[BR-DE-16] ... Umsatzsteueridentifikationsnummer ..." }
]
}Limits
- 30 requests per minute per client, doubling as the burst budget. Beyond it: 429 with Retry-After.
- 5 MB per document — real e-invoices are kilobytes.
- Bounded concurrent validations: under load the API answers fast with 503 instead of slowly with timeouts. Wait briefly and retry.
- Higher quotas for integrations: kontakt@normapi.de.
Client libraries
@normapi/client — TypeScript, zero dependencies, runs in Node 18+, browsers, Deno and Bun. Covers both endpoints: validate() and generateInvoice().
npm install @normapi/clientimport { validate, RateLimitError } from '@normapi/client'
import { readFile } from 'node:fs/promises'
const result = await validate(await readFile('invoice.xml'))
if (result.acceptable) {
console.log(`valid — checked as ${result.scenario}`)
} else {
for (const f of result.findings) {
console.log(`${f.severity} ${f.code}: ${f.text}`)
}
}For every other language the API is deliberately small: one POST with a raw body. The curl examples above translate into any HTTP library in a few lines.
Rule set & versioning
Every response names the rule set that produced its verdict in rulesetVersion — currently v2026-01-31. The XRechnung rule set updates roughly twice a year; an invoice that is valid today can draw advisories or errors from a newer release.
We email when the rule set changes: subscribe to notifications. What individual rules mean and how to fix them: the error-code reference. To try it without writing code: the validator.