---
title: "KSeF PDF API: z XML-a faktury do PDF z kodem QR"
description: "Techniczny opis serwisu wizualizacji faktur KSeF: vendoring biblioteki MF, budowa linku weryfikacyjnego KOD I, przypadek etykiety OFFLINE i hardening kontenera."
author: Maciej Olszewski
date: 2026-09-16
tags:
  - ksef
  - nodejs
  - fastify
  - n8n
url: "https://inprojects.ai/blog/ksef-pdf-api-technikalia/"
language: pl
---

# KSeF PDF API: z XML-a faktury do PDF z kodem QR

> Techniczny opis serwisu wizualizacji faktur KSeF: vendoring biblioteki MF, budowa linku weryfikacyjnego KOD I, przypadek etykiety OFFLINE i hardening kontenera.

> Część przewodnika [KSeF od środka](https://inprojects.ai/blog/ksef-od-srodka-crypto-worker-i-pdf-api/). Tu rozbieramy `ksef-pdf-api`: skąd wziął się kod renderujący, jak liczony jest kod QR weryfikacyjny i dlaczego serwis odmawia drukowania etykiety OFFLINE.

## Problem: ministerstwo nie daje wizualizacji serwerowej

Faktura KSeF to XML zgodny ze schematem FA. Ministerstwo Finansów nie publikuje XSLT ani oficjalnego szablonu do renderowania po stronie serwera. Jedyne, co istnieje, to referencyjna biblioteka TypeScript `CIRFMF/ksef-pdf-generator`. Problem w tym, że jest to biblioteka frontendowa: API oparte o przeglądarkowe `File`, `FileReader` i `Blob`, build przez Vite i brak publikacji w npm. Na serwerze nie użyjesz jej wprost.

`ksef-pdf-api` rozwiązuje to minimalną adaptacją zamiast przepisywania: bierze z repozytorium MF wyłącznie warstwę renderowania i wystawia ją jako bezstanowe HTTP API w kontenerze. Bez persystencji i bez kontaktu z API KSeF: request z XML-em wchodzi, PDF wychodzi. Konsumentem jest n8n w zamkniętej sieci Docker.

## Vendoring biblioteki MF

Około 140 plików kodu ministerstwa (generatory FA(1), FA(2), FA(3), FA_RR i UPO) znajduje się w `src/pdf/vendor/` i jest traktowane jak zewnętrzna zależność: wszystko oznaczone `@ts-nocheck`, wyłączone z typechecku i pomiaru pokrycia, z pinowanym commitem upstreamu i udokumentowaną procedurą aktualizacji w `vendor/README.md`.

Adaptacja do Node dotknęła dokładnie trzech plików:

- `XML-parser.ts`: wejście zmienione z przeglądarkowego `File` na string, parsowanie synchroniczne (`xml-js` w trybie compact, z obcinaniem prefiksów namespace'ów),
- `generate-invoice.ts`: przyjmuje już sparsowany obiekt zamiast surowego XML-a (dzięki temu metadane typu numer faktury wyciąga się bez podwójnego parsowania), zwraca `Buffer` i rzuca `UnsupportedSchemaError` tam, gdzie upstream po cichu zwracał pusty PDF,
- `UPO-generator.ts`: analogiczne przejście na API Node.

Wybór generatora to switch po polu `Naglowek/KodFormularza/@kodSystemowy` z XML-a: `FA (1)`, `FA (2)`, `FA (3)`, `FA_RR (1)`. Nieznana wartość kończy się odpowiedzią 422, łącznie ze złośliwym przypadkiem, w którym ktoś wyśle `FA(2)` bez spacji. Sam rendering robi `pdfmake`; dla FA(3) obsługiwane są między innymi faktury korygujące (typ KOR przełącza tabelę pozycji w tryb rabatowy), zamówienia, procedura marży i podsumowania stawek VAT. Układ sekcji odpowiada wzorowi ministerstwa, bo to dosłownie jego kod.

## API

Serwis wystawia dwa endpointy. `GET /health` to liveness probe bez autoryzacji (używa go HEALTHCHECK Dockera). Całą robotę wykonuje `POST /convert`, autoryzowany nagłówkiem `X-API-Key`:

| Aspekt | Szczegóły |
|---|---|
| Wejście, tryb 1 | surowy XML w ciele, `Content-Type: application/xml`, `text/xml` lub `application/octet-stream` |
| Wejście, tryb 2 | `multipart/form-data` z pojedynczym polem `file` |
| Wyjście | `200`, `application/pdf`, `Content-Disposition: attachment; filename="<numer faktury>.pdf"`, `Cache-Control: no-store` |
| Limit ciała | 5 MiB (konfigurowalne przez `MAX_BODY_SIZE`) |

Opcje przekazuje się query paramem albo nagłówkiem (nagłówek wygrywa), bo ciało to surowy XML i nie ma gdzie włożyć koperty JSON:

- `?qr=false` / `X-KSeF-QR: false` wyłącza kod QR (domyślnie włączony),
- `?env=test` / `X-KSeF-Env: test` kieruje link QR na środowisko testowe KSeF,
- `?ksefNumber=...` / `X-KSeF-Number: ...` podaje numer KSeF do wydrukowania na fakturze.

Błędy mają stabilny słownik kodów w `src/errors.ts` (`INVALID_XML`, `UNSUPPORTED_SCHEMA`, `PAYLOAD_TOO_LARGE`, `MISSING_API_KEY` itd.) i format `{ "error": "...", "code": "..." }`. Odpowiedź 500 zwraca wyłącznie komunikat generyczny. Stack trace idzie do logów i jest test pilnujący, żeby nigdy nie wyciekł do klienta.

Nazwa pliku w `Content-Disposition` pochodzi z pola `Fa/P_2` faktury, więc przechodzi przez sanityzację (usunięcie znaków niebezpiecznych dla nagłówka HTTP, limit 120 znaków, fallback `invoice.pdf`). Bez tego numer faktury byłby wektorem header injection.

## Cykl życia żądania

```mermaid
graph TD
    A["POST /convert"] --> B["hook auth:<br/>timingSafeEqual na X-API-Key"]
    B --> C["odczyt ciała:<br/>raw XML albo multipart"]
    C --> D["parseXML + walidacja<br/>root &lt;Faktura&gt;, kodSystemowy"]
    D --> E["buildKodILink:<br/>NIP + data + SHA-256 XML-a"]
    E --> F["generator FA1/FA2/FA3/FA_RR<br/>(pdfmake, kod MF)"]
    F --> G["200: PDF, Content-Disposition,<br/>Cache-Control: no-store"]
    D -->|błąd| H["globalny error handler:<br/>400/415/422 + kod błędu"]
```

Stack: Node 22, TypeScript uruchamiany przez `tsx` bez kroku kompilacji, Fastify 5, walidacja konfiguracji zodem (błędny env = proces nie wstaje), logi strukturalne pino. Etykiety na fakturze są dwujęzyczne (polski/angielski) przez i18next, rozgrzewany przy starcie, żeby pierwsze żądanie nie płaciło za inicjalizację.

## Kod QR: KOD I liczony w całości z XML-a

Specyfikacja KSeF przewiduje dwa kody QR na wizualizacji. **KOD I** to link weryfikacyjny: po zeskanowaniu strona ministerstwa potwierdza, że faktura istnieje w systemie i ma dokładnie tę treść. **KOD II** to kod certyfikatu dla faktur wystawionych w trybie offline; wymaga podpisu kluczem prywatnym certyfikatu KSeF typu 2, więc bezstanowe API świadomie go nie generuje.

KOD I budowany jest w `src/pdf/ksef-qr.ts` bez żadnych danych spoza faktury:

```
https://qr.ksef.mf.gov.pl/invoice/{NIP}/{DD-MM-RRRR}/{SHA256(xml) w Base64URL}
```

NIP sprzedawcy pochodzi z `Podmiot1/DaneIdentyfikacyjne/NIP` (walidowany regexem), data wystawienia z pola `Fa/P_1` przeformatowana z ISO, a hash to SHA-256 **surowych bajtów** przysłanego XML-a, zakodowany Base64URL. Funkcja hashująca ma test na wektor kontrolny porównany z OpenSSL, więc regresja formatu nie przejdzie niezauważona. Gdy w XML-u brakuje NIP-u albo daty, link nie powstaje i kod jest po prostu pomijany. Dotyczy to na przykład FA_RR, gdzie podmiot z `Podmiot1` nie jest właściwym wystawcą.

## Etykieta OFFLINE, czyli najciekawszy przypadek brzegowy

W kodzie ministerstwa pod kodem QR drukowany jest podpis: numer KSeF, jeśli jest znany, albo słowo „OFFLINE", jeśli faktura ma KOD II. Kusząca droga na skróty brzmi: „nie znam numeru KSeF, więc piszę OFFLINE". Serwis robi dokładnie odwrotnie i jest to udokumentowana decyzja (komentarz w `src/pdf/generate.ts`, commit `6aa0172`): w specyfikacji MF „OFFLINE" oznacza fakturę faktycznie wystawioną w trybie offline, a tego nie da się wywnioskować z samego braku numeru. Drukowanie OFFLINE „na wszelki wypadek" wprowadzałoby odbiorcę w błąd co do statusu prawnego dokumentu. Numer KSeF pojawia się więc pod kodem tylko wtedy, gdy caller poda go jawnie w `X-KSeF-Number`; w przeciwnym razie pod kodem nie ma nic.

## Hardening kontenera

Serwis przetwarza dane finansowe, więc kontener produkcyjny jest dokręcony mocniej niż przeciętny:

- multi-stage build na `node:22-alpine`, proces jako user `node` (non-root), `tini` jako PID 1 dla poprawnej obsługi sygnałów,
- w `docker-compose.prod.yml`: `read_only: true` z dwoma małymi tmpfs, `cap_drop: ALL`, `no-new-privileges`, bind wyłącznie na `127.0.0.1`, sieć zewnętrzna współdzielona z n8n,
- limity zasobów (1 CPU / 256 MB), które przy okazji wcześnie wyłapałyby wyciek pamięci, rotacja logów i `stop_grace_period` zsynchronizowany z graceful shutdownem Fastify,
- healthcheck czystym `node -e`, bez instalowania curla do obrazu,
- `Cache-Control: no-store` na każdym PDF, bo żaden pośrednik nie ma prawa cache'ować faktur.

Do tego timing-safe auth w `src/plugins/auth.ts`: klucze API są pre-buforowane, a porównanie robi `crypto.timingSafeEqual` z wcześniejszym sprawdzeniem długości, bo `timingSafeEqual` na buforach różnej długości rzuca wyjątek, którym dałoby się wywracać proces. Test pokrywa również atak kluczem o wspólnym prefiksie.

## Testy i CI

Vitest, 7 plików spec, ~54 testy, w tym e2e na realnej fakturze FA(3) z produkcji (fixtury z systemu Sellasist). Pokrycie własnego kodu: 99,5% linii przy progu 80% na wszystkich osiach. Vendor jest z pomiaru wyłączony, bo testuje go upstream. CI podwójne, jak w całym ekosystemie: GitHub Actions (typecheck, lint, testy, budowa obrazu do GHCR z tagowaniem SemVer) i GitLab CI (testy + budowa obrazu do rejestru GitLab, na MR tylko przy zmianach w plikach istotnych dla obrazu). Produkcja pinuje obraz do konkretnej wersji zamiast `:latest`.
