DEV Community

Integrating with Poland's KSeF: five things that aren't in the docs

Poland switched to mandatory structured e-invoicing this year. Since 1 February 2026 for the largest companies, since 1 April 2026 for all active VAT payers, and from 1 January 2027 for everyone else, including the smallest and VAT-exempt. The system is called KSeF (Krajowy System e-Faktur). You send an invoice as XML in a schema called FA(3), it validates it, assigns it a number, and returns a signed receipt. Conceptually simple.

There is an OpenAPI spec and official SDKs in C# and Java. I've spent this year building an integration against it in TypeScript on Deno, and it now files invoices in production. Below are five things that cost me real time and are not in the documentation, or are in it in a way you only recognise afterwards.

1. The receipt is issued per invoice, but fetched through the session

This is the one I'd most want to tell my past self, and it has two halves that are easy to get backwards.

You can send invoices in a batch session: one ZIP of XML files, hundreds at a time. The session returns a reference number, and it's tempting to store that and call the invoices delivered. It isn't. The document that legally proves an invoice reached the system is the UPO (UrzΔ™dowe PoΕ›wiadczenie Odbioru), and it exists per invoice. A session reference proves a session happened. If you are ever asked to produce evidence for one specific invoice, that isn't it.

But - and this is the half I got wrong first - the UPO is retrieved through the session that submitted it:

GET /api/v2/sessions/{originalSessionRef}/invoices/ksef/{ksefNumber}/upo

So the session reference isn't disposable, it's a required key. An earlier version of my code opened a fresh session to fetch UPOs and could never resolve them, because KSeF binds UPO metadata to the original session. If you lose that reference, retrieving the receipt afterwards gets considerably harder.

Practically: store the session reference and iterate per invoice. A batch of 200 means one upload and then 200 fetches, all keyed on that one session ref. Budget for it in your queue design - it dominates wall-clock time, not the upload.

2. One bad invoice doesn't fail the batch

The inverse mistake. A document that fails semantic validation is rejected individually. The other 199 in the same session go through normally. If you treat a batch as atomic and roll the whole thing back on any error, you'll re-send invoices that were already accepted, and duplicate invoice numbers are their own category of pain. Per-invoice status tracking isn't optional.

3. Not every buyer has a tax ID, and it isn't only consumers

FA(3)'s buyer section (Podmiot2) offers a choice of identifiers. One of them is BrakID, literally "no ID":

<fa:Podmiot2>
  <fa:DaneIdentyfikacyjne>
    <fa:BrakID> 1 </fa:BrakID>
    <fa:Nazwa> Jan Kowalski </fa:Nazwa>
  </fa:DaneIdentyfikacyjne>
</fa:Podmiot2>

I read that as "consumer invoices" and wrote validation that required a tax ID for everything else. Both halves of that were wrong.

Wrong on scale: roughly three in four invoices in one real customer's book have no buyer tax ID. Any validation demanding one rejects most of a working invoice book, which is a spectacular way to discover your assumption.

Wrong on meaning: BrakID is not consumer-only. A taxpayer can lack a tax ID too - in Poland, unregistered business activity is the common case - and such an invoice is still fully in scope of the mandate. If you special-case "consumer", you'll misclassify them.

The subtler bug that came out of this: my validation demanded the ID while my serialiser, twelve lines further down the same file, correctly emitted BrakID. The two disagreed for months without anyone noticing, because the code path that would have surfaced it was never called in production. Worth grepping for that shape in your own code - a validator and a serialiser that encode the same rule separately will drift, and the drift is silent.

4. Session encryption keys are per-session, and generating them is async

Invoices are uploaded encrypted with a symmetric key, which is itself wrapped with the system's public key (RSA-OAEP, SHA-256). Two traps here.

The keys are per session. They are not credentials to cache. Generate them each time you open a session.

And in the TypeScript client I use, the call that produces them became async at one point, when it migrated to Web Crypto. My code destructured it as if it were synchronous:

// silently produces undefined for every field
const { cipherKey, cipherIv, encryptionInfo } = client.crypto.getEncryptionData();

// correct
const { cipherKey, cipherIv, encryptionInfo } = await client.crypto.getEncryptionData();

Nothing throws. You get undefined for all three, send an empty encryption field, and the server rejects it several calls later with a code that means "encryption must not be empty". The stack trace points at the session-open call, which is not where the bug is.

If you're on Deno specifically, one more note: use Web Crypto (crypto.subtle) rather than the node:crypto compatibility layer for the RSA-OAEP step. The compat path silently downgraded the mask generation function to SHA-1 for us, which produces ciphertext the server won't accept, and again nothing local fails.

5. When a government API tells you the problem is your data, believe it early

The expensive one. Authentication kept failing with HTTP 450 and a message saying the token could not be used in the context of our tax ID. 450 is not a standard status code, and the message reads exactly like the sort of thing you get when your request is malformed. So I checked everything on my side. Token format, scopes, permissions, the certificate, the encryption, the challenge encoding. All correct. I filed a support ticket. I waited.

The actual cause: that tax ID was in a bad state in the test environment's database. Nothing I could send would ever have worked. Generating a token for a different, properly registered tax ID authenticated cleanly on the first attempt. That cost about three weeks, and the fix would have taken an hour on day one. The ticket was never going to resolve it, because there was nothing to fix on my side and the message had been telling me so the whole time.

The generalisable lesson: when an integration fails against a government system and the error blames your data, spend an hour testing that hypothesis directly before you spend a week disproving yours. Try a different account. It's cheap and it's decisive.

Choosing an integration path at all

Stepping back, there are really three ways to reach KSeF, and the right one depends far more on where invoices are created than on engineering taste:

  • Direct API integration - most work up front, least friction afterwards. Right when the system issuing the invoices is yours or your vendor already supports it.
  • **File-based
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.