Skip to content
KSeF Kit

KSeF API and SDK: what integrating KSeF 2.0 actually takes

The KSeF 2.0 API is the Ministry of Finance's REST API at api.ksef.mf.gov.pl/api/v2 (TEST: api-test.ksef.mf.gov.pl/api/v2). To file a single invoice you authenticate with a token or an XAdES signature, open a session carrying an AES key wrapped with the Ministry's public key, upload the encrypted FA(3) XML, close the session, and poll until a KSeF number and a UPO come back.

This is written for someone costing out a self-built integration. It won't promise that it's easy, because it isn't, and it won't claim it's out of reach, because it isn't that either. It's what you actually have to build, with Ruby examples from our own gem, which is open source and yours to take.

What the KSeF 2.0 API is

KSeF 2.0 is the second generation of Poland's National e-Invoicing System. The first one (KSeF 1.0) has been retired: its test hosts now answer with a 503, and ksef.mf.gov.pl is the old system's address. Any tutorial still using those hosts, or the FA(2) schema, is out of date.

The current API is JSON over HTTPS with Bearer authentication, with one exception: the XAdES signature you send and the UPO you receive are XML. Everything revolves around the notion of a session. You don't file an invoice in a single request. You open a session, put documents into it, close it — and closing is what triggers UPO generation.

There are two session types. Online handles one invoice, or several sent one at a time, each in its own request. Batch takes a ZIP of many invoices, split into parts and uploaded to URLs KSeF hands you. A session lives for 12 hours.

Three environments, and what each is for

Environment API Taxpayer app Identity
TEST api-test.ksef.mf.gov.pl/api/v2 ap-test.ksef.mf.gov.pl fictional, any made-up NIP
DEMO api-demo.ksef.mf.gov.pl/api/v2 ap-demo.ksef.mf.gov.pl real NIP, no legal effect
PROD api.ksef.mf.gov.pl/api/v2 ap.ksef.mf.gov.pl real — the invoice is issued

TEST is a sandbox for throwaway identities. You open the taxpayer app, make up a NIP, and generate a token. Nothing is verified, so it's the fastest route to your first UPO. DEMO looks and behaves like production, authenticates with a real NIP and a real certificate, but its invoices carry no legal force — that's where you rehearse before switching over. PROD is the only place where an invoice actually comes into existence.

A token generated on TEST won't work on PROD, or the other way round. That's the most common cause of a 450 on first run, right after a token with a trailing newline picked up while copying it.

What's easy to miss is that QR code verification isn't part of the API at all. It's a separate service at qr-test.ksef.mf.gov.pl, qr-demo… and qr.ksef.mf.gov.pl.

Authentication: where most integrations stall

Authenticating against KSeF isn't trading a login for a token. It's a short asynchronous operation whose result you poll for.

It starts identically whichever method you use: POST /auth/challenge returns a challenge and a millisecond timestamp. From there the two paths diverge.

KSeF token (machine-to-machine). You concatenate "{token}|{timestampMs}", encrypt that string with RSA-OAEP/SHA-256 under the Ministry public key whose usage is KsefTokenEncryption, and post it to /auth/ksef-token along with the key's identifier. You fetch the keys from /security/public-key-certificates and pick the newest one for that usage, because they get rotated. Note that this is a different certificate from the one you wrap your session key with.

XAdES signature. You build an AuthTokenRequest document in the auth/token/2.1 namespace carrying the challenge, the context NIP, and which certificate field KSeF should match on (the subject, or the certificate fingerprint), sign it with an enveloped XAdES-BES signature, and post it as application/xml to /auth/xades-signature — with server-side certificate chain verification on in production, and off on TEST and DEMO, where self-signed certificates are the norm.

From there both paths converge. You poll GET /auth/{referenceNumber} until the status reads 200 (100 means "in progress"), then POST /auth/token/redeem exchanges the one-time operation token for a pair of JWTs: access and refresh. The access token expires quickly; the refresh token extends it without repeating the whole dance. If the refresh token is revoked, your only way out is a full re-authentication.

