Skip to main content

Poland — KSeF (Krajowy System e-Faktur)

Research doc for the Poland fiscalization adapter. Consumed by the implementation bead (TBD). No code in this bead.

Status: Research — no adapter code written yet. Strategic context: Phase-1.5 Central European country added to parallel research at user direction (see fi-awx). €0 cert cost (qualified electronic signature OR portal-issued authorization token), REST API at the Ministry of Finance, mandatory B2B from 2026 (with a phased rollout — see §1). ~2M VAT taxpayers — the largest market in the Phase-1 cluster. Caveat: rollout has been chaotic. The mandatory date has slipped multiple times (originally 2024-01-01, then 2024-07-01, then 2026-02-01, then split into a phased 2026-02 / 2026-04 timeline). All dates below are confirmed against post-2025-09 Ministry of Finance communications. See docs/strategy/thesis-api-wedge.md (fi-awx) and apps/docs-internal/docs/ops/country-prioritization.md.


1. Regulatory scope

What must be reported

Poland's Krajowy System e-Faktur (KSeF — National e-Invoicing System), operated by the Ministerstwo Finansów (Ministry of Finance, MF), is a central platform through which structured electronic invoices are issued, received, archived, and made available to the tax authority. Legal basis is the Ustawa o podatku od towarów i usług (VAT Act) of 11 March 2004, articles 106nb–106ne as amended by the Ustawa o zmianie ustawy o podatku od towarów i usług oraz niektórych innych ustaw (Dz.U. 2023 poz. 1598, the so-called "KSeF Act") and subsequent amendments.

Mandatory-use timeline (verified post-2025-09)

