VulCode
Blog

KSeF API 2.0 krok po kroku: wysyłka faktury z własnego systemu

Od tokenu KSeF do numeru KSeF i UPO: uwierzytelnienie, sesja interaktywna, szyfrowanie RSA-OAEP i AES-256 oraz wysyłka faktury FA(3). Kod w Pythonie uruchomiony na środowisku testowym Ministerstwa Finansów.

VulCodeVulCode13 min czytania
KSeF API 2.0 krok po kroku: wysyłka faktury z własnego systemu - VulCode
Co zbudujeszPython, 8 kroków
  1. Token KSeFKrok 1Sekret z uprawnieniem do wystawiania faktur.
  2. Token dostępuKrok 2Wyzwanie, zaszyfrowany token, odbiór tokenu.
  3. Sesja interaktywnaKrok 3Klucz AES-256 zaszyfrowany kluczem MF.
  4. Faktura FA(3)Krok 4Zaszyfrowany XML z dwoma skrótami SHA-256.
  5. Numer KSeF i UPOWynikDowód, że faktura jest w systemie.

Poradnik w skrócie

Na końcu tego poradnika Twój system sam wysyła fakturę ustrukturyzowaną do Krajowego Systemu e-Faktur i zapisuje jej numer KSeF oraz Urzędowe Poświadczenie Odbioru. Wszystko przez API 2.0, bez ręcznego logowania do Aplikacji Podatnika.

KSeF API 2.0 krok po kroku

Grupa
Faktury i płatności
Poziom
zaawansowany
Czas czytania
około 13 min
Kod
Python

Pliki: ksef.py.

Kroki
8 kroków

W 8 z nich kod albo polecenia do skopiowania.

Sprawdzenie
Środowisko TEST Ministerstwa Finansów

Żądanie przyjęte (202), a celowo fałszywy token odrzucony kodem 450

Dokumentacja
3 oficjalne dokumenty

KSeF API 2.0, dokumentacja OpenAPI (TEST); Dokumentacja integracji KSeF 2.0 (CIRFMF/ksef-docs); KSeF: podstawy prawne i kluczowe terminy

Z naszego kodu
Flaem

frozcore/src/ksef/client.rs

Zanim zaczniesz

Obowiązek wystawiania faktur w KSeF działa od 1 lutego 2026 r. dla firm, których sprzedaż w 2024 r. przekroczyła 200 mln zł, i od 1 kwietnia 2026 r. dla pozostałych. Pracujemy na środowisku testowym, więc nic, co tu wyślesz, nie ma skutków podatkowych.

Co musisz mieć
KSeF API 2.0 krok po kroku
Token KSeF dla środowiska TEST
Token generuje się metodą POST /tokens po jednorazowym uwierzytelnieniu podpisem XAdES. Na środowisku TEST wystarczy samopodpisany certyfikat i losowy NIP. Uprawnienia tokenu ustalasz przy tworzeniu i nie da się ich potem zmienić, więc od razu nadaj InvoiceWrite.
Python 3 z requests i cryptography
Dwie biblioteki: requests do HTTP i cryptography do RSA-OAEP, AES i odczytu certyfikatów X.509. Nic więcej nie jest potrzebne.
Plik faktury FA(3)
XML zgodny ze schematem FA(3), z nagłówkiem kodSystemowy="FA (3)" i wersjaSchemy="1-0E". Na TEST używaj danych zmyślonych: dokumentacja MF zabrania wysyłania tam prawdziwych faktur i danych firm.
Bezpieczne miejsce na token
Token KSeF uwierzytelnia tak samo jak podpis kwalifikowany. Trzymaj go w menedżerze sekretów albo zmiennej środowiskowej, nigdy w repozytorium.

Trzy środowiska KSeF API 2.0

