Skip to content

Przyrostowe pobieranie faktur

21.11.2025

Wprowadzenie

Przyrostowe pobieranie faktur, oparte na eksporcie paczek (POST /invoices/exports), jest rekomendowanym mechanizmem synchronizacji między centralnym repozytorium KSeF a lokalnymi bazami danych systemów zewnętrznych.

Kluczową rolę odgrywa tu mechanizm High Water Mark (HWM) - stabilny punkt w czasie, do którego system gwarantuje kompletność danych.

Architektura rozwiązania

Przyrostowe pobieranie opiera się na trzech kluczowych komponentach:

  1. Synchronizacja w oknach czasowych - wykorzystanie przylegających okien czasowych z uwzględnieniem HWM co zapewnia ciągłość i brak pominięć
  2. Obsługa limitów API - sterowanie tempem wywołań, obsługa HTTP 429 oraz Retry-After.
  3. Deduplikacja - eliminacja duplikatów na podstawie metadanych z plików _metadata.json.

Metoda bazowa: POST /invoices/exports inicjuje asynchroniczny eksport. Po zakończeniu przetwarzania status operacji udostępnia unikalne adresy URL do pobrania części paczki.

Synchronizacja w oknach czasowych (Windowing)

Koncepcja

Pobieranie faktur odbywa się w przylegających oknach czasowych z wykorzystaniem daty PermanentStorageHwmDate. Aby włączyć mechanizm HWM, należy ustawić parametr restrictToPermanentStorageHwmDate = true w żądaniu eksportu. Każde kolejne okno rozpoczyna się dokładnie w momencie zakończenia poprzedniego z uwzględnieniem HWM (z wyjątkiem sytuacji opisanej w sekcji Mechanizm High Water Mark (HWM) i obsługa obciętych paczek). Przez „moment zakończenia" rozumie się:

  • wartość dateRange.to, gdy została podana, lub
  • PermanentStorageHwmDate gdy dateRange.to pominięto.

Takie podejście zapewnia ciągłość zakresów i eliminuje ryzyko pominięcia jakiejkolwiek faktury. Faktury powinny być pobierane oddzielnie dla każdego typu podmiotu (Podmiot 1, Podmiot 2, Podmiot 3, Podmiot upoważniony) występującego w dokumencie. Iteracja przez podmioty zapewnia kompletność danych - firma może występować w różnych rolach na fakturach.

Przykład w języku C#: KSeF.Client.Tests.Core\E2E\Invoice\IncrementalInvoiceRetrievalE2ETests.cs

csharp
// Słownik do śledzenia punktu kontynuacji dla każdego SubjectType
Dictionary<InvoiceSubjectType, DateTime?> continuationPoints = new();
IReadOnlyList<(DateTime From, DateTime To)> windows = BuildIncrementalWindows(batchCreationStart, batchCreationCompleted);

// Tworzenie planu eksportu - krotki (okno czasowe, typ podmiotu)
IEnumerable<InvoiceSubjectType> subjectTypes = Enum.GetValues<InvoiceSubjectType>().Where(x => x != InvoiceSubjectType.SubjectAuthorized);
IOrderedEnumerable<ExportTask> exportTasks = windows
    .SelectMany(window => subjectTypes, (window, subjectType) => new ExportTask(window.From, window.To, subjectType))
    .OrderBy(task => task.From)
    .ThenBy(task => task.SubjectType);