In our gem that whole lifecycle lives in one module, and the method that distinguishes the two paths is literally a single function. Calling it looks like this:

require "ksef"

env = Ksef::Environment.new(:test)

authenticator = Ksef::Authenticator::Token.new(
  environment: env,
  ksef_token: ENV["KSEF_TOKEN"],
  context_nip: "1111111111"
)

client = Ksef::Client.new(env: env, authenticator: authenticator)

With a certificate instead of a token, only the authenticator changes:

authenticator = Ksef::Authenticator::Xades.new(
  environment: env,
  certificate: OpenSSL::X509::Certificate.new(File.read("cert.pem")),
  private_key: OpenSSL::PKey.read(File.read("key.pem")),
  context_nip: "1111111111",
  subject_identifier_type: "certificateSubject"
)

A session grants exactly the permissions of the context (the NIP) you authenticated in. A token that was never granted the invoice-sending permission in the taxpayer app will authenticate perfectly well and only then hand you a 415. Worth distinguishing in your error handling: for the user that's a completely different instruction from "your token expired".

Filing an invoice: session, encryption, UPO

The full path of one invoice:

  1. Build the FA(3) XML and validate it locally against the XSD.
  2. Generate an AES-256 key and IV; wrap the key with RSA-OAEP under the SymmetricKeyEncryption certificate.
  3. POST /sessions/online with the FA(3) formCode and the encryption material. You get back a session reference number.
  4. POST /sessions/online/{ref}/invoices with the encrypted invoice and four values: the hash and size of the plaintext, and the hash and size of the ciphertext.
  5. POST /sessions/online/{ref}/close. Closing is what triggers UPO generation.
  6. GET /sessions/{ref} in a loop: 100 (accepted, queued), 170 (processing), 200 (done). For a batch session the in-progress codes are 100 and 150.
  7. GET /sessions/{ref}/invoices/{invoiceRef}/upo returns the signed UPO XML with the KSeF number.

In the gem those seven steps are two lines — deliberately kept apart:

receipt = client.open_and_send(invoice)
# persist receipt.session_reference, receipt.invoice_reference,
# receipt.invoice_hash and receipt.generated_at BEFORE you start polling

result = client.await(
  session_reference: receipt.session_reference,
  invoice_reference: receipt.invoice_reference
)

result.ksef_number     # => "1111111111-20260820-..."
result.upo.received_at # => when the KSeF number was assigned
result.upo.xml         # => the signed XML for your archive

Splitting the send from the polling isn't a stylistic preference; it's the only way the process survives a restart. If you persist the reference numbers, interrupted polling can resume without re-sending. If you don't, a crash leaves you unable to tell an unsent invoice from a sent one — and retrying in the second case means filing a legal document twice.

The invoice object itself is a plain data structure with no dependency on any source system:

invoice = Ksef::Fa3::Invoice.new(
  number: "FV/2026/08/1",
  issue_date: Date.new(2026, 8, 20),
  currency: "PLN",
  seller: Ksef::Fa3::Party.new(
    name: "Moja Firma sp. z o.o.", nip: "1111111111",
    country: "PL", address_line1: "ul. Przykładowa 1, 00-001 Warszawa"
  ),
  buyer: Ksef::Fa3::Party.new(
    name: "Klient sp. z o.o.", nip: "2222222222",
    country: "PL", address_line1: "ul. Druga 2, 30-001 Kraków"
  ),
  vat_buckets: [ Ksef::Fa3::VatBucket.new(rate: :standard_23, net: "1000.00", vat: "230.00") ],
  lines: [ Ksef::Fa3::Line.new(name: "Subscription, August 2026", net: "1000.00", rate: :standard_23) ],
  total: "1230.00"
)