ŚrodowiskoAdres APIDo czego
TESThttps://api-test.ksef.mf.gov.pl/v2Integracja i wersje RC; dozwolone samopodpisane certyfikaty, dane współdzielone między integratorami.
DEMOhttps://api-demo.ksef.mf.gov.pl/v2Końcowa walidacja w konfiguracji takiej jak produkcja.
PRODhttps://api.ksef.mf.gov.pl/v2Faktury o pełnej mocy prawnej.

Krok po kroku

8 kroków. Okno obok pokazuje plik kroku, który właśnie czytasz.

Pobierz klucze publiczne Ministerstwa

Ministerstwo publikuje dwa certyfikaty: jeden do szyfrowania tokenu KSeF (KsefTokenEncryption), drugi do szyfrowania klucza sesji (SymmetricKeyEncryption). Endpoint jest publiczny i nie wymaga logowania. Certyfikat przychodzi jako DER zakodowany w Base64.

Szyfrowanie to zawsze RSA-OAEP z SHA-256 i MGF1 z SHA-256. Z innym skrótem serwer nie odszyfruje danych.

Pobierz wyzwanie

POST /auth/challenge zwraca jednorazowe wyzwanie i znacznik czasu w milisekundach. Wyzwanie jest ważne 10 minut. Do szyfrowania tokenu potrzebujesz pola timestampMs, nie timestamp.

Zaszyfruj token i rozpocznij uwierzytelnienie

Szyfrujesz tekst token|timestampMs kluczem KsefTokenEncryption i wysyłasz go razem z wyzwaniem i identyfikatorem kontekstu. Kontekst to firma, w której imieniu działasz: typ Nip i jej NIP.

Serwer odpowiada 202 i zwraca numer referencyjny operacji oraz token uwierzytelniający. To jeszcze nie jest dostęp do API: tym tokenem tylko sprawdzasz status i odbierasz właściwy token.

Poczekaj na wynik i odbierz token dostępu

GET /auth/{referenceNumber} zwraca status 100 (w toku), 200 (sukces) albo kod błędu. Po 200 wywołujesz POST /auth/token/redeem i dostajesz accessToken oraz refreshToken.

Odbiór działa tylko raz. Gdy accessToken wygaśnie, nie zaczynasz od nowa, tylko wywołujesz POST /auth/token/refresh z refreshToken.

Otwórz sesję interaktywną

Losujesz 32-bajtowy klucz AES i 16-bajtowy wektor IV. Klucz szyfrujesz kluczem SymmetricKeyEncryption, wektor wysyłasz jawnie w Base64. Kod formularza dla faktury FA(3) to systemCode "FA (3)", schemaVersion "1-0E", value "FA".

Serwer odpowiada 201 z numerem sesji i datą ważności. W jednej sesji wysyłasz dowolnie wiele faktur tym samym kluczem.

Zaszyfruj i wyślij fakturę

XML faktury szyfrujesz AES-256-CBC z dopełnieniem PKCS#7. Do żądania dodajesz skrót SHA-256 i rozmiar pliku jawnego oraz skrót i rozmiar pliku zaszyfrowanego, wszystkie skróty w Base64.

Wektor IV nie trafia do treści faktury: podałeś go przy otwieraniu sesji.

Odbierz numer KSeF

Status faktury sprawdzasz GET /sessions/{ref}/invoices/{invoiceRef}. Kody 100 i 150 oznaczają przetwarzanie, 200 sukces z polem ksefNumber. Kod 440 to duplikat: ten sam plik został już przyjęty, a jego numer jest w extensions.originalKsefNumber.

Duplikat traktuj jak sukces. Tak odzyskujesz wysyłkę, która przekroczyła limit czasu po tym, jak KSeF już ją przyjął.

Zamknij sesję i pobierz UPO

POST /sessions/online/{ref}/close zamyka sesję (204). Zbiorcze UPO pojawia się w GET /sessions/{ref} w polu upo.pages z adresami do pobrania. Pojedyncza faktura ma też własne upoDownloadUrl w swoim statusie.

