API documentation

Two endpoints: validate invoices against the official KoSIT rule set, and generate valid XRechnung from JSON. Everything described here is the same API the public validator runs on.

Last updated: 8 October 2026 · rule set v2026-08-31

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.

EndpointPurpose
POST /v1/validateValidate one invoice: XRechnung XML (UBL or CII) or a ZUGFeRD/Factur-X PDF.
POST /v1/invoicesGenerate 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.

Request
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
Response 200 (abridged)
{
  "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:

FieldMeaning
acceptableThe verdict. The one answer an accept/reject decision should rest on.
schematronValidNot 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.
businessRulesEvaluatedFalse when a schema failure stopped the run early. An empty findings list then means "not checked", never "nothing wrong".
scenarioWhich 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[].explanationThe 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.

Request
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
invoice.json
{
  "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:

HeaderContent
X-Normapi-RulesetThe 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-UsedInvoices generated this month — only on keys that carry an allowance
X-Normapi-Quota-LimitThe 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.

A credit note for an invoice
"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.

Loyalty discount, shipping, a volume discount on one line
"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).

SEPA direct debit
"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.

FieldTypeMeaning
invoiceNumber *stringInvoice number (BT-1)
issueDate *YYYY-MM-DDIssue date (BT-2)
typeCodeUNTDID 1001Document 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
dueDateYYYY-MM-DDDue date (BT-9). Without dueDate or paymentTerms the recipient has no payment deadline
deliveryDateYYYY-MM-DDDelivery date (BT-72); omitting it draws an advisory from the rule set
currency *ISO 4217Currency (BT-5), e.g. EUR
buyerReference *stringBuyer reference (BT-10) — the Leitweg-ID for public-sector buyers
precedingInvoicesReference[]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
notestringFree-text note (BT-22)
seller *PartySeller (BG-4), see below
buyer *PartyBuyer (BG-7), see below
payment *PaymentPayment instructions (BG-16)
paymentTermsstringPayment terms (BT-20). A cash discount machine-readably as a line #SKONTO#TAGE=14#PROZENT=2.00# (BR-DE-18)
allowancesAllowanceCharge[]Document-level allowances (BG-20), see below
chargesAllowanceCharge[]Document-level charges (BG-21), see below
prepaidAmountnumberAlready 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):

FieldTypeMeaning
name *stringLegal name (BT-27 / BT-44)
identifierstringAny 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
vatIdstringVAT id (BT-31), e.g. DE123456789
taxNumberstringGerman tax number (BT-32)
electronicAddressstringElectronic address (BT-34 / BT-49), usually an email address
electronicAddressSchemeEAS codeScheme of the address; default EM (email)
address *Addressstreet (optional), city, postcode, country (ISO 3166-1 alpha-2) — city and postcode are mandatory
contactContactname, phone, email — mandatory on the seller (BR-DE-2 through 7), with plausibility checks (BR-DE-27/28)

Payment and Line:

FieldTypeMeaning
payment.meansCode *UNTDID 446158 SEPA credit transfer, 30 credit transfer, 59 SEPA direct debit, 48/54/55 card; other codes (10, 20, ZZZ …) without a further group
payment.meansTextstringPayment means in plain text (BT-82), e.g. "SEPA credit transfer"
payment.ibanstringPayee 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.accountNamestringAccount holder (BT-85)
payment.bicBICBIC of the payee's bank (BT-86), 8 or 11 characters
payment.referencestringRemittance information (BT-83)
payment.cardCardFor 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.directDebitDirectDebitFor 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 *stringItem name (BT-153)
line.descriptionstringItem description (BT-154)
line.quantity *numberQuantity (BT-129), up to 6 decimals
line.unit *UN/ECE Rec 20Unit (BT-130): C62 piece, HUR hour, DAY day …
line.unitPrice *numberNet unit price (BT-146), up to 4 decimals
line.vatCategory *UNTDID 5305S 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.vatRatepercentVAT rate (BT-152); required for S
line.vatExemptionReasonstringExemption reason (BT-120) — demanded by the rule set for E/AE/K/G, e.g. "Kleinunternehmer gemäß § 19 UStG"
line.allowancesAdjustment[]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.chargesAdjustment[]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:

FieldTypeMeaning
amountnumberAmount (BT-92 / BT-99), two decimals. Exactly one of amount and percent
percentpercentPercentage (BT-94 / BT-101); the server computes the amount, rounded half-up to the cent
baseAmountnumberBase 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
reasonstringReason in plain text (BT-97 / BT-104). reason or reasonCode must be present
reasonCodecodeAllowance: UNTDID 5189, e.g. 95 discount · charge: UNTDID 7161, e.g. FC freight
vatCategory *UNTDID 5305VAT category (BT-95 / BT-102) — decides which VAT group the amount lowers or raises
vatRatepercentVAT rate (BT-96 / BT-103)
vatExemptionReasonstringExemption 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:

StatusMeaningReaction
400Request 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
401The key sent is unknown or revoked. Without a key the anonymous limits apply — a wrong key is worse than noneCheck the key
402The month’s allowance of generated invoices is spent. Deliberately not 429: this does not mean "slow down" but "the plan is full" — validation stays freeNext plan, or wait for the month to turn
413Document over 5 MBDo not retry
415Body is neither XML nor PDFDo not retry
422Readable but not permitted: a PDF without an embedded invoice — or, when generating, data the rule set rejects (findings[] names each rule)Fix the data
429Your request budget is exhausted; Retry-After says how many seconds to waitWait, then retry
503All validation slots busy, or for ZUGFeRD all PDF rendering slots — load on our side, not your faultRetry after Retry-After
Example 422 (generating)
{
  "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().

Install
npm install @normapi/client
TypeScript
import { 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
<?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.

openapi.yaml · Postman collection

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.