KSeF API 2.0 error codes: 440, 450, 21405 and the rest — what they mean and how to fix them in enova365, Optima and Subiekt
Why This Post Exists
Seven months into the KSeF mandate, the questions I get from accounting teams and integrators have changed. Nobody asks any more whether their ERP can send. They paste a number. "We got 440." "Optima says rejected, what's 450?" "The integrator says 21405, is that us or them?"
The answers are in the Ministry of Finance's own API specification, the open-api.json and the documentation in the official CIRFMF/ksef-docs repository on GitHub. But they're scattered across schema descriptions, and the one thing that trips everyone up isn't written anywhere in plain words: KSeF reports errors on three separate levels, and the same number means different things on each.
This post puts them in one place. Everything in the tables comes from the spec as of November 2026. Where I describe what enova365, Optima or Subiekt show on screen, that's from working with them, and versions differ, so check yours.
Three Levels, One Number Space
When something goes wrong, KSeF tells you on one of three levels:
- An exception on the HTTP response. The request itself was refused. The body carries
exception.exceptionDetailList[]with a five-digitexceptionCode(21xxx, 25xxx). Nothing was queued. There is no invoice status, because there is no invoice in the system. - A session status. You opened an interactive or batch session, and the session as a whole failed, for example it timed out with nothing in it.
- An invoice status. The invoice was accepted for processing and then rejected. The status object has a three-digit
code, adescription,details[], and for some codesextensions.
The three-digit codes overlap. 440 on an invoice is a duplicate. 440 on a session is a cancelled session. 440 on a permissions operation is "not allowed for these identifier relationships". 450 on an invoice is a semantic error; 450 on an authentication is a bad token. If your log line says "KSeF error 440" without saying which object it came from, the log line is useless, and I see that in most homemade integrations.
Invoice Status Codes
These are the ones accountants actually see, because ERPs map them to "rejected".
| Code | What the spec says | What it usually means | Fix |
|---|---|---|---|
| 100 / 150 | Accepted / processing | Not an error. Still in the queue | Wait, poll again |
| 200 | Success | KSeF number assigned | Store number, fetch UPO |
| 405 | Cancelled due to session error | The session failed, not this invoice | Fix the session, resend |
| 410 | Wrong permission scope | The credential can't issue for this NIP | Grant the right permission in KSeF |
| 415 | Can't send an invoice with an attachment | Attachments not enabled, or sent in an interactive session | Enable in e-Urząd Skarbowy, send in batch |
| 430 | Invoice file verification error | Not valid XML, BOM, wrong encoding, schema mismatch | Fix the generator |
| 435 | File decryption error | AES/RSA encryption done wrong | Integration bug, not the accountant's |
| 440 | Duplicate invoice | Same NIP + RodzajFaktury + P_2 already in KSeF | Read originalKsefNumber first |
| 450 | Semantic verification error | Dates, NIP checksums, business rules | Fix the data, resend |
| 500 | Unknown error | Their side, or something unclassified | Retry later, then report with reference number |
| 550 | Cancelled by the system | Internal interruption | Retry |
A few of these need more than a table cell.
430 is almost always the generator. The verification doc is strict: UTF-8 without a BOM (the three bytes 0xEF 0xBB 0xBF at the start of a file are enough to fail it), no processing instructions, no prolog declaring another encoding, and a set of discouraged Unicode ranges that turn up when someone pastes a product name from a PDF. Size limits sit here too: 1 MB without attachments, 3 MB with.
450 is the one you fix in the data. Two rules cause most of the cases I've seen. The issue date P_1 can't be later than the moment KSeF accepts the document, so an invoice post-dated to tomorrow fails. And NIP checksums are validated for Podmiot1, Podmiot2, Podmiot3 and PodmiotUpowazniony, but only on production. That last detail means a file can pass every test on the test environment and fail on day one in production. If your test data uses made-up NIPs, you haven't tested this rule.
415 surprises people because the fix isn't technical. Sending invoices with attachments has to be registered in advance in e-Urząd Skarbowy, and it only works in batch mode. The single exception is a technical correction of an offline invoice.
The 440 Trap
This is the error that justifies the whole post.
KSeF detects duplicates globally, on the combination of seller NIP (Podmiot1:NIP), invoice type (RodzajFaktury) and invoice number (P_2), and it keeps that uniqueness for 10 full years from the end of the year the invoice was issued. So 440 means: an invoice with this number from this seller already exists in KSeF.
The reflex fix is to give the invoice a new number and resend. That's usually wrong.
The most common route to a 440 goes like this: the first send succeeded, KSeF assigned a number, and your ERP never recorded the answer. A timeout, a service restart, a status poll that never ran. The document sits in the books without a KSeF number, someone clicks "send" again, and KSeF correctly says it already has it. Renumber and resend at that point, and you've issued a second invoice for the same sale. Now there are two legal invoices in the national system, the buyer can see both, and one of them needs a correction to get rid of.
The spec gives you the way out. A 440 invoice status carries extensions with originalKsefNumber and originalSessionReferenceNumber. Read them. If the original is this same invoice, attach that KSeF number to the document and download its UPO. Done, no new invoice.
The other cause is a genuine collision: two units issuing for the same NIP with colliding series. Branches, organisational units of a local authority, an online shop and an ERP numbering in parallel. The spec says outright that such units have to agree on numbering. There, the invoice really does need a new number, and the series need fixing so it doesn't happen next week.
Here's the decision as code, the way I write it in a reconciliation job:
def handle_invoice_status(doc, status):
code = status["code"]
if code == 200:
return mark_sent(doc, ksef_number=status_ksef_number(status))
if code in (100, 150):
return poll_later(doc)
if code == 440:
ext = status.get("extensions") or {}
original = ext.get("originalKsefNumber")
if original and same_invoice(doc, original): # compare hash, amounts, buyer
return mark_sent(doc, ksef_number=original) # recover, don't renumber
return escalate(doc, "number collision: fix the series, then renumber")
if code in (500, 550):
return retry_later(doc)
return escalate(doc, f"{code}: {status['description']} {status.get('details')}")
same_invoice is where the care goes: fetch the original by its KSeF number and compare the buyer, the totals and ideally the hash of the XML you hold. Matching only on the number is the bug you're trying to avoid.
Session Codes and the 21xxx Exceptions
Session statuses matter mostly to whoever runs the integration. In a batch session, 405 is a package verification error, 415 and 435 are decryption failures, 420 means the session limit of 10,000 invoices was exceeded, 430 is a decompression error, 440 is a cancelled session (upload timed out, or no invoices were sent), and 445 means verification found no valid invoices at all. An interactive session has the same 415, 440 and 445.
The five-digit exceptions arrive on the HTTP response and mean the request never got in:
| Exception | Meaning |
|---|---|
| 21401 | Document doesn't match the XSD schema |
| 21402 / 21403 | File size or hash doesn't match what you declared |
| 21405 | Input data validation error (details carry the validator's message) |
| 21155 | Invoice limit for the session reached |
| 21180 | Session status doesn't allow the operation (e.g. sending into a closed session) |
| 21208 | Upload/finish wait time exceeded, session cancelled |
| 21164 / 21165 | Invoice with that KSeF number doesn't exist / isn't available yet |
| 21184 | Session temporarily unavailable, maintenance |
21165 deserves a note for anyone building purchase-invoice import: an invoice can be processed and still not be downloadable for a while. Treat it as "retry later", not as "doesn't exist".
429 Is Not a Bug
KSeF rate-limits every protected endpoint. Requests are counted per pair of context and IP address: the NIP you authenticated for, plus the address you connect from. Windows slide per second, minute and hour, and all three apply at once. Exceed any of them and you get HTTP 429 with a Retry-After header in seconds. The block length is dynamic and grows with repeated violations.
Two consequences for an accounting office. An office pulling invoices for a client from its own IP has a separate budget from the client pulling from theirs, so they don't starve each other. Rotating many IPs on one context to get around the limit is another matter: the documentation says outright that it may be treated as a security threat. The spec also mentions higher download limits between 20:00 and 06:00, which is where nightly import jobs belong anyway.
What enova365, Optima and Subiekt Show You
None of the three shows you the full picture on the invoice list. In practice, that's the real problem.
Optima's documented KSeF filter has statuses like "Nie wysłano", "Oczekuje na odbiór UPO", "Odebrano UPO", "Awaria" and "Odrzucono przez KSeF". enova365 and Subiekt likewise collapse the outcome into a document-level state. That works for 200. For everything else, "rejected" hides whether you're looking at a 430 (call the vendor or integrator), a 450 (fix the data), a 410 (fix permissions) or a 440 (probably already sent, don't touch the number). The code and the details are in the KSeF communication log or the document's KSeF history in all three, not in the list column.
So my standing advice to offices: when a client says "KSeF rejected it", ask for the three-digit code and the details line before anything else. It takes the client one minute to find and saves an hour of guessing. And for the integrations I build, whether a WebAPI job in enova365, a Sfera service on Subiekt GT or anything on Optima, every rejection is logged with the level, the code, the details and the extensions, in one place someone actually reads.
That last part is the same lesson as the three quiet KSeF states from September: a rejection that nobody reads is just an invisible invoice with extra steps. A nightly read-only job that lists every document with a non-200 status, its code and its age in days is the cheapest KSeF control there is. For 440s, it can propose the recovered KSeF number for a person to confirm.
If you're seeing KSeF rejections you can't classify, or 440s that someone has been "fixing" with new numbers, get in touch. The reconciliation job that sorts them takes a day or two to set up on enova365, Optima or Subiekt, and it's read-only from the first hour.
Check a Polish VAT counterparty in seconds, free
KsięgoAI checks a NIP against the VAT white list and VIES, scores the risk and saves proof of due diligence. Works in Telegram and Claude.
Try KsięgoAI→Let’s talk about your project
Free 30-minute consultation. We’ll figure out if and how I can help.



