Official clients for the Factlint API: validate, fix and convert electronic invoices (EN 16931 in UBL 2.1 and UN/CEFACT CII, PDF/A-3 containers such as Factur-X and ZUGFeRD, national rule sets such as the French Flux 2 and the German XRechnung) from Node, Python, PHP and Java.
One call returns a verdict and the exact list of what is wrong: for every finding, the rule code, the business term, the XPath, whether the API can fix it, must ask you, or refuses to touch it, and a page that explains it in English, French and German.
Every verdict is rendered against a sealed rule set: an identifier, a SHA-256, the version and hash of every artefact that ran, kept executable for ten years. The same file against the same rule set gives the same verdict, today and in 2036.
POST /v1/validate → 200 OK
{
"status": "needs_input",
"ruleset": { "id": "fr-2026-09", "sealed": true, "resolved_by": "latest" },
"errors": [ { "code": "BR-FR-32-LEGALID", "severity": "fatal", "resolution": "input",
"title": "A party identifier in scheme 0002 does not have nine digits",
"docs_url": "https://factlint.com/errors/br-fr-32" } ]
}
Validation is free and needs no account. Fixing, converting, enriching and sealing an evidence pack need an API key (pricing).
No key, no install: post one of the synthetic invoices from this repository.
curl -sS -F "file=@examples/invoices/fr-cii-siret-instead-of-siren.xml" \
-H "Accept-Language: en" \
"https://api.factlint.com/v1/validate?jurisdiction=FR" | jq '.status, .ruleset.id, .errors[].code'"needs_input"
"fr-2026-09"
"BR-FR-10_BT-30"
"BR-FR-32-LEGALID"
A fourteen-digit SIRET was put where a nine-digit SIREN belongs. That is the single most common
mistake in French invoices, and the two codes above are what a receiving platform will print.
More samples in examples/invoices/.
| Language | Package | Install |
|---|---|---|
| Node ≥ 18 | factlint |
npm install factlint |
| Python ≥ 3.9 | factlint |
pip install factlint |
| PHP ≥ 8.1 | factlint/sdk |
composer require factlint/sdk |
| Java ≥ 17 | com.factlint:factlint-sdk |
see the package README |
Not on the registries yet. The four packages are being published; until
npm,pip,composerand Maven Central serve them, install from this repository:git clone https://github.com/arcchester/factlint-sdk.git npm install ./factlint-sdk/packages/node # Node pip install "git+https://github.com/arcchester/factlint-sdk@main#subdirectory=packages/python" # Python cd factlint-sdk/packages/java && mvn -q install # JavaFor PHP, declare
factlint-sdk/packages/phpas apathrepository in yourcomposer.json.
Every client is a thin layer over HTTPS with no runtime dependency (Java uses Jackson).
The method names and their options are identical in the four languages, so the guides in
docs/ apply to all of them. If your language is missing, the
OpenAPI document is the contract; a client is a few hundred lines,
and we welcome one.
Node
import { Factlint } from 'factlint';
const client = new Factlint({ apiKey: process.env.FACTLINT_API_KEY, language: 'en' });
const report = await client.validate('invoice.xml', { jurisdiction: 'FR' });
console.log(report.status, report.ruleset.id, report.ruleset.sealed);
for (const finding of [...report.errors, ...report.warnings]) {
console.log(finding.code, finding.severity, finding.resolution, finding.title, finding.docs_url);
}Python
from factlint import Factlint
client = Factlint(api_key=os.environ["FACTLINT_API_KEY"], language="en")
report = client.validate("invoice.xml", jurisdiction="FR")
print(report["status"], report["ruleset"]["id"], report["ruleset"]["sealed"])
for finding in report["errors"] + report["warnings"]:
print(finding["code"], finding["severity"], finding["resolution"], finding["title"], finding["docs_url"])PHP
use Factlint\Client;
$client = new Client(apiKey: getenv('FACTLINT_API_KEY'), language: 'en');
$report = $client->validate('invoice.xml', jurisdiction: 'FR');
echo $report['status'], ' ', $report['ruleset']['id'], PHP_EOL;
foreach (array_merge($report['errors'], $report['warnings']) as $finding) {
echo $finding['code'], ' ', $finding['resolution'], ' ', $finding['title'], ' ', $finding['docs_url'], PHP_EOL;
}Java
Factlint client = Factlint.builder().apiKey(System.getenv("FACTLINT_API_KEY")).language("en").build();
JsonNode report = client.validate(Path.of("invoice.xml"), ValidateOptions.jurisdiction("FR"));
System.out.println(report.get("status") + " " + report.at("/ruleset/id"));
for (JsonNode finding : report.get("errors")) {
System.out.println(finding.get("code") + " " + finding.get("resolution") + " " + finding.get("title"));
}Then, with a key: client.fix(...) writes the structural corrections the issuer would have
made anyway and returns the corrected document with a before/after diff per change;
client.process(...) chains enrich → validate → fix → convert → seal in one call;
client.evidence.seal(...) returns a signed pack you can verify offline. See
docs/.
This is the real, complete response for the sample above (the pipeline trace is omitted for
length). Field names are snake_case; identifiers never change with the language, only
title and message do.
{
"status": "needs_input",
"document": { "sha256": "91a85500b75310f0ac3444910f9e17bc8b2bb07f5373111dec1ac4afabf61fa0", "bytes": 6399 },
"identify": { "format": "xml", "syntax": "cii", "profile": "urn:cen.eu:en16931:2017" },
"engine": { "name": "factlint-saxon", "version": "saxon-12.5", "pinned": true },
"ruleset": { "id": "fr-2026-09", "sealed": true, "resolved_by": "latest",
"reason": "ruleset en vigueur pour FR au moment de l'appel" },
"national_rules": { "applied": true, "jurisdiction": "FR" },
"errors": [
{
"code": "BR-FR-10_BT-30",
"bt": "BT-30",
"severity": "fatal",
"resolution": "input",
"family": "fr-ctc",
"title": "The seller's SIREN is missing or malformed",
"message": "BR-FR-10/BT-30 : Le SIREN du vendeur (ram:ID) est obligatoire et doit être composé exactement de 9 chiffres. Valeur actuelle : \"12345678900014\". Veuillez renseigner un identifiant SIREN valide.",
"location": "CrossIndustryInvoice",
"xpath": "/*:CrossIndustryInvoice[namespace-uri()='urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100'][1]",
"rule_source": "fr-ctc",
"docs_url": "https://factlint.com/errors/br-fr-10"
},
{
"code": "BR-FR-32-LEGALID",
"severity": "fatal",
"resolution": "input",
"family": "fr-ctc",
"title": "A party identifier in scheme 0002 does not have nine digits",
"message": "BR-FR-32/LEGALID : Tout identifiant légal d'une Partie avec schemeID = '0002' DOIT être composé de 9 chiffres.",
"location": "CrossIndustryInvoice › SupplyChainTradeTransaction › ApplicableHeaderTradeAgreement › SellerTradeParty › SpecifiedLegalOrganization › ID",
"xpath": "…",
"rule_source": "fr-ctc",
"docs_url": "https://factlint.com/errors/br-fr-32"
}
],
"warnings": [],
"notices": [],
"counts": { "fired": 144, "failed": 2, "passes": 142 }
}Three things to notice:
- The verdict is not an HTTP status. A non-conforming invoice is
200 OKwithstatus: "needs_input". HTTP 4xx and 5xx only ever describe the request (request errors). messageis quoted from the rule set, in its original language. It is the sentence a receiving platform running the same public Schematron will print.titleis ours, in the language you asked for. Paste either into the search box of this repository: every documented code carries its quoted texts.resolutiontells you who acts.auto: the API can rewrite it (POST /v1/fix).input: only the issuer knows the value; the API asks instead of inventing.blocked: an amount, a rate, a VAT base or a bank detail. Those are computed and proposed, never written, even if you ask.
status |
Meaning | Who acts |
|---|---|---|
passed |
Conforms as is: no error, container included, no warning inside the jurisdiction's scope | nobody |
fixed |
Conforms after structural corrections, each with its diff (/v1/fix, /v1/process) |
you, to review the diff |
needs_input |
A value only the issuer has is missing or wrong | the issuer |
blocked |
A financial field is inconsistent; the correct value is proposed, not written | the issuer's ERP |
rejected |
Not an invoice we can read: encrypted, unknown format, no XML in the PDF | you |
not_evaluated |
We could not judge (dependency down, issue date unreadable for as_of_issue_date). Never metered |
us |
ruleset in every response names what judged the document. Pin it in CI and the verdict
never drifts; keep it with the invoice and you can re-run the exact same rules years later.
ruleset |
What it does |
|---|---|
latest (default) |
The rule set in force today for the jurisdiction |
as_of_issue_date |
The rule set that was in force at the invoice's issue date (BT-2): the only honest way to re-judge an old invoice |
an id, e.g. fr-2026-09 |
That rule set, whatever the date |
| Rule set | Country | Status | Sealed | In force from | Until | Kept executable until | SHA-256 |
|---|---|---|---|---|---|---|---|
de-2026-08 |
DE | current | yes | 2026-08-01 | 2036-08-01 | ef1b6f54…62ad9 |
|
eu-en16931-2026-08 |
EU | current | yes | 2026-08-01 | 2036-08-01 | 3433666f…4c6e6 |
|
fr-2026-08 |
FR | current | yes | 2026-08-01 | 2026-08-31 | 2036-08-01 | 8b886a91…d30eb |
fr-2026-09 |
FR | current | yes | 2026-09-01 | 2036-09-01 | 85c6ebd8…affcd |
|
peppol-2026-08 |
EU | draft | no | 2026-08-01 | 2036-08-01 |
Full hashes, artefact versions and profiles: docs/rulesets.md, or
GET /v1/rulesets live. A sealed: false rule set is published for transparency: no verdict
rendered against it is replayable, and the response says so.
| Endpoint | Does | Plan |
|---|---|---|
POST /v1/validate |
Verdict and findings, container included | free, no key |
POST /v1/fix |
Structural corrections, corrected document, before/after | Pro |
POST /v1/fix-with-input |
Resume a fix with the values the issuer supplies | Pro |
POST /v1/process |
The whole pipeline in one JSON call | Pro |
POST /v1/convert |
PDF/A-3 container → CII, or CII + your PDF → PDF/A-3 container | Pro |
POST /v1/enrich |
Check parties against VIES and public registries, every value sourced | Pro |
POST /v1/evidence |
Seal a signed evidence pack of the verdict | Enterprise |
POST /v1/evidence/verify |
Verify a pack. No document is sent | free, no key |
GET /v1/rulesets, /v1/rulesets/{id} |
The rule sets, their hashes and artefacts | free, no key |
GET /v1/jurisdictions, /v1/jurisdictions/{code} |
Countries followed, coverage, deadlines | free, no key |
GET /.well-known/evidence-jwks.json |
Ed25519 public keys of the evidence packs | free, no key |
The contract is the OpenAPI document, served live on
GET https://api.factlint.com/v1/openapi and rendered at
factlint.com/en/docs/api.
Every documented code has a page in three languages on the website, and a mirror in this
repository under docs/errors/ that quotes the exact text the
validator prints, so that a search for that sentence lands here.
| Family | What it covers | Codes | Rendered by |
|---|---|---|---|
cen-en16931 |
EN 16931 core rules (CEN) | 28 | every rule set |
fr-ctc |
French national rules, Flux 2 (FNFE) | 29 | fr-2026-08, fr-2026-09 |
fnfe-profile |
Profile requirements of the French PDF/A-3 container and EXTENDED-CTC-FR (FNFE) | 12 | fr-2026-08, fr-2026-09 |
xrechnung |
German national rules, XRechnung 3.0 (KoSIT) | 55 | de-2026-08 |
verapdf |
PDF/A-3 container and XMP metadata (veraPDF) | 3 | every rule set, on PDF input |
xsd |
XML schema of the syntax | 2 | every rule set |
coherence |
Document coherence checks that no Schematron expresses | 2 | every rule set |
A code may come back with a suffix that names the exact assertion (BR-FR-32-LEGALID,
BR-FR-05_BT-22_PMT). The root before the suffix is the documented code, and docs_url
always points to it. A code without title or docs_url is a rule we have not documented
yet: the gap is shown rather than filled with an invented title. Please
tell us.
| Code | Country | Coverage | Rule set | Last step of the rollout |
|---|---|---|---|---|
FR |
France | national | fr-2026-09 |
2027-09-01 · b2b · issuing · small businesses and micro-enterprises |
DE |
Germany | national | de-2026-08 |
2028-01-01 · b2b · issuing · all businesses |
BE |
Belgium | core-en16931 | 2026-01-01 · b2b · issuing and receiving · VAT-registered businesses established in Belgium | |
IT |
Italy | core-en16931 | 2019-01-01 · b2b · issuing and receiving through the SDI · all businesses | |
PL |
Poland | core-en16931 | 2027-01-01 · b2b · issuing · micro-enterprises | |
ES |
Spain | core-en16931 | 2028-10-01 · b2b · issuing and receiving · all other businesses in scope | |
HR |
Croatia | core-en16931 | 2027-01-01 · b2b · receiving and e-reporting · businesses not registered for VAT | |
GR |
Greece | core-en16931 | 2026-10-01 · b2b · issuing · all other resident VAT-registered businesses | |
RO |
Romania | core-en16931 | 2025-01-01 · b2c · issuing · all businesses | |
EU |
European Union (ViDA) | national | eu-en16931-2026-08 |
2035-01-01 · b2b · alignment with the European standard · member states with a national system predating 2024 |
national: a sealed national rule set runs on top of EN 16931. core-en16931: the European
core only; the response says national_rules.applied: false and why. Details, deadlines and
sources: docs/jurisdictions.md.
POST /v1/evidence seals the document hash, the rule set and its artefact hashes, the engine
and the verdict into a pack signed with Ed25519. Anyone can verify it, years later, without
calling us: recompute the SHA-256 of the canonical form, then check the signature against the
key published on /.well-known/evidence-jwks.json. POST /v1/evidence/verify does it for
you, and never receives the invoice. Guide.
There is no queue and no job endpoint, by design: a queue would keep your invoices, and the
API keeps nothing. Concurrency is on your side, and it is enough: measured in production,
one client at twelve concurrent calls validates about thirty invoices per second. The Node and
Python clients ship validateMany(files, { concurrency }) for this. Pin ruleset to an id in
CI and a rule set update can never break your build without you deciding it.
The document is processed in memory and discarded with the response. Nothing is written to
disk, nothing is queued, and no invoice content is ever logged. The journal keeps the SHA-256
of the document and its size, which is what document in the response shows you. The full
statement: factlint.com/en/security.
- Free validator, no account: factlint.com/en/validator
- Documentation and API reference: factlint.com/en/docs
- Error pages: factlint.com/en/errors
- Deadlines by country: factlint.com/en/deadlines
- Pricing: factlint.com/en/pricing
- Contact: contact@factlint.com
En français : README.fr.md. Auf Deutsch: README.de.md.
Factlint validates and prepares your invoices upstream of your accredited platform. It does
not transmit them and does not replace your obligation to designate one. A passed verdict is
rendered against a named, public rule set; it is not a statement by any authority.
Factur-X is a trademark of FNFE-MPE, ZUGFeRD of FeRD, XRechnung a specification of KoSIT, Peppol a trademark of OpenPeppol. They are used here descriptively. See NOTICE.md for the licences of the quoted rule texts.
The clients are released under the MIT License. The quoted rule texts in
catalog/ keep the licence of their publisher (EUPL-1.2 for CEN, Apache-2.0 for KoSIT and
FNFE), with attribution in NOTICE.md.