· wystaw
KSeF w praktyce: offline24 i dosyłanie faktur bez utraty żadnej
KSeF jest obowiązkowy od 1 lutego 2026 dla największych firm i od 1 kwietnia 2026 dla pozostałych (harmonogram na ksef.podatki.gov.pl). Przez te miesiące najwięcej czasu spędziłem nie na samym wysyłaniu faktur, tylko na tym, co się dzieje, kiedy wysyłka się nie udaje. Ten tekst opisuje offline24 od strony kodu: jak faktura powstaje bez KSeF, jak ją potem dosyłam i jak pilnuję, żeby po drodze nic nie zginęło.
Problem
Wysłanie faktury do KSeF to nie jedno żądanie HTTP. Trzeba otworzyć sesję, wysłać zaszyfrowany plik, odpytywać o status, pobrać UPO i zamknąć sesję. Każdy krok może skończyć się błędem 5xx, timeoutem albo limitem zapytań. Najgorszy jest timeout przy samej wysyłce. Nie wiesz wtedy, czy faktura dotarła do KSeF, czy nie.
Z drugiej strony jest klient, który chce PDF teraz, a nie wtedy, kiedy KSeF wstanie. Od 1 stycznia 2027 za fakturę offline niedosłaną w terminie grozi kara do 100% VAT z tej faktury (art. 106ni ust. 1 pkt 3 ustawy o VAT, tekst jednolity Dz.U. 2026 poz. 1263). Zostały dwa miesiące, więc zgubiona faktura przestaje być tylko problemem w statystykach.
Miałem więc dwa wymagania. Klient dostaje ważną fakturę od razu. Każda faktura trafia do KSeF dokładnie raz i w terminie albo właściciel konta dowiaduje się o problemie, zanim zrobi się kosztowny.
Jak działa offline24
Offline24 opisuje art. 106nda ustawy o VAT. Podatnik może wystawić fakturę zgodną ze wzorem FA(3) poza KSeF i jest obowiązany „niezwłocznie, nie później niż w następnym dniu roboczym po dniu jej wystawienia” przesłać ją do KSeF (ust. 2). Ustawa nie wymaga powodu. W dokumentacji KSeF 2.0 MF pisze wprost, że offline24 nie ma ograniczeń i zależy od decyzji podatnika.
Kilka szczegółów z ustawy, które mają wpływ na kod:
- Data wystawienia to data z pola P_1, a nie data wysyłki (ust. 10).
- Ustrukturyzowana faktura wysłana do KSeF później niż data z P_1 też jest traktowana jak faktura offline (ust. 16). Faktura z wczorajszą datą wysłana dziś to w praktyce offline24, czy tego chcesz, czy nie.
- Jeśli faktura trafia do nabywcy poza KSeF, np. mailem jako PDF, musi mieć dwa kody (ust. 6). Do drugiego potrzebny jest certyfikat pobrany z KSeF (ust. 7).
- Korektę do faktury offline wystawia się dopiero, gdy oryginał dostanie numer KSeF (ust. 12).
Obok offline24 są jeszcze tryby ogłaszane przez MF: niedostępność (dosłanie do końca następnego dnia roboczego po jej zakończeniu) i awaria (7 dni roboczych od końca awarii, art. 106nf ust. 4). Jeśli awaria zostanie ogłoszona, zanim dośle się fakturę offline24, termin liczy się według zasad trybu awaryjnego (art. 106nha).
Kody QR opisuje dokumentacja kodów QR. KOD I to link z NIP-em sprzedawcy, datą wystawienia i skrótem SHA-256 pliku XML w base64url. Pod nim drukuje się numer KSeF albo, dopóki go nie ma, napis OFFLINE. KOD II występuje tylko na fakturach offline. To link z identyfikatorem kontekstu, NIP-em, numerem seryjnym certyfikatu, skrótem faktury i podpisem tej ścieżki, z napisem CERTYFIKAT. Podpis musi być złożony certyfikatem KSeF typu Offline, bo certyfikatu typu Authentication do tego użyć nie można.
Jak to zrobiłem w Wystaw
Statusy jako maszyna stanów
W Wystaw faktura ma jawny cykl życia, a każde przejście jest sprawdzane w jednym miejscu:
const transitions: Record<Status, Status[]> = {
draft: ['issued'],
issued: ['sending', 'offline', 'skipped_ksef'],
sending: ['accepted', 'rejected', 'offline'],
offline: ['sending', 'accepted', 'rejected'],
accepted: [], rejected: [], skipped_ksef: [],
};
offline nie jest stanem końcowym. To faktura, która jest ważna i ma numer, ale czeka na KSeF. Z offline można wrócić tylko do sending albo przejść prosto do stanu końcowego.
Wysyłka i ponawianie
Po wystawieniu faktura trafia do kolejki zadań na Postgresie (pg-boss). Klucz zadania jest unikalny dla faktury, więc dwa równoległe zadania wysyłki tej samej faktury nie mogą powstać. Zadanie ma 5 powtórzeń z wykładniczym odstępem od 30 sekund: 30, 60, 120, 240 i 480 sekund. Razem około 15 minut.
Ważniejsze od liczby powtórzeń jest to, które błędy w ogóle się powtarza:
const retryable = new Set(['unavailable', 'timeout', 'rate_limited']);
const offlineCandidate = (k: Kind) => k === 'unavailable' || k === 'timeout';
Odrzucenie przez KSeF albo błąd walidacji XSD kończy się od razu statusem rejected, bez ponawiania. Problem z uprawnieniami cofa fakturę do issued i czeka na użytkownika. Przekroczenie limitu zapytań jest powtarzane, ale nie przełącza faktury w offline, bo KSeF działa, tylko każe zwolnić.
Wejście w offline24
Kiedy ostatnia próba kończy się błędem typu „KSeF niedostępny” albo „timeout”, w tej samej transakcji robię kilka rzeczy:
async function onRetriesExhausted(ev) {
if (!ev.offlineCandidate) return; // zostaje w `sending`, cron spróbuje ponownie
await db.tx(async (tx) => {
const inv = await tx.lockInvoice(ev.invoiceId);
if (inv.status !== 'sending') return;
if (await tx.lastAttemptReachedKsef(inv)) return; // to nie jest offline, tylko czekamy na status
await tx.setStatus(inv, 'offline', { offlineMode: true });
await tx.audit(inv, 'offline24');
});
await queue.add('render-pdf', { reason: 'offline', thenMail: 'customer' });
}
Najważniejsze jest sprawdzenie, czy poprzednia próba nie dostała już od KSeF numeru referencyjnego. Jeśli dostała, faktura jest w KSeF i nie wolno udawać, że to faktura offline.
Następnie generuję PDF z KOD I (etykieta OFFLINE) i KOD II (CERTYFIKAT) i od razu wysyłam go klientowi. W interfejsie użytkownik widzi na osi statusu „W kolejce do KSeF” i „Wyślemy później” z komunikatem, że KSeF był niedostępny, a faktura jest ważna.
W aplikacji webowej nie ma przełącznika „wystaw offline”. O przejściu decyduje odpowiedź KSeF. W publicznym API można jednak wystawić fakturę od razu w trybie offline24, jeśli integracja wie lepiej.
Jeden XML, jeden skrót
XML budowany jest raz, przy pierwszej próbie, walidowany XSD i archiwizowany. Każde ponowienie i każde dosłanie używa tego samego pliku. Skrót SHA-256 jest wydrukowany w KOD I na PDF-ie, który klient już ma. Gdybym przy dosłaniu wygenerował XML jeszcze raz, np. z innym znacznikiem czasu, skrót by się zmienił i kod klienta prowadziłby donikąd.
Dosyłanie co 15 minut
Osobny cron co 15 minut (strefa Europe/Warsaw) przegląda faktury, które czekają na KSeF:
offlinedostają nowe zadanie wysyłki z flagąofflineMode: true, wymaganą przez API dla faktur offline;sendingstarsze niż 30 minut, czyli dłuższe niż cały łańcuch powtórzeń, też trafiają do kolejki, bo worker mógł paść w połowie wysyłki;issuedbez żadnej próby wysyłki, starsze niż 10 minut, łapię na wypadek, gdyby dodanie zadania przy wystawieniu się nie udało.
Każda firma jest przetwarzana osobno, a błąd jednej nie zatrzymuje pozostałych.
Pilnowanie terminu
Termin liczę jako koniec następnego dnia roboczego po dacie wystawienia, czyli 23:59:59.999 czasu warszawskiego:
function offline24Deadline(issueDate: IsoDate): Date {
let d = addDays(issueDate, 1);
while (isWeekend(d) || isPolishHoliday(d)) d = addDays(d, 1);
return endOfDayIn('Europe/Warsaw', d);
}
Święta ruchome liczę od daty Wielkanocy (algorytm Meeusa/Jonesa/Butchera). Wigilia jest dniem wolnym od pracy od 1 lutego 2025, kiedy weszła w życie ustawa z 6 grudnia 2024 r. o zmianie ustawy o dniach wolnych od pracy (Dz.U. 2024 poz. 1965), więc kod liczy ją od 2025 roku.
Soboty traktuję jako dni nierobocze. Ustawa o VAT nie definiuje „dnia roboczego”. Art. 12 § 5 Ordynacji podatkowej przesuwa termin kończący się w sobotę lub dzień ustawowo wolny na następny dzień po dniach wolnych, chyba że ustawy podatkowe stanowią inaczej. MF w swoich przykładach nie liczy sobót: w dokumentacji trybów offline 7 dni roboczych od awarii zakończonej w sobotę 12 lipca 2025 upływa we wtorek 22 lipca.
Jeśli cron widzi fakturę offline po terminie, zapisuje zdarzenie w historii, wysyła właścicielowi konta maila z terminem i ostatnim błędem z KSeF i dalej próbuje ją dosłać. Mail wychodzi najwyżej raz na 24 godziny dla danej faktury. Ostrzeżenie co 15 minut nikomu nie pomoże.
Po przyjęciu: numer, UPO, drugi mail
Po przyjęciu faktura dostaje numer KSeF, a KOD I na PDF-ie dostaje ten numer zamiast napisu OFFLINE. Klient dostaje mail z aktualizacją i nowym PDF-em. Można to wyłączyć w ustawieniach marki, domyślnie jest włączone.
Pobieranie UPO nie blokuje przyjęcia faktury. Dokumentacja MF mówi wprost tylko o UPO zbiorczym: jego generowanie startuje asynchronicznie po zamknięciu sesji. W naszej implementacji obsługujemy też sytuację, gdy status faktury jest już końcowy, a jej UPO nie da się jeszcze pobrać. Wtedy oznaczam brak UPO, a ten sam cron przez 7 dni (najwyżej pięć nieudanych prób) dociąga je po numerze KSeF.
Pułapki
Timeout przy wysyłce to nie porażka. Faktura mogła dotrzeć. Dlatego numer sesji zapisuję w bazie przed wysłaniem pliku. Po timeoucie najpierw sprawdzam listę faktur w tej sesji i szukam pliku po skrócie. Jeśli tam jest, przejmuję jego numer referencyjny i odpytuję o status zamiast wysyłać drugi raz. Każda kolejna próba zaczyna od takiego uzgodnienia wcześniejszych prób. Jeśli KSeF nie pozwala nawet pobrać listy, próba kończy się błędem i nic nie wysyłam w ciemno.
Duplikat jako ostatnia linia obrony. Jeśli mimo wszystko wyślę fakturę ponownie, KSeF odrzuca ją kodem 440 „Duplikat faktury” (weryfikacja faktury) i w szczegółach statusu podaje numer KSeF oryginału oraz sesję, w której trafił do systemu (OpenAPI KSeF 2.0). Duplikat KSeF rozpoznaje po NIP-ie sprzedawcy, rodzaju faktury i numerze z P_2, a nie po skrócie pliku. Jeśli numer należy do NIP-u sprzedawcy i wcześniej były nierozstrzygnięte próby, traktuję to jako przyjęcie. Duplikat przy pierwszej próbie to prawdziwy konflikt numeracji i kończy się odrzuceniem.
Brak certyfikatu offline. Bez certyfikatu typu Offline nie podpiszę KOD II. PDF wychodzi wtedy tylko z KOD I, a zdarzenie trafia do logu i historii faktury. Taki dokument jest niepełny, jeśli trafia do nabywcy poza KSeF. Dlatego kreator połączenia z KSeF pobiera certyfikat Offline razem z certyfikatem do logowania, a osobne zadanie pilnuje jego odnowienia. Przy połączeniu tokenem certyfikatu offline nie ma i mówię to użytkownikowi wprost.
Brak połączenia to nie offline. Faktura wystawiona, zanim firma połączyła konto z KSeF, nie jest kandydatem do offline24. Czeka w issued i nie wywołuje KSeF. Faktury wystawione przed pierwszym połączeniem są wstrzymane, dopóki użytkownik sam nie kliknie „Wyślij do KSeF”. Automat nie wysyła ich na własną rękę.
Komunikatów MF nie czytam automatycznie. Kod zawsze liczy termin offline24, który jest najkrótszy. Jeśli MF ogłosi awarię, faktyczny termin się wydłuża, ale moje ostrzeżenie przyjdzie wcześniej, niż trzeba. To świadomy wybór: wolę fałszywy alarm niż przegapiony termin.
Strefa czasowa. „Następny dzień roboczy” jest liczony w czasie polskim, a serwer działa w UTC. Data wystawienia to data kalendarzowa, a nie znacznik czasu. Testy obejmują zmianę czasu i długie weekendy.
Podsumowanie
- Offline24 nie wymaga powodu. Termin to koniec następnego dnia roboczego po dacie z P_1, a od 2027 roku za jego przekroczenie grożą kary.
- Faktura
offlinejest ważna i ma numer. To stan oczekiwania, a nie stan końcowy. - XML generuję raz, bo jego skrót jest w kodzie QR klienta.
- Timeout to „nie wiem”, a nie „nie udało się”. Najpierw sprawdzam, potem ewentualnie wysyłam ponownie.
- Termin pilnuje cron, nie człowiek, a ostrzeżenie trafia do właściciela konta.
Część prawną, czyli tryby, terminy i to, co musi być na fakturze offline, opisałem dla użytkowników w osobnym tekście o offline24 na blogu Wystaw.