Uwaga
Adresy pobrania UPO mają datę wygaśnięcia (upoDownloadUrlExpirationDate). Pobierz plik od razu i zapisz go przy fakturze.
Okno kodukrok 1 z 8
ksef.pyNowy plik, 21 linii
import base64import requestsfrom cryptography import x509from cryptography.hazmat.primitives import hashesfrom cryptography.hazmat.primitives.asymmetric import padding API = "https://api-test.ksef.mf.gov.pl/v2"  def mf_key(usage: str):    certs = requests.get(f"{API}/security/public-key-certificates", timeout=30).json()    cert = next(c for c in certs if usage in c["usage"])    der = base64.b64decode(cert["certificate"])    return x509.load_der_x509_certificate(der).public_key()  def rsa_oaep(key, data: bytes) -> str:    sealed = key.encrypt(data, padding.OAEP(        mgf=padding.MGF1(algorithm=hashes.SHA256()),        algorithm=hashes.SHA256(), label=None))    return base64.b64encode(sealed).decode()
ksef.pyZmiana w pliku: 3 linie
challenge = requests.post(f"{API}/auth/challenge", timeout=30).json()# {"challenge": "20261006-CR-...", "timestamp": "2026-10-06T12:33:47+00:00",#  "timestampMs": 1791290027137, "clientIp": "..."}
ksef.pyZmiana w pliku: 12 linii
import os KSEF_TOKEN = os.environ["KSEF_TOKEN"]NIP = os.environ["KSEF_NIP"] plain = f"{KSEF_TOKEN}|{challenge['timestampMs']}".encode()init = requests.post(f"{API}/auth/ksef-token", json={    "challenge": challenge["challenge"],    "contextIdentifier": {"type": "Nip", "value": NIP},    "encryptedToken": rsa_oaep(mf_key("KsefTokenEncryption"), plain),}, timeout=30)init.raise_for_status()  # 202 Acceptedoperation = init.json()auth = {"Authorization": f"Bearer {operation['authenticationToken']['token']}"}
ksef.pyZmiana w pliku: 12 linii
import time while True:    status = requests.get(f"{API}/auth/{operation['referenceNumber']}",                          headers=auth, timeout=30).json()["status"]    if status["code"] == 200:        break    if status["code"] != 100:        raise RuntimeError(status)  # 415, 425, 450...    time.sleep(1) tokens = requests.post(f"{API}/auth/token/redeem", headers=auth, timeout=30).json()access = {"Authorization": f"Bearer {tokens['accessToken']['token']}"}refresh_token = tokens["refreshToken"]["token"]
ksef.pyZmiana w pliku: 9 linii
key, iv = os.urandom(32), os.urandom(16)  # AES-256 key and IV for this session opened = requests.post(f"{API}/sessions/online", headers=access, json={    "formCode": {"systemCode": "FA (3)", "schemaVersion": "1-0E", "value": "FA"},    "encryption": {        "encryptedSymmetricKey": rsa_oaep(mf_key("SymmetricKeyEncryption"), key),        "initializationVector": base64.b64encode(iv).decode(),    },}, timeout=30)opened.raise_for_status()  # 201 Createdsession_ref = opened.json()["referenceNumber"]
ksef.pyZmiana w pliku: 21 linii
import hashlibfrom cryptography.hazmat.primitives import padding as sym_paddingfrom cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes  def aes_cbc(data: bytes) -> bytes:    padder = sym_padding.PKCS7(128).padder()    padded = padder.update(data) + padder.finalize()    encryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor()    return encryptor.update(padded) + encryptor.finalize()  def sha256_b64(data: bytes) -> str:    return base64.b64encode(hashlib.sha256(data).digest()).decode()  xml = open("faktura.xml", "rb").read()encrypted = aes_cbc(xml)sent = requests.post(f"{API}/sessions/online/{session_ref}/invoices", headers=access, json={    "invoiceHash": sha256_b64(xml),    "invoiceSize": len(xml),    "encryptedInvoiceHash": sha256_b64(encrypted),    "encryptedInvoiceSize": len(encrypted),    "encryptedInvoiceContent": base64.b64encode(encrypted).decode(),}, timeout=60)sent.raise_for_status()  # 202 Acceptedinvoice_ref = sent.json()["referenceNumber"]
ksef.pyZmiana w pliku: 10 linii
while True:    invoice = requests.get(f"{API}/sessions/{session_ref}/invoices/{invoice_ref}",                           headers=access, timeout=30).json()    code = invoice["status"]["code"]    if code == 200:        print("Numer KSeF:", invoice["ksefNumber"])        break    if code == 440:  # duplicate: the same file was accepted before        print("Już przyjęta:", invoice["status"]["extensions"]["originalKsefNumber"])        break    if code not in (100, 150):        raise RuntimeError(invoice["status"])  # 430, 435, 450...    time.sleep(2)
ksef.pyZmiana w pliku: 9 linii
requests.post(f"{API}/sessions/online/{session_ref}/close",              headers=access, timeout=30).raise_for_status()  # 204 while True:    session = requests.get(f"{API}/sessions/{session_ref}", headers=access, timeout=30).json()    pages = (session.get("upo") or {}).get("pages") or []    if pages:        for page in pages:            upo = requests.get(page["downloadUrl"], timeout=60)            open(f"upo-{page['referenceNumber']}.xml", "wb").write(upo.content)        break    time.sleep(5)

