Skip to content

Repository files navigation

Factlint SDK

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.

License: MIT Error codes documented Languages

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).

Try it in thirty seconds

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/.

Install

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, composer and 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                            # Java

For PHP, declare factlint-sdk/packages/php as a path repository in your composer.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.

Use

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/.

What comes back

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 OK with status: "needs_input". HTTP 4xx and 5xx only ever describe the request (request errors).
  • message is quoted from the rule set, in its original language. It is the sentence a receiving platform running the same public Schematron will print. title is ours, in the language you asked for. Paste either into the search box of this repository: every documented code carries its quoted texts.
  • resolution tells 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.

Statuses

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

Verdicts you can replay

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.

Endpoints

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.

Error codes

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.

Countries

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.

Evidence packs

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.

Batches and CI

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.

Privacy

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.

Links

En français : README.fr.md. Auf Deutsch: README.de.md.

Legal

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.

Licence

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.

About

Official clients for the Factlint API — validate, fix and convert e-invoices (EN 16931, UBL, CII, PDF/A-3 containers, French and German rule sets) against sealed, replayable rule sets.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages