DEV Community

Peter Hallander
Peter Hallander

Posted on

Validate Poland's e-invoices offline against the official XSD, then make your validator fail on purpose

Since 2026, invoices between Polish businesses are XML documents. They go to the national e-invoicing system, KSeF, in a schema called FA(3). The last exemptions end on 1 January 2027. Once KSeF accepts an invoice, you cannot edit it or withdraw it. The only fix is a correcting invoice, filed after the fact.

So when I changed the XML builder to add three new blocks to every invoice (a third party, a payment section with bank accounts, and a footer), I wanted proof that the documents were still valid before any of them reached the real system.

There are two ways to get that proof.

The slow way: the government sandbox

KSeF has a demo environment. To use it you log in through the national ID service, generate a token, open a session and send your documents. In my setup the demo token has to be regenerated every day. When something is wrong, you get an error code and a sentence in Polish, and then you fix one thing and send again.

It is the final word on whether an invoice is accepted. It is also far too slow to run every time you touch the builder.

The fast way: the XSD

The Ministry of Finance publishes the FA(3) schema as an ordinary XSD file on crd.gov.pl. It is 180 KB, it declares 331 elements, and it imports a shared type library, which imports one more. Python's lxml compiles all three without any special setup:

import glob, os, sys, urllib.request
from lxml import etree

XSD_URL = "https://crd.gov.pl/wzor/2025/06/25/13775/schemat.xsd"
XSD = "tmp/fa3/schemat.xsd"
if not os.path.exists(XSD):
    urllib.request.urlretrieve(XSD_URL, XSD)

schema = etree.XMLSchema(etree.parse(XSD))
bad = 0
for f in sorted(glob.glob("tmp/fa3/*.xml")):
    ok = schema.validate(etree.parse(f))
    print("VALID  " if ok else "INVALID", os.path.basename(f))
    if not ok:
        bad += 1
        for e in list(schema.error_log)[:4]:
            print("     line", e.line, "-", e.message)
sys.exit(1 if bad else 0)
Enter fullscreen mode Exit fullscreen mode

Four documents, including fetching the two imported files, take about 1.3 seconds. No token, no login, no network round trip per invoice.

Validate what your code produces, not what you typed

The obvious move is to write a sample XML by hand and validate it. That proves you can write a valid invoice. It says nothing about your builder.

So the samples come from the real serializer. A small script calls the same function the application uses and writes the output to disk:

const samples = {
  "a-baseline":    { ...base, invoiceNumber: "TEST/A/1" },
  "b-wdt-factor":  { ...base, currency: "EUR", vatRate: "0 WDT", payment: factor },
  "c-own-account": { ...base, payment: { bankAccounts: [ownAccount] } },
  "d-footer-only": { ...base, payment: { footer: "Kapitał zakładowy: 50 000 PLN" } },
};
for (const [name, data] of Object.entries(samples))
  fs.writeFileSync(`tmp/fa3/${name}.xml`, buildFa3Xml(data));
Enter fullscreen mode Exit fullscreen mode

Each sample covers one new path: nothing configured, everything at once (a foreign buyer in euros with two factor accounts and a footer), own bank account only, footer only. The baseline matters as much as the others. A change that adds optional blocks must not change the document when they are empty.

Why the order is the whole problem

FA(3) is built from xsd:sequence, so the order of elements is part of the contract. The root looks like this:

Faktura
  Naglowek
  Podmiot1              seller
  Podmiot2              buyer
  Podmiot3        0-100 third parties (factor, recipient, ...)
  PodmiotUpowazniony 0-1
  Fa                    the invoice itself; Platnosc sits inside it, after the rows
  Stopka          0-1   footer
  Zalacznik       0-1
Enter fullscreen mode Exit fullscreen mode

My three new blocks land in three different places: one between the buyer and the invoice body, one deep inside the body after the line items, one after the body. An XML builder that emits them in a slightly different place produces a document that looks perfectly reasonable and is invalid.

The schema also knows things that no summary in the documentation mentions. A factor may have at most 20 bank accounts. An account number is any string of 10 to 34 characters. The third party's role is a number from a closed list, where 1 means "factor". When I read these rules in the XSD instead of in a PDF guide, I found them faster and trusted them more.

All four passed. That was the moment to worry.

Every sample was valid on the first run.

A validator that has never said no has not been tested. Maybe the schema failed to load its imports and validated nothing. Maybe the glob matched an empty folder. Maybe I validated yesterday's files. A green result from any of those looks exactly like a real one.

So I broke a document on purpose. I took the most complex sample and moved the Podmiot3 block from before Fa to after it. Same content, wrong place:

INVALID z-control.xml
     line 80 - Element '{http://crd.gov.pl/wzor/2025/06/25/13775/}Podmiot3':
     This element is not expected. Expected is one of (
     {http://crd.gov.pl/wzor/2025/06/25/13775/}Stopka,
     {http://crd.gov.pl/wzor/2025/06/25/13775/}Zalacznik ).
Enter fullscreen mode Exit fullscreen mode

Now the four green results mean something. The message is also more precise than a rejection code: it names the line, the element, and the elements that would be allowed there.

If you keep one habit from this post, keep that one. Every validator, linter or test gate you add needs one input that must fail, run at least once, on purpose.

What the schema cannot tell you

The XSD checks structure: order, types, lengths, allowed values. It does not check meaning.

The NIP, the Polish tax number, is a good example. Its type in the shared library is a regular expression:

[1-9]((\d[1-9])|([1-9]\d))\d{7}
Enter fullscreen mode Exit fullscreen mode

1234567890 matches it. Its checksum is wrong, so it cannot be anyone's tax number, and the schema will not notice. The same goes for arithmetic: the schema cannot see whether the line totals add up to the invoice total.

KSeF checks meaning on submission, and rejects with its own errors, for example code 450, "Błąd weryfikacji semantyki dokumentu faktury" (semantic verification failed). So the offline check is the fast first gate, not the last one. One real submission through the sandbox is still the final check. The difference is that it becomes one submission to confirm, not twenty to debug.

The setup, in short

  • Download the XSD once and keep it next to your tests. It pulls two more files from crd.gov.pl on compile; cache them too if your CI has no network.
  • Generate samples with your real builder, one per code path, plus a baseline.
  • Keep one sample that must fail, and look at it fail.
  • Treat a pass as "structurally valid", never as "accepted".

I build FakturaFlow, which sends invoices to KSeF in bulk. If you need the field-by-field map of FA(3) in Polish, which is the language the people filing these invoices work in, I keep one here: FA(3) schema guide.

Top comments (0)