Poland's national e-invoicing system, KSeF, moved to version 2.0 in 2026, and issuing invoices through it is now mandatory for most Polish businesses. The official documentation is thorough, but it is written in Polish and its examples are in C# and Java.
On the Python side, SDKs already exist: ksef2, ksef-python and ksef on PyPI. If you just want to send invoices, use one of them. They wrap the details below. This post explains why those details are the way they are, which is what you need when something goes wrong, or when you are deciding whether to trust a library with your invoices.
I built EU E-Invoice Bridge, an open-source Python project that turns one neutral invoice into both an EN16931 UBL invoice and Poland's FA(3) XML, then submits the FA(3) to the KSeF TEST environment and retrieves the official receipt (UPO). These are the eight things that cost me the most time. Each one comes with the code that handles it.
A clear scope note first. My project talks to KSeF's TEST environment only: the base URL is fixed to TEST, and it authenticates with a self-signed certificate, which only TEST accepts. It is a learning and reference implementation, not a tool for real invoices. Production requires a qualified electronic seal or signature, which is out of scope. The upside is that everything below runs with no Polish company, no tax ID and no paid certificate.
1. Authentication is asynchronous, and redeeming is one-shot
Submitting your signed authentication request does not log you in. It returns 202 with a reference number and a temporary token, and authentication carries on in the background. You poll its status, and only once it reports success do you exchange the temporary token for an access token:
challenge = http.post("/auth/challenge").json()["challenge"]
body = http.post("/auth/xades-signature", content=signed_xml,
headers={"Content-Type": "application/xml"}).json()
ref, temp = body["referenceNumber"], body["authenticationToken"]["token"]
while True:
status = http.get(f"/auth/{ref}", headers=bearer(temp)).json()["status"]
if status["code"] == 200:
break
if status["code"] >= 300:
raise RuntimeError(status["description"])
time.sleep(1)
access = http.post("/auth/token/redeem", headers=bearer(temp)).json()["accessToken"]["token"]
The last call is the trap. The docs say that a second redeem with the same temporary token returns 400, so a generic "retry on failure" wrapper around your HTTP client turns one network blip into a failed login. Retry the status poll freely; never retry the redeem. The same split between safe and unsafe calls matters much more in pitfall 4.
2. You cannot start with a KSeF token
KSeF offers two ways to authenticate: a KSeF token, or an XAdES signature. A token looks simpler, and my original plan used one. But POST /tokens, the endpoint that creates tokens, itself requires a Bearer token (OpenAPI, token docs). A token can only be created by someone who has already authenticated, in code or through the web portal. So a token is never the first step.
The way out is that the TEST environment, and only TEST, accepts self-signed certificates for XAdES. The docs say so; they just show it with a .NET tool. In Python you need two things.
First, a certificate shaped like a company seal, with the NIP (Polish tax ID) in organizationIdentifier as VATPL-<NIP>:
from datetime import UTC, datetime, timedelta
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
nip = "..." # a random test NIP, see pitfall 7
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = x509.Name([
x509.NameAttribute(NameOID.COMMON_NAME, "My test seal"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "My company (KSeF TEST)"),
x509.NameAttribute(NameOID.ORGANIZATION_IDENTIFIER, f"VATPL-{nip}"),
x509.NameAttribute(NameOID.COUNTRY_NAME, "PL"),
])
now = datetime.now(UTC)
cert = (x509.CertificateBuilder()
.subject_name(subject).issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=5))
.not_valid_after(now + timedelta(days=365))
.sign(key, hashes.SHA256()))
key_pem = key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
cert_pem = cert.public_bytes(serialization.Encoding.PEM)
Second, an enveloped XAdES-BES signature over the AuthTokenRequest. The signxml library does the heavy lifting; the parts to get right are the namespace, SubjectIdentifierType, and SHA-256 throughout:
from lxml import etree
from signxml.xades import XAdESSigner
NS = "http://ksef.mf.gov.pl/auth/token/2.0"
challenge = "..." # from POST /auth/challenge, see pitfall 1
root = etree.Element(f"{{{NS}}}AuthTokenRequest", nsmap={None: NS})
etree.SubElement(root, f"{{{NS}}}Challenge").text = challenge
context = etree.SubElement(root, f"{{{NS}}}ContextIdentifier")
etree.SubElement(context, f"{{{NS}}}Nip").text = nip
etree.SubElement(root, f"{{{NS}}}SubjectIdentifierType").text = "certificateSubject"
signer = XAdESSigner(signature_algorithm="rsa-sha256", digest_algorithm="sha256")
signed = signer.sign(root, key=key_pem, cert=cert_pem.decode())
signed_xml = etree.tostring(signed, xml_declaration=True, encoding="UTF-8")
With this, a fresh clone of the project can authenticate with no portal login and no manual setup. That is also what makes a daily unattended CI run possible.
3. Every invoice is encrypted, and OAEP means SHA-256 twice
KSeF 2.0 encrypts every invoice, even in interactive mode. The scheme is standard: a fresh AES-256-CBC key and IV per session, with the key wrapped in RSA-OAEP using KSeF's public key (session docs).
If you know CBC, one thing will jump out: every invoice in a session is encrypted with the same key and IV. That is how the KSeF protocol is designed. The IV travels once, when the session opens, and there is no per-invoice IV field. So this is not a choice your client gets to make. Two other details are easy to get wrong.
OAEP has two hash functions, and both must be SHA-256. OAEP takes a hash for the padding and another for its mask generation function (MGF1). Many libraries and examples default one or both to SHA-1. Get it wrong and KSeF cannot decrypt what you send; it has a status code of its own for that (435, decryption error).
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
OAEP_SHA256 = padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()), # this one too
algorithm=hashes.SHA256(),
label=None,
)
wrapped_key = ksef_public_key.encrypt(aes_key, OAEP_SHA256)
A cheap test catches it: wrap a key, then check that unwrapping it with SHA-1 OAEP fails. If SHA-1 can unwrap it, you are not using SHA-256.
KSeF publishes more than one public key. GET /security/public-key-certificates returned two certificates when I called it (key docs). Pick the one whose usage includes SymmetricKeyEncryption, not simply the first:
certs = http.get("/security/public-key-certificates").json()
cert = next(c for c in certs if "SymmetricKeyEncryption" in c.get("usage", []))
public_key = x509.load_der_x509_certificate(base64.b64decode(cert["certificate"])).public_key()
The send request then describes the invoice twice: SHA-256 hash and size of the plaintext, and SHA-256 hash and size of the ciphertext, with the ciphertext itself base64-encoded. The plaintext hash (invoiceHash) becomes important in the next pitfall.
4. Never retry the send, and write down that you sent it first
This is the one that matters for real money. You send an invoice and the connection drops before the response arrives. Did KSeF receive it? You cannot tell. Retry, and you may have issued the same invoice twice.
So the client has two kinds of calls. Status polls, key downloads and UPO downloads are safe to repeat, and get automatic retries with exponential backoff. Sending an invoice, opening a session and redeeming a token are not, and get no retry at all.
That raises the next question: after an unanswered send, how do you find out what happened? You have no invoice reference number yet, because that was in the response you never got. What you do have is the SHA-256 of the invoice you sent, and KSeF lists each invoice in a session with its invoiceHash. The recipe:
-
Before sending, save a local record: invoice number, invoice hash, session reference, status
sending. - Send. On success, update the record to
sentwith the reference number. - On a rerun, a record still marked
sendingmeans "unknown". List the session's invoices and look for your hash. - Found: carry on polling its status. Not found: stop and tell a human. Do not guess.
def find_in_session(http, session_ref, invoice_hash, access_token):
continuation = None
while True:
headers = {"Authorization": f"Bearer {access_token}"}
if continuation:
headers["x-continuation-token"] = continuation
body = http.get(f"/sessions/{session_ref}/invoices", headers=headers).json()
for invoice in body["invoices"]:
if invoice["invoiceHash"] == invoice_hash:
return invoice["referenceNumber"]
continuation = body.get("continuationToken")
if not continuation:
return None
Note the pagination. The list is paged through an x-continuation-token header, and stopping at the first page could miss the very invoice you are looking for.
KSeF has its own safety net too: status 440 means a duplicate invoice. It is good to know it is there, but you should not rely on it as your only protection.
To check that the tests really guard this, I once made the send retryable on purpose and ran the suite. Four tests failed, which is what you want to see.
5. FA(3) contains a timestamp, so keep the exact bytes you sent
The recovery in pitfall 4 depends on the hash matching. My first design re-rendered the invoice on a rerun and hashed the result. It would never have matched; writing the tests for the rerun path is what caught it.
The reason is DataWytworzeniaFa, the generation timestamp in the FA(3) header. Render the same invoice twice and you get two different documents with two different hashes. So:
- Save the exact XML bytes you send, next to the state record, and reuse them on a resumed run.
- To detect that someone changed the invoice under the same number, hash the input data (it is stable) instead of the rendered XML.
This is easy to miss because it only shows up on the rerun path, which is exactly the path you rarely exercise by hand.
6. A 4xx on the send is not a rejected invoice
There are two very different ways for KSeF to say no, and they need opposite handling.
| What happened | What it means | What to do |
|---|---|---|
| The send request gets an HTTP 4xx | KSeF refused the request (for example an expired token). It never looked at the invoice. | Safe to send again |
| The send times out, or gets a 5xx | Unknown: it may have arrived | Never resend; look it up (pitfall 4) |
| The invoice status becomes 300 or higher | KSeF checked the invoice and refused it | Fix it; it may go out again under the same number |
My first version recorded a 4xx on the send as "rejected" and then refused to ever send that invoice number again. That locked a perfectly good invoice out for good because of an expired token. An outside code review caught it, along with a test that had faithfully locked the wrong behaviour in.
The invoice status codes worth knowing, as listed in the OpenAPI description (I treat anything from 300 up as final):
| Code | Meaning |
|---|---|
| 100, 150 | Accepted for processing / processing |
| 200 | Success; the response now carries the ksefNumber
|
| 435 | Decryption error |
| 440 | Duplicate invoice |
| 450 | Semantic validation of the invoice failed |
On success, save the KSeF number before downloading the UPO. If the download fails, the invoice has still been accepted, and a rerun only needs to fetch the receipt.
7. TEST is shared, so bring your own random NIPs
The TEST environment is shared by every integrator, and the docs ask you to use random NIPs rather than real data. Combined with self-signed certificates, that means anyone can authenticate as any NIP, so a well-known test NIP is full of other people's invoices.
A random NIP still has to pass two checks:
- The checksum. Weights 6, 5, 7, 2, 3, 4, 5, 6, 7 over the first nine digits, sum modulo 11. The result is the tenth digit, and a remainder of 10 means the number is invalid.
-
FA(3)'s own pattern,
[1-9]((\d[1-9])|([1-9]\d))\d{7}. The first digit is not 0, and the second and third digits are not both 0.
import secrets
WEIGHTS = (6, 5, 7, 2, 3, 4, 5, 6, 7)
def random_test_nip() -> str:
while True:
digits = [secrets.randbelow(9) + 1] + [secrets.randbelow(10) for _ in range(8)]
if digits[1] == 0 and digits[2] == 0:
continue
check = sum(d * w for d, w in zip(digits, WEIGHTS)) % 11
if check != 10:
return "".join(map(str, digits + [check]))
Two more things that surprised me:
-
The seller must be the NIP you authenticated as. KSeF checks what the context you authenticated in may do, and a fresh test identity has rights only for its own NIP, so an example invoice with a made-up seller is the wrong thing to send. My CLI refuses up front and offers a
--test-sellerflag that substitutes the test identity's NIP. - TEST has a daily maintenance window from 16:00 to 18:00 Warsaw time. A failure in that window tells you nothing about your code. Schedule automated runs outside it.
8. Schema-valid is not the same as correct
The FA(3) XSD will happily accept an invoice that is wrong. Each line carries a rate code, such as 23, 0 WDT (intra-EU supply of goods), 0 EX (export) or oo (domestic reverse charge), and the schema accepts any code with any buyer.
So these are all valid XML:
-
0 WDTinvoiced to a Polish company. Zero-rating an intra-EU supply requires a buyer with a VAT number from another member state. -
ooinvoiced to a German company.oois the domestic reverse charge. Cross-border, it becomesnp Iornp II, depending on whether the supply is a particular kind of service.
Schema validation will not flag these, so my project checks them itself before any XML is produced. The severities differ on purpose:
| Code | Rule | Severity |
|---|---|---|
0 WDT |
Buyer needs an EU VAT number that does not start with PL | error |
oo |
Buyer needs a Polish NIP; choosing between np I and np II would need data the model does not hold, so it refuses rather than guess |
error |
0 EX |
A buyer in Poland is legal (export depends on the goods leaving the EU, not on the buyer) but unusual | warning |
If you are generating FA(3), the XSD is where your validation starts, not where it ends.
Wrapping up
Most of these pitfalls share one lesson: with a government system, the dangerous paths are the ones you rarely see. The unanswered send, the rerun, the invoice that is valid XML but wrong. Those deserve the most tests.
The project runs its integration test against the real KSeF TEST environment every morning on GitHub Actions. It needs no secrets, because each run creates a fresh self-signed identity and random NIPs. If KSeF changes something, the badge goes red within a day. (One caveat: GitHub pauses scheduled workflows on a repository with no activity for 60 days, so a daily check needs someone to keep an eye on it.)
The full code is on GitHub: edyiaeonian/eu-einvoice-bridge. To repeat the scope note: it is a reference implementation for KSeF TEST, not for real invoices. Besides KSeF submission, it covers the EN16931 side: one neutral invoice model, UBL output validated offline against the official Schematron, and a deliberate answer for every mismatch between the European standard and FA(3).
If you are integrating with KSeF from Python, I hope this saves you a few days. If it did, a star on the repo helps other people find it, and I would like to hear about the pitfalls you hit that are not on this list.
Sources
All from the Ministry of Finance's CIRFMF/ksef-api repository, in Polish:
- Authentication: the asynchronous flow, and the one-time redeem
- KSeF tokens
- Environments: self-signed certificates in TEST, random NIPs, the 16:00-18:00 maintenance window
- Test certificates and XAdES signatures: the .NET tool
- Interactive session: one key and IV per session
- Public keys for encryption
- OpenAPI document: endpoints and status codes
Top comments (0)