Sprawdź, czy działa

Uruchomiliśmy kroki 1 do 4 na środowisku TEST 6 października 2026 r. z celowo fałszywym tokenem. Serwer przyjął żądanie (202), a status operacji wrócił z kodem 450 i opisem "Nieprawidłowe kodowanie tokenu". To dowód, że szyfrowanie, wyzwanie i format żądania są poprawne: z prawdziwym tokenem w tym miejscu pojawia się 200.

terminal6 października 2026Środowisko TEST Ministerstwa Finansów
$ python ksef.py
challenge 20261006-CR-2B26197000-FA464AB39C-AE
timestampMs 1791290044823
init 202
referenceNumber 20261006-AU-2B262EC000-FA4D7212F7-50
status 450
Uwierzytelnianie zakończone niepowodzeniem
z powodu błędnego tokenu
details: Nieprawidłowe kodowanie tokenu.
Żądanie przyjęte (202), a celowo fałszywy token odrzucony kodem 450
Co znaczą linie wyniku
  1. linie 2-3Wyzwanie i znacznik czasu

    POST /auth/challenge zwraca jednorazowe wyzwanie i timestampMs. To pole, a nie timestamp, trafia do szyfrowania tokenu.

  2. linie 4-5Żądanie przyjęte

    Serwer odpowiedział 202 i numerem referencyjnym operacji: zaszyfrowany token, wyzwanie i kontekst mają poprawny format.

  3. linie 6-9Odmowa dla fałszywego tokenu

    Kod 450 to oczekiwany wynik dla celowo fałszywego tokenu. Z prawdziwym tokenem w tym miejscu pojawia się 200.

Po czym poznasz, że działa u Ciebie

  • Status uwierzytelnienia wraca jako 200, a redeem zwraca accessToken i refreshToken.
  • Otwarcie sesji zwraca 201 i numer sesji.
  • Status faktury przechodzi przez 100 lub 150 do 200 i zawiera ksefNumber.
  • Po zamknięciu sesji w upo.pages pojawia się adres zbiorczego UPO.

Najczęstsze błędy

Ta sama droga co w "Co zbudujesz" na górze strony, z miejscami, w których praca staje najczęściej. Przy każdym błędzie: objaw, przyczyna i co zrobić.

Gdzie staje i dlaczego

Token KSeF

Krok 1

Sekret z uprawnieniem do wystawiania faktur.

  • Zatrzymuje się: Status 415, brak przypisanych uprawnień

    Token nie ma uprawnienia InvoiceWrite. Co zrobić: Wygeneruj nowy token z InvoiceWrite. Uprawnień istniejącego tokenu nie da się zmienić.

Token dostępu

