Webhooki
Webhooki wysyłają zdarzenia na Twój serwer w momencie, gdy coś się wydarzy, dzięki czemu nie musisz odpytywać API. Rejestrujesz endpoint HTTPS, wybierasz zdarzenia, weryfikujesz podpis przy każdej dostawie i szybko odpowiadasz kodem 2xx. Webhooki, jak całe REST API, wymagają planu MAX: zdarzenia są wysyłane tylko wtedy, gdy konto ma aktywny plan MAX i co najmniej jedną aktywną aplikację API.
Zdarzenia
| Zdarzenie | Wyzwalane, gdy | data |
|---|---|---|
document.created |
dokument zostaje utworzony w aplikacji webowej albo mobilnej (także szkic), przez REST API (także przez asystenta AI połączonego przez MCP) albo przez regułę cykliczną; dotyczy też dokumentów magazynowych; faktury pobrane z KSeF mają osobne zdarzenie document.ksefImported |
documentId |
document.updated |
dokument zostaje zmieniony w aplikacji albo przez API, w tym po powiązaniu z zamówieniem, po wystawieniu do niego korekty w aplikacji albo przez API, po wystawieniu szkicu albo zamianie dokumentu z powrotem w szkic oraz po zmianie w aplikacji oznaczenia wysłania, wydruku albo pobrania | documentId |
document.deleted |
dokument zostaje usunięty | documentId |
document.sentToKSeF |
KSeF przyjął dokument i nadał mu numer, także gdy przetwarzanie dokończyło się w tle | documentId |
document.ksefRejected |
KSeF odrzucił dokument albo wysyłka zakończyła się błędem (walidacja, timeout, utracona sesja) | documentId |
document.paymentStatusChanged |
zmienia się oznaczenie zapłaty dokumentu wystawionego jako „Do zapłaty” (otrzymano albo cofnięto) | documentId |
document.ksefImported |
synchronizacja z KSeF pobrała nowe faktury: otrzymane od dostawców albo wystawione poza Fakturnią; do 100 identyfikatorów w jednej dostawie, większy import przychodzi w kilku dostawach | documentIds (documentId = pierwszy z nich) |
client.created / client.updated / client.deleted |
kontrahent zostaje dodany, zmieniony, usunięty | clientId (clientIds przy usuwaniu wielu) |
product.created / product.updated / product.deleted |
produkt zostaje dodany, zmieniony, usunięty | productId |
webhook.test |
wywołasz POST /webhooks/{id}/test; tego zdarzenia nie da się subskrybować, dostaje je każdy webhook na żądanie |
webhookId |
Zdarzenia dokumentów przychodzą niezależnie od tego, kto wykonał akcję: integracja przez API (także asystent AI przez MCP), użytkownik w aplikacji webowej albo mobilnej, reguła cykliczna czy automat (kolejka KSeF). Zdarzenia kontrahentów i produktów także przychodzą zarówno z API, jak i z aplikacji. Korekta wystawiona w aplikacji albo przez API wyzwala document.created dla siebie i document.updated dla faktury korygowanej. Szkic zapisany w aplikacji też wyzwala document.created (w odczycie ma isDraft: true). Wystawienie szkicu w aplikacji wyzwala document.updated (w odczycie isDraft zmienia się na false). To samo zdarzenie przychodzi po zamianie dokumentu z powrotem w szkic i po zmianie w aplikacji oznaczeń isSent, isPrinted i isDownloaded, także przy pierwszym wydruku albo pobraniu dokumentu. Liczy się tylko rzeczywista zmiana: ponowne pobranie już pobranego dokumentu nie wysyła kolejnego zdarzenia.
document.updated przychodzi też po potwierdzeniu albo cofnięciu wydania magazynowego, po usunięciu ostatniej korekty faktury (dla faktury, którą znowu można edytować), po wystawieniu WZ do faktury (dla faktury, która ma wtedy tę WZ w linkedDocuments), gdy reguła cykliczna wyśle wygenerowany dokument e-mailem (oznaczenie isSent) i wtedy, gdy synchronizacja z KSeF dopisze numer KSeF dokumentowi wystawionemu w Fakturni, ale wysłanemu do KSeF gdzie indziej. client.created przychodzi też dla kontrahenta zapisanego z danych nabywcy dokumentu utworzonego przez API albo MCP, a client.updated wtedy, gdy przy kontrahencie zostaną zapisane adresy e-mail (także przy wysyłce dokumentu na nowe adresy).
Faktury pobrane z KSeF (zakupowe od dostawców i sprzedażowe wystawione poza Fakturnią) nie przychodzą jako document.created. Synchronizacja zgłasza je zdarzeniem document.ksefImported: identyfikatory nowych faktur są w data.documentIds, do 100 w jednej dostawie, więc większy import (np. pierwsza synchronizacja po połączeniu z KSeF) przychodzi w kilku dostawach, każda z własnym id. Każdy dokument odczytaj przez GET /documents/{id}: source ma wartość ksef, a direction odróżnia zakup (expense) od sprzedaży (income).
Zarządzanie webhookami
| Metoda | Ścieżka | Scope | Opis |
|---|---|---|---|
GET |
/webhooks |
webhooks:read |
lista webhooków (bez sekretów); z ?endpoint= sprawdza, czy dany adres jest już zarejestrowany |
POST |
/webhooks |
webhooks:create |
rejestruje endpoint na wybrane zdarzenia; odpowiedź 201 zawiera secret (tylko raz) |
GET |
/webhooks/{id} |
webhooks:read |
jeden webhook z licznikami dostaw |
PATCH |
/webhooks/{id} |
webhooks:update |
zmiana endpoint, events (cała lista), description, status (ACTIVE / INACTIVE) |
DELETE |
/webhooks/{id} |
webhooks:delete |
usunięcie; oczekujące dostawy są anulowane |
POST |
/webhooks/{id}/rotate-secret |
webhooks:update |
nowy sekret podpisujący, zwracany raz |
GET |
/webhooks/{id}/deliveries |
webhooks:read |
historia dostaw, stronicowana (currentPage, itemsPerPage do 100, ?status=), do diagnozowania odbiorcy |
POST |
/webhooks/{id}/test |
webhooks:update |
wysyła webhook.test od razu i zwraca wynik HTTP; limit 5 na minutę |
POST |
/webhooks/initial |
webhooks:create |
dla wtyczek: rejestracja na wszystkie zdarzenia, idempotentna per adres, z opcją rotateSecret |
curl -s -X POST https://sandbox.api.fakturnia.pl/external/v1/webhooks \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{
"endpoint": "https://erp.example.com/hooks/fakturnia",
"events": ["document.created", "document.sentToKSeF", "document.ksefRejected", "document.paymentStatusChanged"],
"description": "ERP"
}'
{
"success": true,
"message": "Webhook created.",
"data": {
"webhook": {
"id": "681a3b2c4d5e6f7a8b9c0d1e",
"endpoint": "https://erp.example.com/hooks/fakturnia",
"description": "ERP",
"events": ["document.created", "document.sentToKSeF", "document.ksefRejected", "document.paymentStatusChanged"],
"status": "ACTIVE",
"successCount": 0,
"failureCount": 0,
"consecutiveFailures": 0,
"lastDeliveryAt": null,
"createdAt": "2026-09-26T10:00:00.000Z",
"updatedAt": "2026-09-26T10:00:00.000Z"
},
"secret": "9f2a7c1d4e6b8a0c3d5f7e9b1a2c4d6e8f0a1b3c5d7e9f2a4b6c8d0e1f3a5b7c"
}
}
Zapisz secret od razu: nie da się go później odczytać. Jeśli go zgubisz, wywołaj POST /webhooks/{id}/rotate-secret (najpierw zaktualizuj odbiorcę, potem rotuj, bo od tej chwili dostawy są podpisywane nowym sekretem). Endpoint musi być publicznym adresem HTTP(S); adresy prywatne i lokalne są odrzucane z 422 VALIDATION_ERROR w polu endpoint. Dla jednego adresu istnieje jeden webhook (409 CONFLICT przy powtórce), więc listę zdarzeń zmieniaj przez PATCH.
Zaraz po rejestracji wyślij ping testowy:
curl -s -X POST https://sandbox.api.fakturnia.pl/external/v1/webhooks/681a3b2c4d5e6f7a8b9c0d1e/test \
-H "Authorization: Bearer $TOKEN"
{ "success": true, "data": { "delivery": { "id": "681a3b2c4d5e6f7a8b9c0d1f", "ok": true, "statusCode": 200, "status": "SUCCESS", "lastError": null } } }
Ping jest podpisany jak prawdziwa dostawa, nie jest ponawiany i nie liczy się do automatycznego wyłączenia.
Format dostawy
Każda dostawa to żądanie POST z treścią w formacie JSON oraz nagłówkami:
| Nagłówek | Wartość |
|---|---|
X-Webhook-Event |
nazwa zdarzenia, np. document.created |
X-Webhook-Delivery |
identyfikator dostawy, ten sam przy każdym ponowieniu (użyj do deduplikacji) |
X-Webhook-Signature |
HMAC-SHA256(rawBody, secret) w postaci szesnastkowej małymi literami |
Content-Type |
application/json |
{
"id": "681a3b2c4d5e6f7a8b9c0d1f",
"event": "document.created",
"timestamp": "2026-09-26T10:30:00.000Z",
"data": { "documentId": "681a3b2c4d5e6f7a8b9c0d2a" },
"resourceId": "681a3b2c4d5e6f7a8b9c0d2a"
}
idpowtarza wartość nagłówkaX-Webhook-Delivery.datazawiera identyfikator zasobu nazwany po jego typie (documentId,clientId,productId,webhookId). Listę niosą dwa zdarzenia:client.deletedpo usunięciu kilku kontrahentów naraz w aplikacji (clientIds) idocument.ksefImported(documentIds, zawsze lista, do 100 identyfikatorów); pole pojedyncze wskazuje wtedy pierwszy z nich.resourceId(iresourceIdsprzy zdarzeniach z listą) to to samo, zachowane dla starszych odbiorców.- Dostawa nie niesie pełnego obiektu. Po odbiorze pobierz zasób (
GET /documents/{id}), żeby przeczytać aktualny stan; przydocument.sentToKSeFidocument.ksefRejectedsprawdźGET /documents/{id}/ksef-status.
Pełny schemat treści to WebhookEventPayload w specyfikacji OpenAPI.
Weryfikacja podpisu
Oblicz HMAC-SHA256 na surowej treści żądania przy użyciu sekretu webhooka i porównaj wynik z nagłówkiem X-Webhook-Signature w czasie stałym. Odrzuć wszystko, co się nie zgadza.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// Capture the RAW body; the signature is computed over exact bytes.
app.post('/webhooks/fakturnia', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.header('X-Webhook-Signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.FAKTURNIA_WEBHOOK_SECRET!)
.update(req.body) // Buffer of the raw body
.digest('hex');
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// dedupe on event.id, then handle event.event / event.data and ACK fast:
res.sendStatus(200);
});
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Zawsze weryfikuj podpis, zanim zaufasz payloadowi. Żądanie bez podpisu lub z niezgodnym podpisem nie pochodzi z Fakturnia.
Mechanizm ponawiania
Pierwsza próba dostawy następuje od razu po zdarzeniu. Jeśli Twój endpoint nie odpowie statusem 2xx w ciągu 10 sekund, Fakturnia kolejkuje zdarzenie i ponawia dostarczenie z rosnącymi odstępami: po 5 minutach, po 30 minutach, po 2 godzinach i po 8 godzinach od poprzedniej próby. Łącznie to maksymalnie 5 prób (pierwsza i 4 ponowienia). Przekierowania (3xx) nie są śledzone i liczą się jako niepowodzenie. Gdy któraś z prób zakończy się odpowiedzią 2xx, dostawa jest udana.
Po 5 nieudanych próbach dostawa dostaje status FAILED. Po 10 niepowodzeniach z rzędu dla tego samego webhooka (niezależnie od zdarzenia) webhook przechodzi w status ERROR i przestaje dostawać zdarzenia. Popraw endpoint i włącz go ponownie przez PATCH /webhooks/{id} z { "status": "ACTIVE" }; licznik niepowodzeń zaczyna wtedy od zera. Historię prób obejrzysz przez GET /webhooks/{id}/deliveries:
{
"success": true,
"data": {
"deliveries": [
{ "id": "681a…", "event": "document.created", "resourceId": "681a…", "status": "PENDING", "attempts": 2, "nextAttemptAt": "2026-09-26T11:05:00.000Z", "lastAttemptAt": "2026-09-26T10:35:00.000Z", "lastError": "HTTP 503", "createdAt": "2026-09-26T10:30:00.000Z" }
],
"currentPage": 1, "itemsPerPage": 20, "totalCount": 1, "totalPages": 1
},
"executionTime": 12
}
Rekordy dostaw są przechowywane 30 dni.
Dobre praktyki
- Odpowiadaj szybko (
2xx) i przetwarzaj asynchronicznie. Ciężką pracę wykonuj po potwierdzeniu odbioru; w przeciwnym razie dostawy mogą wygasnąć i zostać ponowione. - Zapewnij idempotentność. To samo zdarzenie może dotrzeć więcej niż raz. Usuwaj duplikaty na podstawie
iddostawy (nagłówekX-Webhook-Delivery). - Traktuj webhooki jako wskazówki, a nie źródło prawdy. W przypadku czegokolwiek krytycznego pobierz zasób, aby uzyskać miarodajny stan.
- Zwracaj kod inny niż 2xx, aby wywołać ponawianie, jeśli faktycznie nie możesz jeszcze przetworzyć żądania.
- Przechowuj sekret po stronie serwera i rotuj go w razie wycieku.
- Subskrybuj tylko to, co obsługujesz. Mniej zdarzeń to mniej dostaw do zignorowania i mniejsze ryzyko wpadnięcia w serię niepowodzeń.