Stripe to KSeF Integration: How a Stripe Invoice Becomes an FA(3)
To get Stripe invoices into KSeF you need three things: something that listens for the invoice.finalized webhook, something that turns the invoice payload into FA(3) XML, and something that authenticates with KSeF, opens a session, encrypts the document and collects the KSeF number and the UPO. You can write it yourself — the protocol is open and so is our library (the ksef gem, Apache-2.0) — or plug in something finished: in KSeF Kit you connect Stripe, enter your NIP, paste a token or a certificate, and your first invoice reaches the TEST environment in minutes.
What follows is the whole mechanism: exactly what happens between finalization and the KSeF number, where that path usually breaks, and how much work is left on your side in each of the two options. A developer can skim the headings and the code; an owner can read it top to bottom.
Before anything else, though: FA(3) is the schema in force. If you've landed on a guide, a set of docs or a library that talks about FA(2), you're reading a description of a format KSeF no longer accepts. That isn't a cosmetic version bump, and it gets its own section below.
What has to happen for a Stripe invoice to land in KSeF
The whole path has six stages. None of them is hard on its own, but every one has a place where it's easy to get things wrong.
- Stripe finalizes the invoice. Until that moment it's a draft and can still change. Finalization
is the
invoice.finalizedevent, and that event is the trigger. Store the payload untouched: it's your source document, and in six months you'll want to know exactly what you built the XML from. - The FA(3) XML is built. From the Stripe invoice fields you assemble the
Fakturastructure: the seller (Podmiot1), the buyer (Podmiot2), the VAT-rate buckets, the lines, the totals and the annotations. Element order comes from the XSD and is not up to you. - You authenticate with KSeF. Fetch a challenge, sign it with a token or a XAdES signature, poll for the result and redeem an access token.
- You open a session and send the document. The session carries an AES key generated for that one session and wrapped with KSeF's public key. The invoice goes up encrypted, along with its size and SHA-256 digest.
- You close the session and poll for status. Only closing the session triggers UPO generation. Once the document is accepted you get a KSeF number and the UPO, the official proof of receipt.
- You write the number back to Stripe. The KSeF number lands in the invoice's metadata
(
ksef_number), so you can see it in the Stripe dashboard and read it from the API. For exports we also addksef_verification_url, the verification address the QR code is drawn from.
All of this has to happen exactly once. An invoice sent twice is two documents in KSeF, and a
document with a KSeF number cannot be cancelled. So split stages 4 and 5: first persist the session
reference and the invoice reference on your side, then poll. If the process dies mid-poll you have
something to resume from, and you're not tempted to send the document again. That's exactly how
Filing#submit works here: open_and_send returns the references, we store them, and only then
does await run.
FA(3): what changed, and why FA(2) no longer passes
FA(3) has been mandatory since 1 February 2026. FA(2) was valid from 1 September 2023 to
31 January 2026, and that's the whole story: there is no transition window in which KSeF accepts
both. The legal basis is the act of 5 August 2025 (Dz.U. 2025 poz. 1203). The current schema is
1-0E, in a namespace dated 2025/06/25.
The fastest way to spot code written for the old schema is the invoice lines. In FA(2) the lines sat
inside a <FaWiersze> wrapper. In FA(3), <FaWiersz> is a direct, repeating child of <Fa> and
there is no wrapper at all. Emit <FaWiersze> and KSeF rejects the invoice on schema validation,
code 21401. Differences of that kind never show up in a sales description, and they cost you an
afternoon.
Second: element order inside <Fa> is the XSD sequence, not a suggestion. It looks like this:
KodWaluty · P_1 · P_2 · P_13_n/P_14_n buckets · P_15 · Adnotacje · RodzajFaktury
· (correction block: PrzyczynaKorekty? · TypKorekty · DaneFaKorygowanej)
· FaWiersz*
Emit it in a different order and the session ends in a rejection, even though every value is present and correct.
Third, and often forgotten: corrections issued today go out as FA(3), even when the original was an FA(2) or an FA(1). There is no such thing as a correction "in the original's schema".
Fourth, and it matters for planning: the production API contract has been frozen since 22 December 2025. That's good news, because an integration written once won't drift every month. The bad news is that a frozen contract also ends the excuses: whatever doesn't pass today won't start passing on its own next quarter.
A practical test for the quality of a source: if a piece about integrating Stripe says "XML conforming to the FA(2) schema", it describes the legal position before 1 February 2026. Everything else in that piece — including its effort estimate and its build-vs-buy argument — rests on an outdated assumption.
What a structured invoice is in conceptual terms is covered separately in how to issue a structured invoice, and the dates and thresholds of the mandate in KSeF mandatory in 2026.
The hard parts, honestly
Below is the list of things that eat the most time in a self-built integration. Not to scare anyone off — so that nobody budgets two days for something with six layers.
Authentication
There are two routes, and both come down to signing a challenge fetched from the API.
KSeF token (machine to machine): fetch a challenge, encrypt the string "{token}|{timestampMs}"
with RSA-OAEP and SHA-256 using KSeF's public key from the certificates endpoint, POST it to
/auth/ksef-token, poll for the outcome and exchange it for an access token. The classic trap: the
token is encrypted verbatim, so one stray space or newline from a copy-paste breaks
authentication and you get code 450. That's why both the gem and the app strip the pasted value
before doing anything with it.
XAdES signature: build an AuthTokenRequest in the auth/token/2.1 namespace, sign it as an
enveloped XAdES-BES (SHA-256 over an RSA or EC key) and POST it as application/xml. There's a
further choice: how KSeF matches the certificate to the taxpayer — on the subject (a company
certificate carrying the NIP) or on the fingerprint (a personal qualified certificate, which doesn't
carry one). In production the certificate chain is verified; in TEST and DEMO it isn't, because
self-signed certificates are used there.
We break down the choice in KSeF token or certificate. In short: the published rule says tokens work until 31 December 2026 and that from 1 January 2027 only certificates remain. After its own consultation on 9 June 2026 the Ministry of Finance recommended keeping tokens after all, but that's still a recommendation, not law. There's a hard constraint worth knowing up front: a session authenticated with a token cannot request a certificate. Moving from a token to a certificate is therefore a deliberate human step, not a script you run overnight.
Session encryption
KSeF won't take an invoice in the clear. For each session you generate a fresh AES-256 key
(32 bytes) and an initialization vector (16 bytes), wrap the key with RSA-OAEP (SHA-256 and
MGF1-SHA-256), and encrypt the invoice with AES-256-CBC and PKCS#7 padding. You then declare four
values, not two: the size and SHA-256 digest of the plaintext XML (invoiceSize, invoiceHash) and
the size and digest of the encrypted bytes (encryptedInvoiceSize, encryptedInvoiceHash), all in
Base64.
The place almost everyone trips: in an online session the initialization vector travels in its own
initializationVector field, while in a batch session it is prepended to each part. Get it
backwards and you get 21403 (hash mismatch) or 21402 (size mismatch) — messages that tell you nothing
about where the problem actually is.
Schema strictness
FA(3) validates hard and forgives nothing:
- The VAT rate in
P_12is a closed enumeration. A value outside it is rejected, so it's better for your code to refuse loudly than to send something that will bounce. - An identified buyer (one carrying a NIP, an EU VAT number or a foreign tax id) must have an
address. That's art. 106e ust. 1 pkt 3, and an empty
AdresL1is simply schema-invalid. A buyer with no identifier at all — a consumer — doesn't need one. - Seller and buyer can't be the same NIP. KSeF rejects
Podmiot1equal toPodmiot2, and the session ends with status 445, "no invoices accepted".
Currency and NBP rates
The VAT amount in FA(3) always has to be stated in PLN, in the P_14_xW fields. The rate you must
use is the NBP average from the last business day before the tax point (art. 31a). NBP table A is
published only on business days, so you query a short window backwards and take the latest published
rate instead of assuming yesterday was a working day.
The most common mistake is using Stripe's settlement rate. That's the rate at which Stripe converted your money, and it doesn't belong on an invoice. The full mechanism, edge cases included, is in NBP exchange rates on an invoice.
Corrections
A Stripe credit note (credit_note.created) corresponds to a correcting invoice. In FA(3) that's a
document of kind KOR, whose DaneFaKorygowanej block points at the original: its KSeF number, its
own number and its issue date, plus TypKorekty and an optional reason. A correction is a separate
document and takes the same full path, with its own session and its own UPO.
An invoice with a KSeF number cannot be cancelled. The only way back is a correction, so design your refund flow on the assumption that documents never get withdrawn. More in correcting an invoice in KSeF (KOR).
Rejection codes
Rejections aren't uniform, and there's no sense handling them with a single rescue. They fall into
families, and the family tells you which side the fix is on:
| Code | Meaning | Where you fix it |
|---|---|---|
| 21401 | Document failed FA(3) validation | the data, or your XML generator |
| 21402 / 21403 | Size or hash mismatch | transmission, usually just resend |
| 21301 / 21304 / 21478 | NIP invalid, missing or not an active VAT payer | buyer data |
| 21408 | Duplicate — the invoice is already in KSeF | do nothing, check your own records |
| 450 / 401 / 403 / 415 / 425 / 460 | Authentication or permissions | the KSeF connection |
| 21470 | The public key rotated | re-fetch the key and retry |
| 429 | Too many requests | wait and retry, honouring Retry-After |
| 440 / 445 / 21440 | Session cancelled, empty or closed | open a fresh session |
| 500 / 550 | KSeF-side error | retry later |
One thing decides your retry logic: 429 and 5xx may be retried, but only while you don't yet hold the references of a sent document. If the send succeeded and something failed afterwards, the invoice is already in KSeF and resending makes it a duplicate.
What Stripe's data doesn't cover
Stripe looks like a system that "has everything". Six things are missing when you go to build an FA(3).
- The buyer's NIP. Stripe keeps it in
customer_tax_idsonly if you collected it. A missing number isn't a formality: without it the sale is B2C, and therefore outside the KSeF mandate. A B2B invoice quietly turns into a consumer one. - The buyer's address.
customer_address.line1is often empty, and an address is mandatory for a buyer carrying a tax id. - GTU codes and other markers. Stripe knows nothing about Polish goods-and-services markers. If your sales need them, they have to come from your side.
- The reverse-charge annotation.
P_18is a tax determination, not a Stripe field. - Export of services. Stripe Tax can compute tax, but it doesn't decide whether a sale is an export outside Polish VAT or an EU reverse charge. Those are two different things, and confusing them is one of the commonest errors in Polish guides.
- The full line list. In the webhook payload,
linesis paginated at 10 with ahas_moreflag. A fifteen-line invoice built from the payload alone will simply be incomplete — and KSeF will accept it, because it is formally valid.
What to collect in Stripe, and how to force it in Checkout, is in customer data in Stripe for a KSeF invoice.
Foreign sales, the part most often got wrong
Foreign B2B invoices are in scope for KSeF too. The obligation comes from art. 106ga ust. 1 and attaches to the issuer, not the buyer. A Polish sole trader invoicing a Delaware company files that invoice with KSeF exactly as they would one for a company in Katowice. On top of that, art. 106gb ust. 4 says a document handed over outside the system must carry the KSeF number and a QR code, because a foreign counterparty cannot log into KSeF.
Three cases, three different documents:
- An EU company with a valid EU VAT number. Reverse charge on services, net only, no VAT,
P_18=1, bucketnp II(art. 100 ust. 1 pkt 4). - A non-EU company with a foreign tax id. Export of services outside the scope of Polish VAT, net
only,
P_18=2, bucketnp I. This is not reverse charge, whatever half the guides call it. - A consumer. Voluntary in KSeF, not mandatory. OSS and IOSS sit outside the system.
The worked examples are in export invoices in KSeF, and the QR code itself in the KSeF QR code. KSeF does not return that code: you compute it yourself from the verification base URL, the seller's NIP, the issue date and the SHA-256 of exactly the XML bytes you sent.
Build it yourself, or buy
We'll say it plainly even though we sell a tool: building it yourself is a legitimate choice, and not a rare one. The protocol is public, the Ministry of Finance publishes reference clients, and our own protocol library is open under Apache-2.0 — so you neither start from zero nor need our permission.
An honest estimate if you work in Ruby and use the gem: a few days to your first invoice in TEST, then somewhere between several and a dozen or so days on the things that decide whether it survives contact with production. Those things are:
- a queue and retry policy that never sends a document twice,
- resuming a session after a crash mid-poll,
- fetching the paginated Stripe line list,
- VAT classification (domestic, EU, export, consumer) together with the decision about what deliberately not to file,
- NBP rates and the
P_14_xWfields, - KOR corrections that reference the original,
- reproducibility: persisting the
DataWytworzeniaFastamp next to the digest, because without it you can't rebuild the same bytes and can't reproduce the QR code, - storing UPOs and handling rejection codes sensibly,
- a second authentication path for the moment a token stops being enough.
If someone in the company will own this, it's a good route. If the integration would be a one-off project with no owner, it becomes a source of quiet problems, because KSeF failures don't show up as an outage — they show up as an invoice that isn't where it should be. More on the API itself in the KSeF API for developers, and on the practice ground in the KSeF test environment.
Code: filing an invoice with the ksef gem
Here is the full path, split into send and poll, which is how it should be done in the background:
require "ksef"
environment = Ksef::Environment.new(:test) # api-test.ksef.mf.gov.pl/api/v2
client = Ksef::Client.new(
env: environment,
authenticator: Ksef::Authenticator::Token.new(
environment: environment,
ksef_token: ENV.fetch("KSEF_TOKEN"),
context_nip: "1111111111"
)
)
invoice = Ksef::Fa3::Invoice.new(
number: "FV/2026/08/17",
issue_date: Date.current,
currency: "PLN",
seller: Ksef::Fa3::Party.new(name: "Moja Firma sp. z o.o.", nip: "1111111111",
country: "PL", address_line1: "ul. Przykładowa 1", address_line2: "00-001 Warszawa"),
buyer: Ksef::Fa3::Party.new(name: "Klient sp. z o.o.", nip: "2222222222",
country: "PL", address_line1: "ul. Testowa 7", address_line2: "30-001 Kraków"),
vat_buckets: [
Ksef::Fa3::VatBucket.new(rate: :standard_23, net: BigDecimal("1000"), vat: BigDecimal("230"))
],
lines: [
Ksef::Fa3::Line.new(name: "Subscription, August 2026", net: BigDecimal("1000"), rate: :standard_23)
],
total: BigDecimal("1230")
)
# Send without polling: persist the references before you start waiting.
receipt = client.open_and_send(invoice)
receipt.session_reference # session reference
receipt.invoice_reference # invoice reference within the session
receipt.invoice_hash # Base64URL SHA-256 of the sent bytes — the QR "wyróżnik"
receipt.generated_at # the DataWytworzeniaFa stamp of those bytes
# Polling, safe to repeat: it never re-sends anything.
result = client.await(
session_reference: receipt.session_reference,
invoice_reference: receipt.invoice_reference
)
result.ksef_number # e.g. "1111111111-20260817-…"
result.upo.xml # the UPO as issued, to keep
The NBP rate and the QR verification address are separate, small calls:
rate = Ksef::NbpRate.lookup(currency: "EUR", date: Date.current)
rate.rate # average PLN per 1 EUR
rate.effective_date # the business day it came from
Ksef::VerificationCode.new(
env: :production, nip: "1111111111",
issue_date: Date.current, invoice_hash: receipt.invoice_hash
).url
The library lives on GitHub (github.com/startupkit-app/ksef, directory gems/ksef). If you'd
rather run the whole application yourself, that's covered in
self-hosting.
What KSeF Kit does
Once Stripe and KSeF are connected, the app:
- Listens for
invoice.finalizedandcredit_note.createdon your Stripe account and stores the payload as an immutable source document. - Builds the FA(3): classifies VAT from the buyer's country and tax id, picks the rate bucket, sets
P_18, converts VAT to PLN at the NBP rate. - Fetches the full, paginated line list from the Stripe API, so a 40-line invoice isn't truncated to 10.
- Authenticates with a token or a certificate, against TEST or PROD, and holds both connections side by side so going live doesn't mean disconnecting test.
- Files the document, closes the session, collects the KSeF number and the UPO, and keeps the UPO available for download.
- Writes the KSeF number back into the Stripe invoice's metadata, and for exports adds the verification address behind the QR code.
- Turns a credit note into a KOR correction referencing the original's KSeF number, and writes the
correction number onto the Stripe credit note, including the human-visible
memofield. - Refuses when it isn't sure. If the buyer can't be classified, the address is missing, or the rate is outside what we build, the invoice is held with an explanation rather than filed as a wrong 23%. KSeF will accept a document that is formally valid but substantively wrong, and that is worse than a rejection, because nobody finds out.
- Makes sure the document goes out exactly once, including when two workers start in parallel or the connection dies halfway.
What it costs
Free tier: 6 accepted filings per month, no card. Paid plans are 9, 49 and 129 PLN per month and lift the quota only. We never gate correctness: no plan takes away your QR code, your corrections or your exports.
The quota counts only invoices KSeF accepted. A rejection, a hold or a failed attempt doesn't burn any of it. You don't pay for things that didn't work.
Getting started
Onboarding is three steps and that's it:
- Stripe. Authorize the account, or install the app from the Marketplace.
- Company details. Your NIP, verified against the MF register (the biała lista), plus the seller's name and address. The NIP checksum is checked immediately, so typos never reach KSeF.
- KSeF. Paste a token or upload a certificate, and pick the environment.
Start with TEST. It's the environment with fictional authentication, so you can send anything
there and break nothing. Only once you've seen your first KSeF number and your first UPO do you
switch to PROD. The taxpayer app where you generate a token or apply for a certificate is
ap-test.ksef.mf.gov.pl for test and ap.ksef.mf.gov.pl for production.
If you're a sole trader wondering what exactly applies to you, start with KSeF for sole traders. If you issue a handful of invoices a month, the free tier will probably do: see free KSeF for Stripe.