Skip to content
KSeF Kit

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.

  1. Stripe finalizes the invoice. Until that moment it's a draft and can still change. Finalization is the invoice.finalized event, 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.
  2. The FA(3) XML is built. From the Stripe invoice fields you assemble the Faktura structure: 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 add ksef_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:

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).

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:

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:

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:

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:

  1. Stripe. Authorize the account, or install the app from the Marketplace.
  2. 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.
  3. 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.

Frequently asked questions

How do I connect Stripe to KSeF?

Three steps: connect your Stripe account, fill in your company details including the NIP, and connect KSeF with a token or a certificate. You start in the TEST environment and switch to PROD after your first successful filing. From then on, every finalized Stripe invoice goes to KSeF as an FA(3) without you doing anything.

FA(2) or FA(3) — which schema applies?

FA(3). It has been mandatory since 1 February 2026. FA(2) was valid from 1 September 2023 to 31 January 2026 and no longer passes validation. If a guide or a library still describes FA(2), it describes a format KSeF no longer accepts. Corrections issued now also go out as FA(3), even when the original was an FA(2) or FA(1).

Does this work with Stripe Billing and subscriptions?

Yes. What matters isn't where the invoice came from, it's that it was finalized. A subscription, an invoice created by hand in the dashboard, one from Checkout or from the API — they all produce the same invoice.finalized webhook and take the same path to KSeF. A fifteen-line invoice works too, because we fetch the full line list from the Stripe API rather than from the payload alone.

What about credit notes and corrections?

A Stripe credit note becomes an FA(3) correction of type KOR that references the original's KSeF number. An invoice that already has a KSeF number cannot be cancelled, only corrected. The correction's number is written back onto the Stripe credit note.

What about foreign customers?

Foreign B2B sales are in scope for KSeF too, because the obligation follows the issuer's status, not the buyer's location. An EU company means reverse charge (net only, P_18=1); a non-EU company means export of services outside Polish VAT (net only, P_18=2). Consumer sales stay voluntary, and by default we don't file them.

Do I need a KSeF token or a certificate?

Today both work. A token is a single paste and the integration starts immediately. A KSeF certificate is free, valid for two years, and it is the direction of travel: the published rule says tokens were to work until 31 December 2026. The Ministry of Finance has proposed keeping tokens permanently, but that is still a recommendation, not law.

What does filing Stripe invoices to KSeF cost?

The free tier covers 6 accepted filings per month and needs no card. Paid plans are 9, 49 and 129 PLN per month and lift the quota only, never the correctness of the document. Rejections and holds don't burn quota — only invoices KSeF accepted count.

Do I have to do anything for each invoice?

No. Finalizing an invoice in Stripe starts the whole path, and the KSeF number comes back into the document's metadata. You only step in when KSeF rejects something, or when an invoice is waiting because Stripe didn't collect a detail FA(3) requires.