foreach (ExportTask task in exportTasks)
{
    DateTime effectiveFrom = GetEffectiveStartDate(continuationPoints, task.SubjectType, task.From);

    OperationResponse? exportResponse = await InitiateInvoiceExportAsync(effectiveFrom, task.To, task.SubjectType);

    // Dalsza obsługa eksportu...

Przykład w języku java: IncrementalInvoiceRetrieveIntegrationTest.java

java
Map<InvoiceQuerySubjectType, OffsetDateTime> continuationPoints = new HashMap<>();

List<TimeWindows> timeWindows = buildIncrementalWindows(batchCreationStart, batchCreationCompleted);
List<InvoiceQuerySubjectType> subjectTypes = Arrays.stream(InvoiceQuerySubjectType.values())
        .filter(x -> x != InvoiceQuerySubjectType.SUBJECTAUTHORIZED)
        .toList();

List<ExportTask> exportTasks = timeWindows.stream()
        .flatMap(window -> subjectTypes.stream()
                .map(subjectType -> new ExportTask(window.getFrom(), window.getTo(), subjectType)))
        .sorted(Comparator.comparing(ExportTask::getFrom)
                .thenComparing(ExportTask::getSubjectType))
        .toList();
exportTasks.forEach(task -> {
        EncryptionData encryptionData = defaultCryptographyService.getEncryptionData();
        OffsetDateTime effectiveFrom = getEffectiveStartDate(continuationPoints, task.getSubjectType(), task.getFrom());
        String operationReferenceNumber = initiateInvoiceExportAsync(effectiveFrom, task.getTo(),
            task.getSubjectType(), accessToken, encryptionData.encryptionInfo());

// Dalsza obsługa eksportu...

Zalecane wielkości okien

  • Częstotliwość i limity
    POST /invoice/exports wymaga wskazania typu podmiotu (Podmiot 1, Podmiot 2, Podmiot 3, Podmiot upoważniony). Zgodnie z limitami API można zainicjować maksymalnie 20 eksportów na godzinę; harmonogram powinien dzielić tę pulę między wybrane typy podmiotów.
  • Strategia harmonogramu
    W trybie ciągłej synchronizacji można przyjąć 4 eksporty/h na każdy typ podmiotu. W praktyce role Podmiot 3 i Podmiot upoważniony zwykle występują rzadziej i mogą być uruchamiane sporadycznie, np. raz na dobę w oknie nocnym.
  • Minimalny interwał
    Interwał cykliczny nie powinien być krótszy niż 15 minut dla każdego typu podmiotu (zgodnie z zaleceniami w limitach API).
  • Wielkość okna W scenariuszu ciągłej synchronizacji zalecane jest wywołanie eksportu bez określonej daty końcowej (DateRange.To pominięte). W takim przypadku system KSeF przygotowuje możliwie duży, spójny pakiet w granicach limitów algorytmu (liczba faktur, rozmiar danych po kompresji), co ogranicza liczbę wywołań i obciążenie po obu stronach. Gdy IsTruncated = true, kolejne wywołanie należy rozpocząć od LastPermanentStorageDate, gdy IsTruncated = false , kolejne wywołanie należy rozpocząć od zwróconego PermanentStorageHwmDate.
  • Brak nakładania Zakresy muszą być przylegające; koniec jednego okna jest początkiem następnego.
  • Punkt kontrolny Punkt kontynuacji wyznaczony przez HWM - PermanentStorageHwmDate lub LastPermanentStorageDate dla obciętych paczek stanowi początek kolejnego okna.

Datą otrzymania faktury jest data nadania numeru KSeF. Numer nadawany jest podczas przetwarzania faktury po stronie KSeF i nie zależy od momentu pobrania do systemu zewnętrznego.

Obsługa limitów API

Limity według typu endpointów

Wszystkie endpointy związane z pobieraniem faktur podlegają ścisłym limitom API określonym w dokumentacji Limity API. Limity te są wiążące i muszą być respektowane przez każdą implementację przyrostowego pobierania.

W przypadku przekroczenia limitów system zwraca kod HTTP 429 (Too Many Requests) wraz z nagłówkiem Retry-After wskazującym czas oczekiwania przed kolejną próbą.

Inicjalizacja eksportu faktur

Kluczowe znaczenie daty PermanentStorage

Dla przyrostowego pobierania faktur konieczne jest użycie daty typu PermanentStorage, które zapewnia wiarygodność danych. Oznacza moment trwałej materializacji rekordu, jest odporna na asynchroniczne opóźnienia procesu przyjmowania danych i pozwala bezpiecznie wyznaczać okna przyrostu. Tym samym inne typy dat (jak Issue czy Invoicing) mogą prowadzić do nieprzewidywalnych zachowań w synchronizacji przyrostowej.

Przykład w języku C#: KSeF.Client.Tests.Core\E2E\Invoice\IncrementalInvoiceRetrievalE2ETests.cs

csharp
EncryptionData exportEncryption = CryptographyService.GetEncryptionData();

InvoiceQueryFilters filters = new()
{
    SubjectType = subjectType,
    DateRange = new DateRange
    {
        DateType = DateType.PermanentStorage,
        From = windowFromUtc,
        To = windowToUtc,
        RestrictToPermanentStorageHwmDate = true
    }
};

InvoiceExportRequest request = new()
{
    Filters = filters,
    Encryption = exportEncryption.EncryptionInfo
};

OperationResponse response = awat KsefClient.ExportInvoicesAsync(request, _accessToken, ct, includeMetadata: true);

Przykład w języku java: IncrementalInvoiceRetrieveIntegrationTest.java

java
EncryptionData encryptionData = defaultCryptographyService.getEncryptionData();
InvoiceExportFilters filters = new InvoiceExportFilters();
filters.setSubjectType(subjectType);
filters.setDateRange(new InvoiceQueryDateRange(
        InvoiceQueryDateType.PERMANENTSTORAGE, windowFrom, windowTo)
);

InvoiceExportRequest request = new InvoiceExportRequest();
request.setFilters(filters);
request.setEncryption(encryptionInfo);

InitAsyncInvoicesQueryResponse response = ksefClient.initAsyncQueryInvoice(request, accessToken);

Pobieranie i przetwarzanie paczek

Po zakończeniu eksportu paczka faktur jest dostępna do pobrania jako zaszyfrowane archiwum ZIP dzielone na części. Proces pobierania i przetwarzania obejmuje:

  1. Pobranie części - każda część pobierana osobno z adresów URL zwróconych w statusie operacji.
  2. Deszyfrowanie AES-256 - każda część jest deszyfrowana przy użyciu klucza i IV wygenerowanych podczas inicjalizacji eksportu.
  3. Składanie paczki - odszyfrowane części łączone w jeden strumień danych.
  4. Rozpakowanie ZIP - archiwum zawiera pliki XML faktur oraz plik _metadata.json.

Plik _metadata.json

Zawartość pliku _metadata.json to obiekt JSON z właściwością invoices (tablica elementów typu InvoiceMetadata, jak zwracany przez POST /invoices/query/metadata). Plik ten jest kluczowy dla mechanizmu deduplikacji, ponieważ zawiera numery KSeF wszystkich faktur w paczce.

Włączenie metadanych (do 27.10.2025)
Aby dołączyć plik _metadata.json, należy dodać nagłówek do żądania eksportu:

http
X-KSeF-Feature: include-metadata

Od 27.10.2025 paczka eksportu będzie zawsze zawierać plik _metadata.json bez konieczności dodawania nagłówka.

Przykład w języku C#:

KSeF.Client.Tests.Core\E2E\Invoice\IncrementalInvoiceRetrievalE2ETests.cs

KSeF.Client.Tests.Utils\BatchSessionUtils.cs

csharp
List<InvoiceSummary> metadataSummaries = new();
Dictionary<string, string> invoiceXmlFiles = new(StringComparer.OrdinalIgnoreCase);

// Pobranie, odszyfrowanie i połączenie wszystkich części w jeden strumień
using MemoryStream decryptedArchiveStream = await BatchUtils.DownloadAndDecryptPackagePartsAsync(
    package.Parts, 
    encryptionData, 
    CryptographyService, 
    cancellationToken: CancellationToken)
    .ConfigureAwait(false);

// Rozpakowanie ZIP
Dictionary<string, string> unzippedFiles = await BatchUtils.UnzipAsync(decryptedArchiveStream, CancellationToken).ConfigureAwait(false);

foreach ((string fileName, string content) in unzippedFiles)
{
    if (fileName.Equals(MetadataEntryName, StringComparison.OrdinalIgnoreCase))
    {
        InvoicePackageMetadata? metadata = JsonSerializer.Deserialize<InvoicePackageMetadata>(content, MetadataSerializerOptions);
        if (metadata?.Invoices != null)
        {
            metadataSummaries.AddRange(metadata.Invoices);
        }
    }
    else if (fileName.EndsWith(XmlFileExtension, StringComparison.OrdinalIgnoreCase))
    {
        invoiceXmlFiles[fileName] = content;
    }
}

Przykład w języku java: IncrementalInvoiceRetrieveIntegrationTest.java

java
 List<InvoicePackagePart> parts = invoiceExportStatus.getPackageParts().getParts();
byte[] mergedZip = FilesUtil.mergeZipParts(
        encryptionData,
        parts,
        part -> ksefClient.downloadPackagePart(part),
        (encryptedPackagePart, key, iv) -> defaultCryptographyService.decryptBytesWithAes256(encryptedPackagePart, key, iv)
);
Map<String, String> downloadedFiles = FilesUtil.unzip(mergedZip);

String metadataJson = downloadedFiles.keySet()
        .stream()
        .filter(fileName -> fileName.endsWith(".json"))
        .findFirst()
        .map(downloadedFiles::get)
        .orElse(null);
InvoicePackageMetadata invoicePackageMetadata = objectMapper.readValue(metadataJson, InvoicePackageMetadata.class);

Mechanizm High Water Mark (HWM) i obsługa obciętych paczek (IsTruncated)

Koncepcja HWM

High Water Mark (HWM) to mechanizm zapewniający optymalne zarządzanie punktami startowymi dla kolejnych eksportów w przyrostowym pobieraniu faktur. HWM składa się z dwóch komplementarnych składników:

  • PermanentStorageHwmDate - stabilna górna granica danych uwzględnionych w paczce, reprezentująca moment, do którego system gwarantuje kompletność danych.
  • LastPermanentStorageDate - data ostatniej faktury w paczce, używana gdy paczka została obcięta (IsTruncated = true).

Korzyści mechanizmu HWM

  • Minimalizacja duplikatów - HWM znacząco redukuje liczbę duplikatów między kolejnymi paczkami
  • Optymalizacja wydajności - zmniejsza obciążenie mechanizmu deduplikacji
  • Zachowanie kompletności - zapewnia, że żadne faktury nie zostaną pominięte
  • Stabilność synchronizacji - dostarcza niezawodne punkty kontynuacji dla długotrwałych procesów

Strategia kontynuacji paczek

Flaga IsTruncated = true jest ustawiana, gdy podczas budowy paczki osiągnięto limity algorytmu (liczba faktur lub rozmiar danych po kompresji). W takim przypadku w statusie operacji dostępne są obydwie właściwości HWM. Mechanizm HWM wykorzystuje następującą hierarchię priorytetów dla wyznaczania punktu kontynuacji, Aby zachować ciągłość pobierania i nie pominąć żadnego dokumentu, następne wywołanie eksportu należy rozpocząć od:

  1. Paczka obcięta (IsTruncated = true) - następne wywołanie rozpoczyna się od LastPermanentStorageDate
  2. Stabilny HWM - wykorzystanie PermanentStorageHwmDate jako punktu startowego dla następnego okna
  • kolejne okno rozpoczyna się w tym samym punkcie co zakończone (przyległość); ewentualne duplikaty zostaną usunięte w etapie deduplikacji na podstawie numerów KSeF z _metadata.json. Poniżej przykład utrzymywania punktu kontynuacji:

Przykład w języku C#: KSeF.Client.Tests.Core\E2E\Invoice\IncrementalInvoiceRetrievalE2ETests.cs

csharp
private static void UpdateContinuationPointIfNeeded(
    Dictionary<InvoiceSubjectType, DateTime?> continuationPoints,
    InvoiceSubjectType subjectType,
    InvoiceExportPackage package)
{
    // Priorytet 1: Paczka obcięta - LastPermanentStorageDate
    if (package.IsTruncated && package.LastPermanentStorageDate.HasValue)
    {
        continuationPoints[subjectType] = package.LastPermanentStorageDate.Value.UtcDateTime;
    }
    // Priorytet 2: Stabilny HWM jako granica kolejnego okna
    else if (package.PermanentStorageHwmDate.HasValue)
    {
        continuationPoints[subjectType] = package.PermanentStorageHwmDate.Value.UtcDateTime;
    }
    else
    {
        // Zakres w pełni przetworzony - usunięcie punktu kontynuacji
        continuationPoints.Remove(subjectType);
    }
}

Przykład w języku java: IncrementalInvoiceRetrieveIntegrationTest.java

java
private void updateContinuationPointIfNeeded(Map<InvoiceQuerySubjectType, OffsetDateTime> continuationPoints,
                                                 InvoiceQuerySubjectType subjectType,
                                                 InvoiceExportPackage invoiceExportPackage) {
    if (Boolean.TRUE.equals(invoiceExportPackage.getIsTruncated()) && Objects.nonNull(invoiceExportPackage.getLastPermanentStorageDate())) {
        continuationPoints.put(subjectType, invoiceExportPackage.getLastPermanentStorageDate());
    } else {
        continuationPoints.remove(subjectType);
    }
}

Deduplikacja faktur

Strategia deduplikacji

Deduplikacja odbywa się na podstawie numerów KSeF zawartych w pliku _metadata.json:

Przykład w języku C#: KSeF.Client.Tests.Core\E2E\Invoice\IncrementalInvoiceRetrievalE2ETests.cs

csharp
Dictionary<string, InvoiceSummary> uniqueInvoices = new(StringComparer.OrdinalIgnoreCase);
bool hasDuplicates = false;

// Przetwarzanie metadanych z paczki - dodawanie unikalnych faktur i wykrywanie duplikatów
hasDuplicates = packageResult.MetadataSummaries
    .DistinctBy(s => s.KsefNumber, StringComparer.OrdinalIgnoreCase)
    .Any(summary => !uniqueInvoices.TryAdd(summary.KsefNumber, summary));

Przykład w języku java: IncrementalInvoiceRetrieveIntegrationTest.java

java
hasDuplicates.set(packageProcessingResult.getInvoiceMetadataList()
        .stream()
        .anyMatch(summary -> uniqueInvoices.containsKey(summary.getKsefNumber())));

packageProcessingResult.getInvoiceMetadataList()
        .stream()
        .distinct()
        .forEach(summary -> uniqueInvoices.put(summary.getKsefNumber(), summary));

Powiązane dokumenty