Effective dateScope change
2022-01-01KSeF available on a voluntary basis. Taxpayers may opt in to issue faktury ustrukturyzowane (structured invoices).
2024-01-01Original mandatory-use date — deferred (announced 2023-04).
2024-07-01Second mandatory-use date — deferred in 2024-01 after MF audit found the system "not ready for compulsory use".
2026-02-01 → 2026-04-01Current phased rollout (announced by MF on 2025-09; codified in the amending Act signed 2025-11):
2026-02-01Mandatory for "large taxpayers" (sales value incl. VAT > PLN 200 million in 2024). Approx. 4,500 entities.
2026-04-01Mandatory for all other VAT-active taxpayers issuing invoices. Approx. 2 million entities. Foreign VAT-registered entities without a fixed establishment (stałe miejsce prowadzenia działalności) in Poland remain outside the mandate but may opt in.
2026-04-01Mandatory to capture paragony z NIP (cash-register receipts ≤ PLN 450 with the buyer's NIP that are treated as simplified invoices) outside KSeF — these continue to flow through the cash-register regime, not KSeF.
2027-01-01Mandatory inclusion of B2C invoices that the customer requests in invoice form — provisional, may slip again. B2C receipts (paragony) remain outside KSeF indefinitely.

Source for the phased dates: Ministry of Finance press release 2025-09-26 ("KSeF — kalendarz wdrożenia"), the amending Act published in Dziennik Ustaw 2025 (sygn. legislative track UD125), and the MF FAQ at gov.pl/web/kas/krajowy-system-e-faktur (last verified 2026-04-26).

Out of scope

  • B2C consumer invoices and receipts (paragony fiskalne) — handled by the separate kasy fiskalne (fiscal cash register) regime; not KSeF. Our Poland adapter targets KSeF only. A merchant who issues only paragony for B2C and never issues invoices is out of scope for this adapter.
  • Invoices issued by foreign entities not VAT-registered in Poland — out of scope.
  • Invoices where the seller is a VAT-exempt small business (podatnik zwolniony, art. 113 VAT Act) — exempt from the mandate but may opt in.
  • Simplified invoices issued through a fiscal cash register (paragon ≤ PLN 450 with the buyer's NIP) — these are the "fiskalne uproszczone" invoices and remain in the cash-register universe.
  • Documents that are not invoices — proforma, dispatch notes, demand letters, etc. KSeF accepts only the structured-invoice document type (faktura ustrukturyzowana).

Invoice categories handled by KSeF

TypePolish termNotes
Standard invoiceFakturaDefault. Issued for taxable supplies.
Credit noteFaktura korygującaReferences the original numerKSeF (or numerKSeF-less issuer number for pre-KSeF originals). Adjusts net/VAT/quantity.
Advance-payment invoiceFaktura zaliczkowaIssued before the supply for an advance payment.
Final settlementFaktura końcowa / rozliczeniowaSettles the supply against earlier zaliczka(s).
Self-billingSamofakturowanieIssued by the buyer on behalf of the seller. Requires prior agreement and authorisation in KSeF.
Reverse-chargeFaktura z odwrotnym obciążeniemVAT shifted to recipient. Marked with P_18 (reverse-charge indicator) and a 0% / "oo" rate.
Intra-community supplyWDT (wewnątrzwspólnotowa dostawa towarów)EU customer with valid VAT-UE number. Marked with P_18A.
ExportFaktura eksportowaNon-EU customer.
Marketplace / platformFaktura w procedurze unijnej / OSS / IOSSOSS/IOSS flagged via Adnotacje.

2. API surface

Base URLs

EnvironmentAPI baseWeb portal
Test (synthetic NIPs)https://ksef-test.mf.gov.pl/api/https://ksef-test.mf.gov.pl/web/login
Demo (real NIPs, no fiscal effect)https://ksef-demo.mf.gov.pl/api/https://ksef-demo.mf.gov.pl/
Productionhttps://ksef.mf.gov.pl/api/https://ksef.mf.gov.pl/

Cited in the MF KSeF technical documentation page (gov.pl/web/kas/krajowy-system-e-faktur, "Specyfikacja interfejsów API"). Test, demo, and production are fully isolated taxpayer universes; an authorization token issued in test cannot be used in demo or production.

API version

Current production: KSeF 1.0, schema FA(2) (namespace http://crd.gov.pl/wzor/2023/06/29/12648/, schema file Schemat_FA(2)_v1-0E.xsd, valid since 2023-09-01).

KSeF 2.0, schema FA(3), has been published in beta:

VersionSchemaStatusProduction date
KSeF 1.0FA(2) v1.0ELive in production; only XSD currently accepted by the production endpoint.Live since 2022-01-01. Will remain accepted at least through 2026-09-30 per MF roadmap.
KSeF 2.0FA(3)Sandbox (test / demo) only. Public consultation ran 2024-12 / 2025-02; final XSD published 2025-06-13.Mandated alongside the 2026-04-01 rollout; FA(2) acceptance ends 2026-10-01 (per the amending Act).

Adapter strategy: ship FA(2) first (the only schema accepted in production at the 2026-02-01 cutover), with FA(3) on a feature flag and on the same dispatch path. The two schemas are not wire-compatible (different namespace, restructured Fa body, additional Adnotacje codes); we'll gate per the Location.country_config.schema_version setting.

Endpoints (KSeF 1.0, FA(2))

All endpoints share Content-Type: application/xml for write operations (except /Session/InitSigned which is XAdES-enveloped XML) and application/json is also accepted on most read endpoints when the Accept: application/json header is sent. All paths are relative to the base URL and rooted under /online/ for interactive flows or /batch/ for the batch (async-archive) flow.

PathMethodPurposeAuth
/online/Session/AuthorisationChallengePOSTInitiate a session — server returns a challenge (timestamp + nonce).none (anonymous)
/online/Session/InitTokenPOSTOpen a session using an authorization token (system-to-system flow). Body wraps the encrypted token.challenge + encrypted token
/online/Session/InitSignedPOSTOpen a session using a XAdES-BES enveloped qualified signature or qualified seal.challenge + XAdES
/online/Session/StatusGETSession status + count of submitted invoices in this session.session token
/online/Session/TerminatePOSTClose the session (best-effort; sessions also expire after 2 h or 100 MB of traffic).session token
/online/Invoice/SendPOSTSubmit one FA(2) invoice. Returns synchronous referenceNumber (submission ID) + processingCode.session token
/online/Invoice/Status/{referenceNumber}GETPer-submission status; returns the numerKSeF and embedded UPO once async validation completes.session token
/online/Invoice/Get/{numerKSeF}GETRetrieve a previously accepted invoice (XML) by its KSeF number.session token
/online/Common/Status/{referenceNumber}GETGeneric status endpoint for any submission reference (invoice, query, batch).session token
/online/Query/Invoice/SyncPOSTSynchronous lookup; returns up to 100 invoices matching filter (date range, NIP, type).session token
/online/Query/Invoice/Async/InitPOSTInitialise an async query covering up to 2-year ranges.session token
/online/Query/Invoice/Async/Status/{ref}GETPoll async query progress; download URL when ready.session token
/batch/Invoice/SendPOSTSubmit a ZIP archive of up to 10,000 invoices in one batch.session token (batch session)
/batch/Invoice/Status/{ref}GETPer-batch status.session token

Rate limiting: MF publishes a soft limit of 1 invoice/second sustained on /online/Invoice/Send per session, with bursts up to 100/min tolerated. The batch endpoint is the recommended path for >10 invoices/minute. Hard quotas: max 2-hour session lifetime, max 100 MB of payload per session.

Adapter hot path: AuthorisationChallengeInitTokenInvoice/Send → (poll) Invoice/Status to retrieve UPO.


3. Authentication

KSeF supports four authentication modes. Our adapter targets token mode as the primary path, with InitSigned (XAdES qualified signature/seal) as a secondary path for customers who do not want to provision a portal token.

One-time onboarding (out-of-band, performed by the merchant once):

  1. Merchant logs into the KSeF web portal (ksef.mf.gov.pl) using a qualified electronic signature, qualified seal, or Profil Zaufany (Trusted Profile, the Polish e-government identity).
  2. In "Tokeny autoryzacyjne" they generate an authorization token with one or more scopes:
    • InvoiceWrite — issue invoices on behalf of this NIP.
    • InvoiceRead — read invoices issued under this NIP.
    • CredentialsManage — manage downstream tokens (avoid).
  3. Token is a 40+ character opaque string, displayed once. Capture immediately. Bound to the NIP and the chosen scopes.
  4. Optionally: in "Uprawnienia" the merchant grants the token's owner permission to issue self-billing invoices on behalf of other NIPs.

Per-session flow (every 2 hours or per submission batch):

  1. POST /online/Session/AuthorisationChallenge

    <AuthorisationChallengeRequest>
    <ContextIdentifier>
    <Type>onip</Type>
    <Identifier>1234567890</Identifier> <!-- 10-digit NIP -->
    </ContextIdentifier>
    </AuthorisationChallengeRequest>

    Response:

    <AuthorisationChallengeResponse>
    <Timestamp>2026-04-26T14:00:00.000Z</Timestamp>
    <Challenge>20260426140000-XXXXXXXXXX-XX</Challenge> <!-- 26 chars -->
    <ReferenceNumber>20260426-CR-...</ReferenceNumber>
    </AuthorisationChallengeResponse>
  2. Encrypt the authorization token. The token plaintext is concatenated with the challenge:

    plaintext = authorizationToken + "|" + challenge

    Encryption is AES-256-CBC with a fresh per-session key:

    sessionKey   = random 32 bytes
    iv = random 16 bytes
    cipher = AES-256-CBC(sessionKey, iv).encrypt(plaintext)
    encryptedKey = RSA-OAEP-SHA256(MFPublicKey).encrypt(sessionKey)

    The MF public key is published as an X.509 certificate at gov.pl/api/files/.../publicKey.pem (separate per environment — test, demo, production keys are distinct). The certificate is rotated yearly in late Q4; pin the year-current key by file digest, refresh on EncryptionKeyExpired errors.

  3. POST /online/Session/InitToken with the encrypted token:

    <InitSessionTokenRequest>
    <Context>
    <Challenge>20260426140000-XXXXXXXXXX-XX</Challenge>
    <Identifier>
    <Type>onip</Type>
    <Identifier>1234567890</Identifier>
    </Identifier>
    <DocumentType>FA</DocumentType>
    <Token>BASE64(iv || cipher || encryptedKey)</Token>
    </Context>
    </InitSessionTokenRequest>

    Response: a JWT-like SessionToken (200 chars, opaque) and ReferenceNumber. Lifetime: 2 hours, sliding on activity.

  4. Subsequent requests carry header:

    SessionToken: <token>

Mode 2 — Qualified electronic signature / seal (InitSigned, XAdES)

One-time onboarding: none — the merchant just needs a qualified certificate (smart card, USB token, or cloud QSCD via SimplySign / mSzafir / similar) issued by an EU-listed QTSP.

Per-session flow:

  1. AuthorisationChallenge as above.
  2. Build the InitSessionRequest payload (XML):
    <InitSessionRequest xmlns="…/2021/10/01/0001/">
    <Context>
    <Challenge></Challenge>
    <Identifier>
    <ContextIdentifierType>onip</ContextIdentifierType>
    <ContextIdentifierValue>1234567890</ContextIdentifierValue>
    </Identifier>
    <DocumentType>FA</DocumentType>
    </Context>
    </InitSessionRequest>
  3. Sign with XAdES-BES enveloped (the <Signature> element nests inside <InitSessionRequest> per ETSI EN 319 132-1). Hash algorithm must be SHA-256 (the previous SHA-1 fallback was retired 2023-12). The signing certificate must be a qualified certificate per eIDAS.
  4. POST the signed XML to /online/Session/InitSigned. Returns the same SessionToken shape as InitToken.

Cloud-OK implication: signing requires access to the qualified key material. For SimplySign / mSzafir (cloud-hosted qualified signing), the adapter calls the QTSP's REST signing API instead of holding a local key — this preserves the Cloud deployment story. For local-key customers (smart card / USB), the InitSigned flow must run embedded (in the POS or on a customer-side gateway). We will document both paths but expect token mode to dominate.

Mode 3 — Profil Zaufany / podpis zaufany (Trusted Profile)

Web-only. Used by individual entrepreneurs to log into the KSeF portal and generate authorization tokens. Not available as an API auth method — mentioned here for completeness because every Polish KSeF onboarding discussion confuses Profil Zaufany with the API auth modes. Our adapter does not integrate with PZ directly.

Mode 4 — Pieczęć kwalifikowana (qualified seal)

Same wire flow as Mode 2 (XAdES enveloped signed XML), but the signing cert is a seal certificate (legal-entity-bound, no natural-person identification) instead of a signature certificate (natural-person signature). KSeF accepts both. We treat them identically in the adapter.

What our adapter persists

Per Location:

FieldSourceStored as
nipmerchant10-digit string, plaintext
auth_methodmerchantenum `Token
authorization_tokenmerchant (Mode 1 only)encrypted at rest (Cloud KMS)
signing_certificatemerchant (Mode 2/4)reference into KMS / QTSP API config
mf_public_key_fingerprintMF environmentlocal file SHA-256, refreshed yearly

Session tokens are never persisted — they are short-lived and stay in memory.


4. Payload shape (FA(2))

Schema

  • XSD: Schemat_FA(2)_v1-0E.xsd — published at https://www.gov.pl/web/kas/krajowy-system-e-faktur → "Struktury dokumentów XML" → "Faktura ustrukturyzowana FA(2)".
  • Namespace: http://crd.gov.pl/wzor/2023/06/29/12648/
  • Imports: http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2022/01/05/eD/DefinicjeTypy/ (general MF types — TNIP, TKodKraju, etc.) and http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2024/01/05/eD/StrukturyDanych/ (address structures).

Top-level shape

Faktura
├── Naglowek
│ ├── KodFormularza kodSystemowy="FA (2)" wersjaSchemy="1-0E"
│ ├── WariantFormularza # always "2" for FA(2)
│ ├── DataWytworzeniaFa # ISO datetime, ms precision
│ ├── SystemInfo # informational, free text
│ └── KodUrzedu # 4-digit tax-office code (e.g. "1010" Warszawa-Śródmieście)
├── Podmiot1 # Supplier (issuer)
│ ├── DaneIdentyfikacyjne
│ │ ├── NIP # 10 digits
│ │ ├── PelnaNazwa
│ │ └── (optional) StatusInfo
│ ├── Adres
│ │ ├── KodKraju # ISO-3166 alpha-2 (PL)
│ │ ├── AdresL1 # full address line 1 (street + number)
│ │ ├── AdresL2 # postal code + city + voivodeship
│ │ └── (optional) GLN
│ ├── (optional) AdresKoresp # correspondence address
│ └── (optional) DaneKontaktowe # email / phone
├── Podmiot2 # Customer
│ ├── DaneIdentyfikacyjne
│ │ ├── (one of) NIP | KodUE+NrVatUE | NoIdentyfikatoraPodatkowego | BrakID
│ │ └── PelnaNazwa
│ ├── Adres ...
│ └── (optional) Status / KodKrajuOdbiorcy
├── (optional) Podmiot3 ×N # Third party — recipient distinct from buyer (rzeczywisty odbiorca)
├── (optional) PodmiotUpowazniony # Authorised representative (self-billing buyer)
├── Fa
│ ├── KodWaluty # ISO-4217 (PLN | EUR | USD …)
│ ├── P_1 # invoice issue date (YYYY-MM-DD)
│ ├── P_1M # place of issue (city)
│ ├── P_2 # issuer's invoice number — UNIQUE PER ISSUER PER YEAR
│ ├── P_6 # supply date (date of completion / VAT obligation)
│ ├── P_6_Od / P_6_Do # supply period (range, alternative to P_6)
│ ├── (per-rate sums — populated only for rates the invoice actually uses)
│ │ ├── P_13_1, P_14_1 # net + VAT at 23% (basic rate)
│ │ ├── P_13_2, P_14_2 # net + VAT at 8%
│ │ ├── P_13_3, P_14_3 # net + VAT at 5%
│ │ ├── P_13_4, P_14_4 # net + VAT at 0% (export / WDT / 0% goods)
│ │ ├── P_13_5, P_14_5 # net at "zw" (zwolniona — exempt)
│ │ ├── P_13_6 # net at "np" (out-of-scope, no VAT)
│ │ ├── P_13_7, P_14_7 # net at "oo" (reverse-charge)
│ │ └── (FA(2) v1.0E adds: P_13_8/P_14_8 reserved for future rate changes)
│ ├── P_15 # gross total in invoice currency
│ ├── KursWaluty # exchange rate to PLN (4-decimal) when KodWaluty ≠ PLN
│ ├── Adnotacje # nested flags (see below)
│ ├── (optional) DodatkowyOpis # free-text annotations / numbered side notes
│ ├── (optional) RodzajFaktury # invoice subtype: VAT | KOR | ZAL | ROZ | UPR | KOR_ZAL | KOR_ROZ | POZ
│ ├── DaneFaKorygowanej # MANDATORY for KOR / KOR_ZAL / KOR_ROZ — references original
│ ├── FakturaWiersz ×N # line items (see below)
│ ├── (optional) Zamowienia # for ZAL — references the order
│ ├── (optional) WarunkiTransakcji # delivery / payment / reverse-charge details
│ └── Platnosc # payment method, deadline, account
└── Stopka
└── Informacje # signatures of seller (text) — purely informational

Line items: FakturaWiersz

Repeated 1..∞ times:

FieldDescription
NrWierszaFaline number, 1..N (sequential, no gaps)
UU_IDoptional universal product/unit ID
P_6Aoptional supply date for this line (overrides invoice-level P_6)
P_7description (free text, ≤ 256)
P_8Aunit of measure (free text — szt., kg, usl., etc.)
P_8Bquantity (decimal, up to 6 fractional digits)
P_9Aunit net price (decimal, currency of invoice)
P_9Bunit gross price (optional, if invoice is gross-based)
P_10line discount (decimal)
P_11line net amount (= P_9A × P_8B − P_10)
P_11Aline gross amount (= P_11 + line VAT)
P_12VAT rate string — see below
GTUspecial-goods/services code (GTU_01GTU_13), optional
KursWalutyper-line FX rate (rare)
GTUKodCNcombined-nomenclature code for goods (export)
PKWiUservice classification code (rare)

P_12 VAT-rate values

Stored as strings in FA(2), not numerics:

ValueMeaning
"23"23% standard rate
"8"8% reduced rate (food, books print, hospitality)
"5"5% super-reduced (basic groceries, books digital)
"4"4% (cab fares — flat-rate transportation)
"3"3% (legacy; agriculture flat-rate)
"0"0% (intra-community supply, export, certain goods)
"zw"zwolniona — VAT-exempt under art. 43
"oo"odwrotne obciążenie — reverse charge
"np"nie podlega — out of scope

Adapter rule: the input Decimal VAT rate is mapped to the FA(2) string form per the table above. 0.23 → "23", 0.0 + reverseCharge → "oo", exempt → "zw". We never emit the raw decimal in P_12.

Adnotacje (procedural flags)

A nested element grouping every regulatory marker the invoice carries:

FlagPolishTrigger
P_16metoda kasowacash-accounting method
P_17samofakturowanieself-billing (buyer-issued)
P_18odwrotne obciążeniereverse-charge to buyer
P_18Amechanizm podzielonej płatnościsplit-payment (mandatory for invoices ≥ PLN 15,000 with annex-15 goods/services)
P_19art. 119 — biuro podróżytravel-agency margin scheme
P_20art. 120 — towary używanesecond-hand goods margin scheme
P_21wewnątrzwspólnotowa dostawa nowych środków transportuintra-EU new-vehicle supply
P_22wewnątrzwspólnotowa transakcja trójstronnaEU triangular transaction
P_23dostawa towarów ujęta w innej fakturzerefers to bundled invoice
Zwolnienienested element with VAT-exempt basis (Podstawa_zw art./par.) for P_12 = "zw" rows
NoweSrodkiTransportunested data block for new-vehicle supplies
WyrazenieZgodyvarious consent flags (electronic delivery, etc.)

Adapter rule: each Adnotacje flag is set from input fields on Transaction.country_metadata.poland and validated before XSD serialisation. Several flags are mutually exclusive with VAT rates (e.g. P_18 requires at least one line at oo); we enforce this at prepare-time to avoid round-tripping through a KSeF rejection.

Encoding

  • UTF-8, with <?xml version="1.0" encoding="UTF-8"?> header required.
  • Numeric fields use . as the decimal separator (the schema mandates ISO numeric form, not Polish locale ,).
  • All dates ISO-8601 (YYYY-MM-DD).
  • All datetimes ISO-8601 with millisecond precision and Z (UTC) zone: 2026-04-26T14:30:15.123Z.
  • KSeF accepts max 10 MB per single-invoice POST. Larger invoices must go through the batch endpoint as a ZIP.

RodzajFaktury values (invoice subtype)

CodeMeaning
VATnormal invoice
KORkorygująca — credit note
ZALzaliczkowa — advance payment
KOR_ZALcorrection of an advance
ROZrozliczeniowa — final settlement
KOR_ROZcorrection of a settlement
UPRuproszczona — simplified (≤ PLN 450)
POZpozostała — other (rare; e.g. internal transfer document)

DaneFaKorygowanej is mandatory for any KOR*. It carries the original invoice's numerKSeF (or, if the original was issued before KSeF or outside KSeF, the original P_2 issuer number plus issue date), the reason for correction, and the correction type (in/decreasing / re-issuing).


5. Ordering and sequencing — the dual-numbering model

This is the key contract impact for our CountryAdapter trait.

Two parallel identifier spaces

IdentifierSourceFormatVisibility
Issuer's invoice number (P_2 in FA(2))Issuer (our adapter / the merchant's POS)Free-form string ≤ 50 chars; merchant-controlled. Polish law requires uniqueness within issuer-NIP per calendar year and a monotonic sequence per "billing series".Printed on the invoice; legal "invoice number"; what the buyer sees.
KSeF reference number (numerKSeF)KSeF, on successful acceptance35-char string: {NIP}-{YYYYMMDD}-{12-hex-id}-{2-hex-checksum} (e.g. 1234567890-20260426-A1B2C3D4E5F6-7E)KSeF metadata; printed on a printed-copy "wizualizacja" alongside the issuer number; what KSeF queries on.

A submission referenceNumber also exists, returned synchronously from /Invoice/Send. It is not the same as numerKSeF — it identifies the HTTP submission. Mapping:

1× POST /Invoice/Send → 1× referenceNumber (immediate)
→ 1× numerKSeF (after async validation, retrievable via /Invoice/Status/{referenceNumber})
→ 1× UPO (Urzędowe Poświadczenie Odbioru — official receipt, embedded in the status response)

Mapping onto our fiscal_id field

The Transaction.fiscal_id field in our core model is the canonical adapter-assigned identifier. For Poland we have a choice:

StrategyProsCons
fiscal_id = numerKSeF (post-acceptance)Matches the upstream canonical reference. Stable. Searchable on KSeF.Not assigned at prepare() time. We'd have to populate it later, via a submit() follow-up or via the UPO retrieval. Forces a two-phase write to the transaction.
fiscal_id = "{NIP}:{P_2}" (assigned at prepare())Available immediately, deterministic, idempotent over retries.Not the upstream canonical — a separate query against KSeF needed to find the numerKSeF.
fiscal_id = "{NIP}:{P_2}" and fiscal_id_upstream = numerKSeF (a new field)Best of both.Adapter trait change — needs a fiscal_id_upstream: Option<String>.

Recommendation (open question for fi-fjm validation bead): adopt option 3. Add fiscal_id_upstream: Option<String> to the CountryAdapter contract; populate at submit time (or via reconcile()), keep fiscal_id as the merchant-owned identifier minted at prepare time. This generalises to any adapter where the upstream system mints a separate ID (KSeF, Italy SDI's IdentificativoSdI, Hungary NAV's transactionId — though for Italy/Hungary we currently overload fiscal_id).

Idempotency on the wire

  • KSeF rejects duplicate (NIP, P_2, year) with processingCode 21XXX business-validation error. The original submission remains canonical.
  • Network retries on /Invoice/Send are safe: KSeF deduplicates by the hash of the inner FA(2) XML (a 6-hour window). A retransmitted identical body returns the original referenceNumber.
  • referenceNumber is unique per submission per session; sessions die after 2 h, so for genuinely long retries we re-open a session and resubmit — KSeF's content-hash dedup catches it.

Per-invoice retries

  • Adapter rule: we mint P_2 deterministically at prepare() from Transaction.id (or merchant-supplied document number) and never change it on retry.
  • If KSeF rejects with numerFaktury_NIE_UNIKALNY (already issued), the adapter calls /online/Query/Invoice/Sync filtered on (NIP, P_2) to retrieve the prior numerKSeF, treats the prior submission as canonical, and updates the transaction record.

6. Submission window

ModeWindowNotes
Online (default)Real-time, "at issuance" — POST the FA(2) within the request-response cycle of the POS transaction.Typical p95 latency to KSeF acceptance: 0.5–2 s. KSeF returns the referenceNumber synchronously.
Tryb offline24 (planned offline)Issuer flags the invoice as offline-issued (Adnotacje/TrybOfflineEnable). Must POST to KSeF within 24 hours (was extended to 48 h then back to 24 h in the 2025-11 amending Act — confirm at integration time).Used when the issuer pre-stamps the offline mode for known-degraded sessions.
Tryb awaryjny (KSeF outage)If MF declares an outage on gov.pl/web/kas/komunikaty, issuers must POST within 7 calendar days of the outage end.Triggered by an MF announcement, not by individual client errors.

Our adapter targets the "online, at issuance" regime. SubmissionWindow value: Immediate (same as Spain Verifactu and Hungary NAV). Practical target: submit inside the POS transaction cycle, with an async fallback queue if KSeF is slow (>2 s) or unreachable. On 5xx / network errors we retry up to 3 times (exponential backoff), then mark the transaction OFFLINE_PENDING and resubmit on the next reconciliation pass within the 24 h window.

If the customer NIP is not registered in KSeF (this can happen for foreign customers without PL VAT-EU number, or for unusual edge cases), the invoice is still accepted by KSeF — KSeF stores it in the issuer's "sent" tray, but the recipient never sees it through KSeF. Out-of-band delivery (email PDF + visualisation) is then required. This is a data-policy decision for the merchant, not a KSeF rule — our adapter flags recipientNotInKSeF based on the buyer's NIP/country and surfaces to the merchant.


7. Sandbox access

Test environment (ksef-test.mf.gov.pl)

Self-service, no real KYC, free.

  1. Go to https://ksef-test.mf.gov.pl/web/login.
  2. Choose "Zaloguj jako" → "Token autoryzacyjny" with a synthetic NIP. Test NIPs follow the Polish NIP checksum rule (10 digits, last is the check digit modulo 11). MF publishes test NIPs in the API documentation PDF (e.g. 1111111111, 2222222222 — check the docs page for the current list; they are environment-resettable).
  3. From the portal, navigate "Tokeny" → "Wygeneruj token". Pick scopes: InvoiceWrite, InvoiceRead. Optional: CredentialsManage if you plan to test downstream-token issuance.
  4. Capture the 40+ character token and the MF public key for the test environment (ksef-test.mf.gov.pl/api/online/Common/PublicKey).

Optional, for InitSigned testing: test certificates are not provided by MF. Use a developer cert from a QTSP that supports a sandbox issuance (SimplySign offers a test certificate on request) or a self-issued cert if the InitSigned endpoint accepts it for testing — MF actually validates trust in production, but the test environment may be lenient. Confirm at integration time.

Demo environment (ksef-demo.mf.gov.pl)

Real NIP / real qualified signature, but no fiscal effect — invoices are not actually filed against the taxpayer's VAT account. Used for production-mirror validation by large taxpayers before the 2026-02 cutover. Onboarding requires the same web-portal login as production (qualified signature or Profil Zaufany). MF prefers that mid-size implementations stay on ksef-test; demo is positioned as a "dress rehearsal" with a 2–4 week onboarding window.

Timeline

  • Test — fully self-service, same-day sandbox onboarding once you have any NIP-shaped string.
  • Demo2–4 weeks practically, dominated by the merchant getting a qualified signature or PZ login, and by MF's internal review for "first use" of demo by an integrator.
  • Production — gated only on the merchant having a working authorization token or qualified cert; the API itself does not certify the integrator (as Spain TicketBAI does) or whitelist softwareIds (as Hungary does).

What we'll need

  • Test environment: NIP, token, MF test public key, env values.
  • Demo environment: NIP, token (or signing cert reference), MF demo public key, env values. Per-customer.
  • An .env template in the Poland adapter: PL_KSEF_NIP, PL_KSEF_AUTH_METHOD, PL_KSEF_TOKEN (encrypted), PL_KSEF_SIGNING_KEY_REF (KMS / QTSP ref), PL_KSEF_BASE_URL, PL_KSEF_MF_PUBKEY_PATH.

8. Error model

KSeF errors arrive in three layers: HTTP transport, KSeF response envelope, and async per-invoice business validation (UPO retrieval).

HTTP transport

ClassExamplesAdapter response
2xx200 OK, 202 AcceptedContinue.
4xx (other than 401/403)400 Bad Request, 404 Not FoundInspect response body for <exception>; treat as bug-in-our-code.
401 / 403session token invalid / expiredRe-init session, retry with same body; max 1 re-init per logical attempt.
5xx500, 502, 503Exponential backoff (1s, 4s, 16s), then mark offline-pending.
Networktimeout, DNS, TLSRetry up to 3 times with the same body — KSeF dedupes on content-hash.

KSeF response envelope (synchronous)

Every API endpoint returns either a typed success body or an <exception> envelope (the common error shape):

<exception>
<serviceCtx>KSeF</serviceCtx>
<serviceCode>20231215-EX-XXXXXXXXX</serviceCode>
<serviceName>online.Session.InitToken</serviceName>
<timestamp>2026-04-26T14:00:00.123Z</timestamp>
<referenceNumber>20260426-PR-...</referenceNumber>
<exceptionDetailList>
<exceptionDetail>
<exceptionCode>21001</exceptionCode>
<exceptionDescription>Token autoryzacyjny niepoprawny lub wygasł</exceptionDescription>
</exceptionDetail>
</exceptionDetailList>
</exception>

exceptionCode is a 5-digit integer; the leading two digits map to a broad category:

RangeCategory
20xxxSession lifecycle — challenge expired, session not found, terminate failed.
21xxxAuthentication / authorization — bad token, expired token, missing scope, NIP mismatch.
22xxxEncryption / signature — RSA decryption failed, XAdES validation failed, MF public key mismatch.
23xxxSubmission — schema-validation failure, business-rule failure, content-hash deduplication note.
24xxxQuery — async query expired, batch query too large.
25xxxRate limiting — too many requests, session limit exceeded.
40xxxSystem — internal MF error, service unavailable.

Async per-invoice (UPO) — processingCode

Polled via GET /online/Invoice/Status/{referenceNumber}:

<InvoiceStatus>
<Timestamp>2026-04-26T14:00:05.456Z</Timestamp>
<ReferenceNumber>20260426-SE-…</ReferenceNumber>
<ProcessingCode>200</ProcessingCode>
<ProcessingDescription>Faktura została przyjęta i obliczono numer KSeF</ProcessingDescription>
<ElementReferenceNumber>20260426-IN-…</ElementReferenceNumber>
<InvoiceNumber>1234567890-20260426-A1B2C3D4E5F6-7E</InvoiceNumber> <!-- numerKSeF -->
<AcquisitionTimestamp>2026-04-26T14:00:00.000Z</AcquisitionTimestamp>
<Upo>BASE64(UPO PDF/XML payload)</Upo>
</InvoiceStatus>

ProcessingCode taxonomy:

CodeMeaningAdapter response
100Accepted for processing (synchronous, immediately after Send)Poll Status until terminal.
200Processed and stored. numerKSeF assigned. UPO available.Capture numerKSeF and Upo, transition transaction to ACCEPTED.
300Stored, UPO available — duplicate of 200 in some MF responsesSame as 200.
320Stored offline, awaiting validationContinue polling.
400Rejected by validation. Upo is empty; ProcessingDescription lists faults.Transition transaction to REJECTED. Do not retry as-is. Notify merchant.
401 / 410Rejected: schema (XSD) failureAs 400, with hint that the bug is in the FA(2) builder.
415 / 425Rejected: business-rule failure (duplicate P_2, NIP mismatch, …)As 400.
500Technical error during processing — content not storedRetry from Send with same body (content-hash dedupes).

Key distinctions

  • Schema validation (XSD): synchronous reject at /Invoice/Send if the request is malformed at the envelope level; async reject (processingCode 401/410) if the inner FA(2) is malformed. Adapter must catch both.
  • Business validation: always async. Examples: duplicate P_2, NIP not active in KSeF, invalid VAT rate for the supply date, unbalanced per-rate sums (the P_13_* totals must equal the line-level sums).
  • Acceptance is always async in KSeF — even in the happy path the numerKSeF and UPO arrive after the Send call returns. Code that treats a 200 from /Invoice/Send as "fiscalised" is wrong; it only means "received". The transaction is fiscalised only when processingCode 200 arrives via /Invoice/Status.

Retry policy matrix

ErrorRetry same body?Retry with new P_2?Human ticket?
5xx / network✅ (KSeF content-hash dedupes)
401 / token expired✅ after re-init session
21xxx token rejected✅ config
22xxx encryption / signature✅ keys
23xxx schema✅ adapter bug
processingCode 400 business✅ only after data fix (new P_2 required by Polish law)✅ surface to merchant
processingCode 500 technical✅ (content-hash dedup)

9. Implementation plan

Crate layout

rust/adapters/poland/
├── Cargo.toml # poland-ksef
└── src/
├── lib.rs # PolandAdapter (impl CountryAdapter)
├── config.rs # PolandConfig (nip, auth_method, token_or_signer, …)
├── auth.rs # AuthorisationChallenge + InitToken / InitSigned
├── crypto.rs # AES-256-CBC + RSA-OAEP token wrap, MF pubkey rotation
├── xades.rs # (optional) XAdES-BES enveloped signing for Mode 2/4
├── client.rs # reqwest client, session management, retry, rate-limit
├── fa2.rs # Transaction -> FA(2) XML (quick-xml writer)
├── envelope.rs # InitSession / Invoice/Send envelopes
├── types.rs # Polish enums (RodzajFaktury, P_12, Adnotacje flags)
├── errors.rs # AdapterError variants for transport / 2xxxx / 4xx processing
├── upo.rs # UPO retrieval + PDF visualisation hook
└── tests.rs # XSD-schema conformance against golden fixtures

Trait fit

CountryAdapter::prepare() is the natural split:

  • prepare() — build Faktura XML, validate against FA(2) XSD locally, produce fiscal_id = "{NIP}:{P_2}" (deterministic from Transaction.id), record subtype + adnotacje. Fast, no network.
  • submit() — open or reuse a session (cached up to 2 h) → POST /Invoice/Send → return referenceNumber as the upstream submission reference; does not wait for numerKSeF. A separate reconcile() step polls /Invoice/Status/{referenceNumber} and populates fiscal_id_upstream = numerKSeF plus the UPO PDF.

DeploymentMode::Cloud is the primary target with token mode. InitSigned with a cloud QSCD (SimplySign / mSzafir) is also Cloud-OK. InitSigned with a local key (smart card / USB) requires Embedded deployment. SubmissionWindow::Immediate.

Config shape

pub struct PolandConfig {
/// 10-digit Polish NIP, no separators.
pub nip: String,

/// Selected auth method.
pub auth_method: PolandAuthMethod,

/// true => ksef-test.mf.gov.pl, false => ksef.mf.gov.pl, or DemoUrl.
pub environment: PolandEnvironment, // Test | Demo | Production

/// FA(2) — current. FA(3) — feature-flagged.
pub schema_version: PolandSchemaVersion,

/// Tax-office code (KodUrzedu) for Naglowek; 4 digits.
pub tax_office_code: String,

/// Issuer "place of issue" — required field P_1M; usually company HQ city.
pub place_of_issue: String,

/// Optional GLN for the supplier address (rare).
pub supplier_gln: Option<String>,
}

pub enum PolandAuthMethod {
Token {
/// Encrypted at rest, decrypted just-in-time via KMS.
token_kms_ref: String,
},
XAdESSignature {
/// Reference into KMS / QTSP signing API.
signer_ref: String,
/// PEM of the qualified cert chain (for trust validation by KSeF).
cert_chain_pem: String,
},
XAdESSeal {
signer_ref: String,
cert_chain_pem: String,
},
}

Dependencies (expected)

  • quick-xml — XML build + parse (already in workspace via Spain).
  • aes + cbc + cipher — AES-256-CBC for the token wrap.
  • rsa (with sha2) — RSA-OAEP-SHA256 for the MF-key wrap of the AES key.
  • rand_core — random IV / session key.
  • xmlsec or xades-rs (TBD; lightweight) — XAdES-BES enveloped signing for InitSigned. Open question: pick a library or roll the canonical C14N + signature manually with quick-xml + openssl. We have already done XAdES for Spain TicketBAI; reuse if possible.
  • base64 — already in workspace.
  • flate2 — gzip (only for the batch endpoint; the online endpoint does not compress).
  • reqwest — already in workspace.
  • chrono — already in workspace.

No native fiscal device, no HSM. The MF public key is just a published cert; we hold it as a file in the adapter's data dir.

Open questions (for implementation bead and fi-fjm)

  1. Dual-numbering on fiscal_id. See §5. Recommendation: add fiscal_id_upstream: Option<String> to the CountryAdapter contract. Flag for fi-fjm.
  2. FA(2) → FA(3) migration. When does the adapter switch? Per the amending Act, FA(2) is accepted in production through 2026-09-30 and FA(3) is mandatory from 2026-10-01. We need a feature flag that can flip per-tenant during the overlap window. Build two builders side by side from day 1.
  3. XAdES-BES signing in pure Rust. Spain TicketBAI taught us this is non-trivial. Decision: reuse the Spain TicketBAI XAdES helpers if their shape transfers (TicketBAI uses XAdES-EPES; KSeF uses XAdES-BES — the signed-properties block differs). Otherwise vendor a small in-tree helper.
  4. P_2 minting. Strict format requirement on Polish invoices: sequential, gapless, monotonic per "billing series". Our Transaction.sequence_number is monotonic but per-Location, not per issuer-NIP. Decision: the Poland adapter takes P_2 from Transaction.document_number verbatim (source of truth = POS) and does not mint its own. If absent, derive P_2 = "{prefix}/{year}/{seq}" from a per-NIP counter held in country_state.
  5. MF public key rotation. The cert rotates yearly. Decision: cache the key by file digest with TTL = 30 days; on 22xxx errors, re-fetch and retry once.
  6. Foreign-customer NIP / VAT-UE handling. Podmiot2.NIP is mandatory for PL customers but FA(2) accepts KodUE+NrVatUE (intra-community) and NoIdentyfikatoraPodatkowego (no tax ID — exports). Our Transaction.customer.tax_id is opaque; we route based on customer.country_code.
  7. GTU codes (special-goods/services markers). Required on certain line items (alcohol, fuels, financial services, …). Currently no field on Transaction.line.metadata. Decision: extend LineItem.country_metadata.poland.gtu as Option<Vec<GtuCode>>.
  8. Self-billing (P_17). The buyer issues the invoice on behalf of the seller. Requires prior authorisation in KSeF. Adapter must support both "issuer is supplier" (default) and "issuer is buyer with self-billing rights". We will gate this by a Location-level flag.

What could force us off Cloud-OK?

The only sticking point is InitSigned with a hardware-bound qualified key — that requires the key to be on a smart card / USB token at the issuance moment, which is incompatible with a centralized cloud deployment. For Cloud, we restrict to Token and XAdESSignature/Seal with cloud QSCD. Customers who insist on a local hardware key fall back to the Embedded SDK with a small XAdES-signing daemon — same model we already use for France NF525 local signing.

This is a green light for the wedge thesis: Cloud-OK in the dominant configuration, with a clean fallback for the long tail.

Testing strategy

  • Unit (in-adapter): FA(2) XML builder validated against Schemat_FA(2)_v1-0E.xsd. RSA-OAEP+AES-CBC token wrap covered by KAT vectors against the MF reference values. XAdES-BES signing checked by re-parsing and verifying with xmlsec in the test harness.
  • Integration (against ksef-test.mf.gov.pl): full AuthorisationChallenge → InitToken → Invoice/Send → Invoice/Status round-trip. Gated behind PL_KSEF_TEST_CREDENTIALS env var.
  • Conformance: the testdata/conformance/poland/ksef/golden/* triplets validated as part of cargo test -p poland-ksef. Six fixtures shipping with this bead cover B2B domestic, credit note, advance, multi-VAT, reverse-charge EU, and intra-community supply.

Rollout sequence (for the implementation bead)

  1. Day 1–2: fa2.rs builder + XSD validation against Schemat_FA(2)_v1-0E.xsd. Pass all 6 goldens.
  2. Day 3: crypto.rs — RSA-OAEP + AES-256-CBC wrap, KAT vectors against MF reference; auth.rs AuthorisationChallenge + InitToken.
  3. Day 4: client.rs + envelope — dry POST against ksef-test, confirm processingCode 200, retrieve UPO.
  4. Day 5: Error model + retry, Invoice/Status polling, reconciliation job that populates fiscal_id_upstream.
  5. Day 6: xades.rs — XAdES-BES enveloped signing for InitSigned; gated by feature flag.
  6. Day 7–8: Cross-border + credit-note + multi-VAT edge cases. GTU codes, Adnotacje matrix.
  7. Day 9: Soak test against ksef-test with 100 invoices/min, observe rate limits.
  8. Day 10: Demo environment dry-run with at least one pilot customer.

10. Citations

All facts above are sourced from one of:

  • MF KSeF documentation hubhttps://www.gov.pl/web/kas/krajowy-system-e-faktur. Subpages: "Specyfikacja interfejsów API", "Struktury dokumentów XML", "Komunikaty", "Pytania i odpowiedzi (FAQ)", "Kalendarz wdrożenia".
  • MF API specification PDFKSeF — Dokumentacja API (currently v1.7.x in the test environment; v1.6.x in production), linked from the documentation hub. PDF is refreshed quarterly; cite by version + date.
  • FA(2) XSDSchemat_FA(2)_v1-0E.xsd, namespace http://crd.gov.pl/wzor/2023/06/29/12648/, valid from 2023-09-01, only schema currently accepted by the production endpoint.
  • FA(3) XSD (beta)Schemat_FA(3)_v1-0E.xsd, namespace http://crd.gov.pl/wzor/2025/06/13/13418/. Sandbox only as of 2026-04.
  • VAT ActUstawa z dnia 11 marca 2004 r. o podatku od towarów i usług (consolidated text Dz.U. 2024 poz. … as amended), articles 106nb–106ne.
  • KSeF amending ActUstawa z dnia (sygn. UD125), październik 2025 (the "phased rollout" amendment), Dz.U. 2025 poz. ….
  • MF press release 2025-09-26 — "KSeF — kalendarz wdrożenia", gov.pl/web/finanse.
  • MF public-key endpoint/api/online/Common/PublicKey per environment (test / demo / production).

Per-fixture citations live in each meta.json under testdata/conformance/poland/ksef/golden/.