errors = Ksef::Fa3::Builder.validate(Ksef::Fa3::Builder.new(invoice).to_xml)
errors.empty? # => true

What surprises people costing this out

None of this is visible in the documentation on a first read.

The XAdES signature. "Sign the XML" doesn't cover it. You need an enveloped XAdES-BES signature with two references — one over the document with the signature removed, one over the SignedProperties block, both canonicalized with C14N 1.0 — plus a QualifyingProperties block carrying the signing time and the certificate digest. The issuer name in IssuerSerial has to be RFC 2253, not OpenSSL's default form. And if you're using an EC key, which is typical for a KSeF-issued certificate, the signature must be converted from the DER SEQUENCE into a raw fixed-width r‖s pair. Otherwise you get a rejection with no hint as to what's actually wrong. Your signature can verify perfectly on your own side, because it's internally consistent, and still be refused by KSeF.

Session encryption. AES-256-CBC with PKCS#7 padding, a fresh key and IV per session. In an online session the IV travels in its own field; in a batch session it's prepended to each part. That's the opposite of what intuition suggests, and the two modes of the same API do it differently. On top of that, four values — a hash and a size computed once over the plaintext and once over the ciphertext; get one wrong and you get a 21402 or a 21403. And because the Ministry rotates its public keys, you have to handle 21470 ("you encrypted with a stale key") by re-fetching the certificates.

How strict FA(3) is. The XSD fixes element order, not just presence. The buyer (Podmiot2) requires the JST and GV flags, which the seller must not carry. Invoice lines in FA(3) are direct children of the Fa element, with no FaWiersze wrapper of the kind FA(2) used; leave the old structure in and 21401 is guaranteed. Rates go into fixed buckets: 23, 8, 5 and 4 percent carry a VAT amount, while not-subject and reverse-charge positions carry a net amount only. In a foreign currency you additionally supply the VAT in PLN, converted at the NBP rate from the day before the sale. And one detail that only shows up in practice: an invoice whose buyer NIP equals the seller NIP is rejected, and the session closes with code 445.

Corrections. A correcting invoice (KOR) isn't just an invoice with a minus sign. It's a document with a correction type (1, 2 or 3), a reason, and a block of data about the invoice being corrected — which must include the original's KSeF number. Without that number there's nothing to correct, so in practice you need a durable link between your own invoice and the number KSeF assigned to it.

Rejection codes. KSeF returns a code, not a sentence. Without a mapping from codes to something actionable you'll ship an interface where the user sees "error 21401" and takes nothing away from it. We maintain our own map from code to cause and concrete next step, and we treat it as part of the product rather than an add-on.

Life after go-live. KSeF can answer with a 429 and a Retry-After header, or plainly with a 5xx, and your polling loop can run out before the session reaches a terminal state. That calls for a reconciliation process that goes back to the stored reference numbers and re-reads the status rather than re-sending the document. And there is one detail that costs the most nerves: closing the session is what generates the UPO, so a session whose close failed will be polled forever and never produce a receipt. That close has to be re-issued before you carry on polling.

The document hash is pinned to a timestamp. FA(3) XML carries a DataWytworzeniaFa stamp, and the SHA-256 hash is computed over exactly the bytes you sent. That same hash is the discriminator in the QR code and comes back on the UPO. Rebuild the same invoice a second later and the hash changes. So the stamp has to be stored alongside the hash, or the document you filed can never be reproduced.

The production contract is frozen

One piece of good news for planning. The Ministry of Finance froze the KSeF 2.0 production API contract on 22 December 2025. Endpoints, formats and validation rules are settled. Which means an integration written today won't fall apart at the next update, and the work you put in keeps working.

The deadlines are unchanged: mandatory from 1 February 2026 for large taxpayers and from 1 April 2026 for everyone else, with penalties from 1 January 2027.

Build it yourself, or buy

The honest answer depends on how many invoices you issue and where they come from.