Krok 2

Wyzwanie, zaszyfrowany token, odbiór tokenu.

  • Zatrzymuje się: Status 450, "Nieprawidłowy czas tokena" albo "Nieprawidłowe wyzwanie"

    Do szyfrowania użyto pola timestamp zamiast timestampMs albo wyzwanie jest starsze niż 10 minut. Co zrobić: Pobierz nowe wyzwanie tuż przed szyfrowaniem i użyj timestampMs.

  • Zatrzymuje się: Drugi redeem kończy się błędem

    Token uwierzytelniający można wymienić na token dostępu tylko raz. Co zrobić: Przechowuj refreshToken i odświeżaj dostęp przez /auth/token/refresh.

Sesja interaktywna

Krok 3

Klucz AES-256 zaszyfrowany kluczem MF.

    Faktura FA(3)

    Krok 4

    Zaszyfrowany XML z dwoma skrótami SHA-256.

    • Zatrzymuje się: Faktura ze statusem 435, błąd odszyfrowania pliku

      Inny klucz lub IV niż przy otwieraniu sesji, brak dopełnienia PKCS#7 albo IV doklejony do treści. Co zrobić: Szyfruj kluczem i IV z tej samej sesji i wysyłaj sam szyfrogram.

    • Zatrzymuje się: Faktura ze statusem 430 albo 450

      XML nie przechodzi schematu FA(3) albo jego semantyki, na przykład złe pole lub kolejność elementów. Co zrobić: Zwaliduj plik lokalnie schematem XSD FA(3) przed wysyłką; elementy są w xsd:sequence, więc kolejność ma znaczenie.

    Numer KSeF i UPO

    Wynik

    Dowód, że faktura jest w systemie.

      Jak to robimy u siebie

      Ten sam temat w kodzie, który utrzymujemy: Flaem.

      Panel firmy z kolejką faktur do KSeF i przebiegiem jednej wysyłki

      Jak wysyła to Flaem

      W Flaem ten sam przebieg napisaliśmy w Rust, w module frozcore/src/ksef. Najważniejsza decyzja nie dotyczy szyfrowania: wystawienie faktury nigdy nie czeka na KSeF. Dokument zapisuje się razem z wierszem w kolejce ksef_submissions, a wysyłką zajmuje się osobny worker.

      Jedna faktura trafia do KSeF raz. Pilnują tego trzy warstwy: klucz główny kolejki na identyfikatorze dokumentu, blokada wiersza przy pobieraniu przez workera i deduplikacja KSeF po skrócie pliku, czyli status 440, który worker zapisuje jako sukces.

      frozcore/src/ksef/client.rsrust, 20 linii
      pub async fn open_session(&self, access: &str) -> Result<Session, ClientError> {    let key = self.public_key("SymmetricKeyEncryption").await?;    let mut symmetric = [0u8; 32];    let mut iv = [0u8; 16];    rand_core::OsRng.fill_bytes(&mut symmetric);    rand_core::OsRng.fill_bytes(&mut iv);     let wrapped = encrypt_rsa(&key.key, &symmetric)?;    let opened: Opened = self        .post_json("/sessions/online", Some(access), Some(serde_json::json!({            "formCode": { "systemCode": "FA (3)", "schemaVersion": "1-0E", "value": "FA" },            "encryption": {                "encryptedSymmetricKey": BASE64.encode(&wrapped),                "initializationVector": BASE64.encode(iv),                "publicKeyId": key.id,            },        })))        .await?;    Ok(Session { reference_number: opened.reference_number, symmetric, iv })}

      Fragment klienta KSeF z Flaem, skrócony o obsługę błędów.

      Powiązane strony i źródła

      Usługa, narzędzia i hasła, do których odsyła ten poradnik, i kolejne poradniki.

      Oficjalna dokumentacja

      Wolisz, żeby zrobił to ktoś, kto już to robił?

      Opisz swój system. Odpiszemy w godzinę z planem integracji i wyceną.

      Pytania