Validate e-invoices
POST /v1/einvoices/validate says which rules an e-invoice passed, by which validator and which release of its rules, and which bytes it judged. It takes two kinds of request:
- A render of your workspace that made an e-invoice, as JSON with
render_id. Every e-invoice Formfeed delivers was validated before it left, and the render keeps that report. The answer is the report, in the shape below. It costs nothing and runs no validator. - An e-invoice XML that Formfeed did not make, as
multipart/form-datawith afilepart: one you received, or one another system wrote. From the Starter plan; each upload counts one of the organisation's validations of the day.
The key needs the einvoice:validate scope. Keys made in the app carry it by default, and formfeed login gives it to editors, admins and owners; a key without it answers 403 forbidden.
What the report says, and what it does not
The report says which rules a file passed, by which validator and release. It does not say whether the document is a proper invoice for VAT purposes, and it does not replace the check a business owes an invoice it receives.
The letter of the German Federal Ministry of Finance on e-invoicing of 15 October 2025 (BStBl I S. 1806) keeps the same things apart:
- an error of the format makes the document an "other invoice" (sonstige Rechnung, Rn. 6a);
- an error of the business rules is something else, for example a missing buyer reference (BT-10) in an XRechnung (Rn. 6b);
- a business may rely on a validator's report, but the report does not replace the recipient's own check (Rn. 35a).
So the report keeps apart what it checked: the file's syntax, its XML schema, and each rule set by name, each with its own result. It states no verdict on the document as an invoice. Where KoSIT's validator recommends accepting or refusing a document, that recommendation is not passed on. This page is not tax advice.
A render of your workspace
curl https://api-eu.formfeed.dev/v1/einvoices/validate \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Content-Type: application/json" \
-d '{"render_id": "rnd_0d290c45cbeb4554b946bbad1acee87e"}'
The answer carries source.stored: true and the checks of the report the render keeps: the XML schema of its profile, Mustang's Factur-X rules (which contain the rules of EN 16931), France's rules where they applied, and the PDF/A-3b container with the invoice it carries. pdf_sha256 and display come from that report too. A render that failed keeps its report as well; one that made no e-invoice answers 422 source_invalid.
rules asks for another rule set. en16931 holds the XML of an EN 16931 invoice to CEN's own rules, from the configuration KoSIT publishes for its validator, instead of Mustang's: the render's file is read and validated again, which counts like an upload. Rules that cannot apply to the file's specification identifier, such as xrechnung for a Factur-X file, answer not_checked without counting.
An XML you received
curl https://api-eu.formfeed.dev/v1/einvoices/validate \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-F file=@RE-2026-0001.xml \
-F rules=auto
- What it takes: one XML of up to 4 MB, CII or UBL, in UTF-8. A PDF is not taken (
415 file_type_unsupported): send the XML, or for a ZUGFeRD or Factur-X PDF of your own, its render id. A larger request answers413 file_too_largefrom itsContent-Length, before it is read. - What is kept: nothing. The file is validated and forgotten; the answer names its SHA-256 (
xml_sha256) and its size. - Nothing in a file is followed. A document type (
DOCTYPE), XInclude, an encoding other than UTF-8, a document nested deeper than 64 elements, more than 32 attributes on one element, an attribute longer than 4,096 characters or more than 300,000 elements answernot_checkedwithreason: not_read: no validator read the file. A file that is not well-formed XML answersfailed, with its syntax check failed.
How many a day
| Plan | Validations of uploads per organisation and UTC day |
|---|---|
| Free | none |
| Starter | 100 |
| Pro | 500 |
| Business | 2,000 |
| Enterprise | by contract |
A render's stored report never counts. A request the validator never answered (503 conformance_unavailable) gives its validation back; one that ran out of time (500 einvoice_validator_error) counts, because the same file runs out of time again. When the day is used up, the answer is 403 forbidden with feature: einvoice_validation, the plan's limit, what the day has used and resets_at, the start of the next UTC day; never 429. Requests also count against the plan's requests per second, as renders do. The answer's allowance says what the day has used after the request.
Which rules judge a file
A table in front of the validators chooses the rules, by the root element and an exact match of the specification identifier (BT-24, GuidelineSpecifiedDocumentContextParameter/ID in CII, cbc:CustomizationID in UBL). The file names the rules it claims to follow, and it is held to those.
| Root | Specification identifier (BT-24) | Rule set (rule_set) | Validator |
|---|---|---|---|
| CII | Factur-X and ZUGFeRD 2.x: MINIMUM, BASIC WL, BASIC, EN 16931 (urn:cen.eu:en16931:2017), EXTENDED | factur-x | Mustang, with the rules of Factur-X 1.09 / ZUGFeRD 2.5 for the profile |
| CII, UBL Invoice, UBL CreditNote | XRechnung 3.0 (urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0), its Extension, CVD | xrechnung | KoSIT's validator with KoSIT's configuration for XRechnung |
| UBL Invoice, UBL CreditNote | urn:cen.eu:en16931:2017 | en16931 | KoSIT's validator with CEN's rules from the same configuration, without XRechnung's |
| any | Peppol BIS Billing 3.0 | none yet | not checked |
| any other root, or any other identifier | none | not checked, naming what it found |
- The XRechnung Extension in a UBL credit note has no rules in KoSIT's configuration, so such a file is not checked.
- In CII,
urn:cen.eu:en16931:2017is the EN 16931 profile of Factur-X and ZUGFeRD and goes to Mustang;rules: en16931holds it to CEN's own rules instead. - Peppol BIS Billing 3.0 is not checked yet.
rulesother thanautojudges only a file whose identifier that rule set is for; any other file answersnot_checkedwithreason: rules_not_applicable.
The releases of the validators and rules are pinned, and every report names those that judged the file (validator, rules, scenario, versions). The coverage page lists the current pins.
The answer
{
"id": "6f2c7e9a-1d4b-4c3e-8a5f-2b7d9e0c1a34",
"result": "failed",
"reason": null,
"explanation": null,
"source": {
"type": "upload",
"filename": "RE-2026-0001.xml",
"bytes": 21304
},
"route": {
"syntax": "cii",
"document": "invoice",
"root": "CrossIndustryInvoice",
"customization_id": "urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0",
"rule_set": "xrechnung"
},
"validator": "KoSIT validator 1.6.3",
"rules": "XRechnung 3.0.2, configuration of 2026-08-31",
"scenario": "EN16931 XRechnung (CII)",
"profile": null,
"checks": [
{
"kind": "syntax",
"name": "XML well-formedness, UTF-8",
"result": "passed",
"messages": []
},
{
"kind": "schema",
"name": "XML Schema for UN/CEFACT XML (SCRDM - CII uncoupled)",
"result": "passed",
"messages": []
},
{
"kind": "rules",
"name": "Schematron rules for EN16931 (CII)",
"result": "passed",
"messages": []
},
{
"kind": "rules",
"name": "Schematron rules for CIUS XRechnung (CII)",
"result": "failed",
"messages": [
{
"part": "xml",
"severity": "error",
"rule": "BR-DE-15",
"message": "Das Element \"Buyer reference\" (BT-10) muss übermittelt werden.",
"location": "/rsm:CrossIndustryInvoice"
}
]
}
],
"truncated": false,
"versions": {
"kosit": "1.6.3",
"xrechnung_configuration": "2026-08-31",
"xrechnung_schematron": "2.6.0",
"cen": "1.3.16",
"saxon": "12.10"
},
"xml_sha256": "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b",
"pdf_sha256": null,
"display": null,
"validated_at": "2026-11-06T10:15:02.118Z",
"allowance": { "limit": 100, "used": 7, "resets_at": "2026-11-07T00:00:00Z" }
}
| Field | What it says |
|---|---|
result | passed: a validator ran its rules to the end and every check passed. failed: a check found an error. not_checked: no rule set here is for the file, or it was not read, or the validator did not finish |
reason, explanation | Why it was not checked, as a code and a sentence that names what was found: not_read, unknown_root, no_customization_id, unknown_customization_id, not_offered, no_scenario, rules_not_applicable, validator_stopped, not_validated, no_verdict |
route | What the table read off the file before any validator did: syntax, document, root, the identifier as the file names it, and the rule set it chose |
validator, rules, scenario, profile, versions | Which validator, which rules and which release judged the file |
checks | Every check with its own result (passed, failed, or not_run where it did not run to its end or never ran), and its findings: errors first, at most 100 a check (truncated says when there were more) |
xml_sha256 | SHA-256 of the XML that was judged, as it arrived |
allowance | What the day's validations have used after this request; null where nothing was counted |
The answer is 200 whatever the result: a file that fails is a report, not an error. New values of result, reason and a check's kind may be added; treat one you do not know as not passed.
In the SDKs, the CLI and agents
const report = await client.einvoices.validate({
file: { data: xml, name: 'RE-2026-0001.xml' },
});
const stored = await client.einvoices.validate(
'rnd_0d290c45cbeb4554b946bbad1acee87e',
);
report = client.einvoices.validate(file=xml, file_name="RE-2026-0001.xml")
stored = client.einvoices.validate("rnd_0d290c45cbeb4554b946bbad1acee87e")
- The CLI's
formfeed einvoice <slug> --validatewrites the XML of a data set offline and sends that file here. - The MCP server has the tool
validate_einvoice.