Build it if invoices originate in your own system, you have a team that will maintain the integration for years, and invoicing is part of your product. The protocol is finite, the contract is frozen, and the specification is public. It's doable. Budget six to ten weeks of one person's time to reach production quality, plus an ongoing cost: queues, retries, reconciling state after a crash, UPO archiving, handling rejections.

Buy it if invoices already exist somewhere else — Stripe, a sales system — and KSeF is a compliance box to tick rather than an advantage. A few weeks of engineering time usually costs several times more than a year of a ready-made tool.

There's a middle road too, and we genuinely recommend it: take the protocol and write the rest yourself. Our ksef gem (github.com/startupkit-app/ksef) is Apache-2.0 licensed and does exactly that — token and XAdES authentication, encryption, online and batch sessions, FA(3) building and validation, UPO parsing, NIP validation and NBP rates. No Rails, no Stripe, just the protocol. You can use it and never pay us a złoty. We'd rather that than pretend building this yourself is harder than it really is.

Error handling in the gem is typed, so telling "retry" apart from "tell the user" looks like this:

begin
  client.submit(invoice)
rescue Ksef::SchemaInvalid => e   # 21401 — fix the data and resubmit
  report_to_user(e.code)
rescue Ksef::RateLimited => e     # 429 — KSeF supplies Retry-After
  retry_in(e.retry_after)
rescue Ksef::AuthError => e       # 450, 415, 460 — a connection problem, not an invoice problem
  reconnect(e.code)
end

If you bill through Stripe

There's one case where building it yourself rarely pays off. When invoices originate in Stripe, the work isn't the filing at all — it's the mapping: recognizing the transaction type, converting the currency, folding rates into FA(3) buckets, turning credit notes into corrections, and writing the KSeF number back onto the invoice.

KSeF Kit does that from the Stripe side, and underneath it runs on the same gem described above. If you'd rather keep everything in-house, we'll run that same app on your own server. See also how the Stripe–KSeF integration works and what exactly you get in a UPO.

Frequently asked questions

Does KSeF have a public API?

Yes. KSeF 2.0 exposes a REST API at api.ksef.mf.gov.pl/api/v2 (production), plus api-test.ksef.mf.gov.pl/api/v2 and api-demo.ksef.mf.gov.pl/api/v2 for the non-production environments. It's free and open to any taxpayer — you only need to authenticate with a KSeF token or an XAdES signature.

How do you authenticate against the KSeF API?

You fetch a challenge from POST /auth/challenge, then either encrypt your KSeF token with the Ministry's public key and post it to /auth/ksef-token, or XAdES-sign an AuthTokenRequest document and post it to /auth/xades-signature. Either way you poll GET /auth/{ref} until status 200 and redeem the one-time token for an access + refresh pair via POST /auth/token/redeem.

What's the difference between the TEST, DEMO and PROD environments?

TEST runs on fictional identities — you make up a NIP and generate a token, and nothing is verified, so it's where you start. DEMO requires authentication with a real NIP but carries no legal effect. PROD is the only environment where an invoice is actually issued.

How long does a self-built KSeF integration take?

Getting a first invoice onto TEST takes a few days. Six to ten weeks goes into everything else: XAdES signing, session encryption, full FA(3) mapping, corrections, mapping rejection codes to something a user can act on, crash-safe retries, and UPO archiving.

Does the FA(2) schema still work?

No. The current schema is FA(3), version 1-0E, mandatory since 1 February 2026. FA(2) structures — such as the FaWiersze wrapper around invoice lines — are invalid in FA(3) and end in a 21401 rejection.

Is there a ready-made KSeF 2.0 library for Ruby?

Yes. We publish the ksef gem under Apache-2.0: token and XAdES authentication, RSA-OAEP and AES-256-CBC encryption, online and batch sessions, FA(3) building and validation, and UPO parsing. The code is on GitHub and you can use it whether or not you use KSeF Kit.