Overview
Base URL: https://api.normapi.de. Requests and responses are UTF-8; errors are RFC 9457 problem JSON. No authentication to validate — the fair-use limits apply. You create your own key with its own quota (Authorization: Bearer nk_live_…) yourself: create an account — magic link or GitHub, no credit card, and validation stays free and never counts against the allowance.
| 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 'Accept-Language: en' \
-H 'X-Document-Name: invoice.xml' \
--data-binary @invoice.xml{
"acceptable": false,
"wellFormed": true,
"schemaValid": true,
"schematronValid": false,
"businessRulesEvaluated": true,
"rulesetVersion": "v2026-08-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": "/Q{urn:oasis:...:Invoice-2}Invoice[1]/Q{...}LegalMonetaryTotal[1]",
"line": null, "column": null,
"test": "(exists(cbc:PrepaidAmount) and not(exists(cbc:Payable...",
"explanation": {
"meaning": "The amount due for payment (BT-115) must add up: ...",
"cause": "...",
"fix": "Use decimal arithmetic throughout rather than float or double, ...",
"url": "https://normapi.com/en/errors/br-co-16"
}
}
]
}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), plus “EN16931 Factur-X/ZUGFeRD BASIC (CII)” and “… EXTENDED (CII)” for the two hybrid profiles. The official KoSIT configuration has no scenario for BASIC or EXTENDED; NormAPI adds them so those documents are checked against the EN 16931 core rather than passing through unevaluated. 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. The location is in Clark notation — each step carries its namespace as a Q{…} prefix rather than a short form like cac:, because a prefix means nothing without the document that declared it. The severity is the one the official XRechnung configuration sets for the scenario: BR-CL-23, for instance, is fatal in the rule itself but only a warning in an XRechnung — so it matches the verdict in acceptable. |
findings[].explanation | The rule in plain language: what it requires (meaning), why it usually fires (cause), how to fix it (fix), and a link to its entry in the error code reference (url). Language via Accept-Language: de for German, English otherwise; Content-Language names the one chosen. Every business rule is explained; null appears only on schema findings. The rule text in text stays untranslated. |
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-08-31 |
X-Normapi-Scenario | "EN16931 XRechnung (UBL Invoice)" — the rules the document passed |
X-Normapi-Quota-Used | Invoices generated this month — only on keys that carry an allowance |
X-Normapi-Quota-Limit | The plan's allowance. Once it is reached, /v1/invoices answers 402 |
Credit notes, corrections, partial and final invoices are chosen with typeCode (BT-3); the reference to the earlier invoice goes in precedingInvoices (BG-3). A 381 is written as a UBL CreditNote document, as the rule set requires. Amounts stay positive — the document type says which way the money flows.
"typeCode": "381",
"precedingInvoices": [
{ "number": "RE-2026-0815", "issueDate": "2026-08-13" }
]Allowances and charges exist at document level (allowances, charges, each with its own VAT category) and per line (without a VAT category — the line's applies). Give an amount or a percentage; the server computes the amount from the percentage, rounded half-up to the cent, and every total and the VAT breakdown with it.
"allowances": [
{ "amount": 9.80, "reason": "Treuerabatt", "reasonCode": "95",
"vatCategory": "S", "vatRate": 7 }
],
"charges": [
{ "amount": 12.50, "reason": "Versandkosten", "reasonCode": "FC",
"vatCategory": "S", "vatRate": 19 }
],
"lines": [
{ "name": "Softwarelizenz Jahresabo", "quantity": 3, "unit": "C62",
"unitPrice": 199.00, "vatCategory": "S", "vatRate": 19,
"allowances": [
{ "percent": 10, "reason": "Mengenrabatt", "reasonCode": "95" }
] }
]Payment means: credit transfer (58, 30) with an account, SEPA direct debit (59) with a mandate, card payment (48, 54, 55) with a card — and any other UNTDID 4461 code with nothing further. Each route carries only its own group: collecting by direct debit means leaving your own IBAN out (BR-DE-25-b).
"payment": {
"meansCode": "59",
"reference": "RE-2026-0815",
"directDebit": {
"mandateReference": "MANDAT-2026-001",
"creditorId": "DE98ZZZ09999999999",
"debitedAccount": "DE02120300000000202051"
}
}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) |
| typeCode | UNTDID 1001 | Document type (BT-3), default 380 invoice · 326 partial invoice · 381 credit note · 384 corrected invoice · 389 self-billed invoice · 875/876/877 partial, partial final, final construction invoice. Other codes draw the BR-DE-17 warning |
| dueDate | YYYY-MM-DD | Due date (BT-9). Without dueDate or paymentTerms the recipient has no payment deadline |
| 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 |
| precedingInvoices | Reference[] | Earlier invoices referred to (BG-3): number (BT-25, required), issueDate (BT-26). Expected for 384 (BR-DE-26). At most one in CII and ZUGFeRD — the schema has room for one |
| 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). A cash discount machine-readably as a line #SKONTO#TAGE=14#PROZENT=2.00# (BR-DE-18) |
| allowances | AllowanceCharge[] | Document-level allowances (BG-20), see below |
| charges | AllowanceCharge[] | Document-level charges (BG-21), see below |
| prepaidAmount | number | Already paid (BT-113), such as partial payments before a final invoice; the amount due (BT-115) drops accordingly |
| 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, 59 SEPA direct debit, 48/54/55 card; other codes (10, 20, ZZZ …) without a further group |
| payment.meansText | string | Payment means in plain text (BT-82), e.g. "SEPA credit transfer" |
| payment.iban | string | Payee account (BT-84); required by BR-DE-23-a for codes 58/30. On a credit note, the account the amount is paid out to |
| payment.accountName | string | Account holder (BT-85) |
| payment.bic | BIC | BIC of the payee's bank (BT-86), 8 or 11 characters |
| payment.reference | string | Remittance information (BT-83) |
| payment.card | Card | For 48/54/55 (BG-18): number (BT-87) — only the last 4 to 6 digits, no asterisks or spaces; more than 10 digits is refused with 400 — holder (BT-88), network (e.g. VISA, UBL only) |
| payment.directDebit | DirectDebit | For 59 (BG-19) all three: mandateReference (BT-89), creditorId (BT-90, your creditor identifier), debitedAccount (BT-91, the customer's IBAN). Leave your own IBAN out then |
| 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 not subject to VAT · L/M. O stands alone: no other category beside it, no VAT id on seller or buyer (the seller then goes by taxNumber and identifier), and an exemption reason |
| 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" |
| line.allowances | Adjustment[] | Allowances on the line (BG-27): amount or percent, baseAmount, reason, reasonCode — no VAT category. The line net (BT-131) is quantity × price less allowances plus charges |
| line.charges | Adjustment[] | Charges on the line (BG-28), the same shape |
AllowanceCharge — one document-level allowance or charge; at line level the same fields without the three VAT ones:
| Field | Type | Meaning |
|---|---|---|
| amount | number | Amount (BT-92 / BT-99), two decimals. Exactly one of amount and percent |
| percent | percent | Percentage (BT-94 / BT-101); the server computes the amount, rounded half-up to the cent |
| baseAmount | number | Base amount (BT-93 / BT-100), only together with percent — a base without a percentage is an error (PEPPOL-EN16931-R042). If omitted: the line nets of the same VAT category and rate, at line level quantity × price |
| reason | string | Reason in plain text (BT-97 / BT-104). reason or reasonCode must be present |
| reasonCode | code | Allowance: UNTDID 5189, e.g. 95 discount · charge: UNTDID 7161, e.g. FC freight |
| vatCategory * | UNTDID 5305 | VAT category (BT-95 / BT-102) — decides which VAT group the amount lowers or raises |
| vatRate | percent | VAT rate (BT-96 / BT-103) |
| vatExemptionReason | string | Exemption reason for the VAT group (BT-120) when this amount alone creates it |
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 field concerned: missing, malformed (a BIC, say) or impossible to turn into a document (amount and percent together, a percentage with no base, a card number with more than 10 digits) | Fix the request |
| 401 | The key sent is unknown or revoked. Without a key the anonymous limits apply — a wrong key is worse than none | Check the key |
| 402 | The month’s allowance of generated invoices is spent. Deliberately not 429: this does not mean "slow down" but "the plan is full" — validation stays free | Next plan, or wait for the month to turn |
| 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, or for ZUGFeRD all PDF rendering slots — 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-08-31",
"findings": [
{ "code": "BR-DE-16", "severity": "ERROR",
"text": "[BR-DE-16] ... Umsatzsteueridentifikationsnummer ...",
"explanation": { "meaning": "...", "cause": "...",
"fix": "Supply at least one of the three. ...",
"url": "https://normapi.com/en/errors/br-de-16" } }
]
}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 and PDF renderings: 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 — here in PHP, because this market runs a lot of WooCommerce and JTL.
<?php
$xml = file_get_contents('invoice.xml');
$ch = curl_init('https://api.normapi.de/v1/validate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $xml,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/xml',
'Accept-Language: en',
'Authorization: Bearer ' . getenv('NORMAPI_KEY'),
],
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);
if ($result['acceptable']) {
echo "valid — checked as {$result['scenario']}\n";
} else {
foreach ($result['findings'] as $f) {
echo "{$f['severity']} {$f['code']}: {$f['text']}\n";
if ($f['explanation'] !== null) {
echo " Fix: {$f['explanation']['fix']}\n";
}
}
}Specification & Postman
The full OpenAPI 3.1 specification describes both endpoints, every status code and the field structure — ready for code generators, mock servers and import into any API tool.
Rule set & versioning
Every response names the rule set that produced its verdict in rulesetVersion — currently v2026-08-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.