Fakturnia Developers Quickstart API reference Polski

Webhooks

Webhooks push events to your server the moment something happens, so you do not have to poll the API. Register an HTTPS endpoint, pick the events, verify the signature on every delivery and answer quickly with 2xx. Like the whole REST API, webhooks require the MAX plan: events are delivered only while the account has an active MAX plan and at least one active API application.

Events

Event Fired when data
document.created a document is created in the web or mobile app (drafts included), through the REST API (also by an AI assistant connected over MCP) or by a recurring rule; warehouse documents included; invoices downloaded from KSeF have their own event, document.ksefImported documentId
document.updated a document changes in the app or through the API, including after an order link, after a correction is issued for it in the app or through the API, after a draft is issued or a document is turned back into a draft, and after its sent, printed or downloaded mark changes in the app documentId
document.deleted a document is deleted documentId
document.sentToKSeF KSeF accepted the document and assigned a number, also when processing finished in the background documentId
document.ksefRejected KSeF rejected the document or the send failed (validation, timeout, lost session) documentId
document.paymentStatusChanged the payment mark of a document issued as "Do zapłaty" changes (received or reverted) documentId
document.ksefImported a KSeF sync downloaded new invoices: received from suppliers or issued outside Fakturnia; up to 100 ids per delivery, a larger import arrives as several deliveries documentIds (documentId = the first of them)
client.created / client.updated / client.deleted a contractor is added, changed, deleted clientId (clientIds on bulk delete)
product.created / product.updated / product.deleted a product is added, changed, deleted productId
webhook.test you call POST /webhooks/{id}/test; it cannot be subscribed to, every webhook gets it on request webhookId

Document events arrive no matter who acted: an integration through the API (an AI assistant over MCP included), a user in the web or mobile app, a recurring rule or an automation (the KSeF queue). Contractor and product events also arrive from both the API and the app. A correction issued in the app or through the API fires document.created for itself and document.updated for the corrected invoice. A draft saved in the app fires document.created too (it reads back with isDraft: true). Issuing a draft in the app fires document.updated (isDraft reads back as false). The same event arrives when a document is turned back into a draft and when its isSent, isPrinted or isDownloaded mark changes in the app, including the first print or download. Only a real change counts: downloading an already downloaded document again sends no further event.

document.updated also arrives when a warehouse release is confirmed or reverted, when the last correction of an invoice is deleted (for the invoice, which can be edited again), when a WZ is issued for an invoice (for the invoice, which then lists the WZ in linkedDocuments), when a recurring rule e-mails the document it generated (the isSent mark) and when a KSeF sync adds the KSeF number to a document issued in Fakturnia but sent to KSeF elsewhere. client.created also arrives for a contractor saved from the buyer data of a document created through the API or MCP, and client.updated when e-mail addresses are saved on a contractor (also when a document is e-mailed to new addresses).

Invoices downloaded from KSeF (purchase invoices from suppliers and sales invoices issued outside Fakturnia) do not arrive as document.created. A sync reports them with document.ksefImported: the ids of the new invoices are in data.documentIds, up to 100 per delivery, so a larger import (for example the first sync after connecting KSeF) arrives as several deliveries, each with its own id. Read each document with GET /documents/{id}: source is ksef and direction tells a purchase (expense) from a sale (income).

Managing webhooks

Method Path Scope Description
GET /webhooks webhooks:read list of webhooks (no secrets); with ?endpoint= it says whether that URL is already registered
POST /webhooks webhooks:create registers an endpoint for the chosen events; the 201 response carries secret (once)
GET /webhooks/{id} webhooks:read one webhook with its delivery counters
PATCH /webhooks/{id} webhooks:update change endpoint, events (the whole list), description, status (ACTIVE / INACTIVE)
DELETE /webhooks/{id} webhooks:delete removal; pending deliveries are cancelled
POST /webhooks/{id}/rotate-secret webhooks:update a new signing secret, returned once
GET /webhooks/{id}/deliveries webhooks:read delivery history, paginated (currentPage, itemsPerPage up to 100, ?status=), for debugging a receiver
POST /webhooks/{id}/test webhooks:update sends webhook.test right away and returns the HTTP outcome; 5 per minute
POST /webhooks/initial webhooks:create for plugins: registration for every event, idempotent per URL, with a rotateSecret option
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"
  }
}

Store secret immediately: it cannot be read back later. If you lose it, call POST /webhooks/{id}/rotate-secret (update the receiver first, then rotate, because deliveries are signed with the new secret from that moment). The endpoint must be a public HTTP(S) address; private and local addresses are rejected with 422 VALIDATION_ERROR on the endpoint field. One webhook exists per address (409 CONFLICT on a repeat), so change the event list with PATCH.

Send a test ping right after registering:

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 } } }

The ping is signed like a real delivery, is never retried and does not count towards the automatic switch-off.

Delivery format

Every delivery is a POST request with a JSON body and these headers:

Header Value
X-Webhook-Event the event name, e.g. document.created
X-Webhook-Delivery the delivery id, the same on every retry (use it to deduplicate)
X-Webhook-Signature HMAC-SHA256(rawBody, secret) as lowercase hex
Content-Type application/json
{
  "id": "681a3b2c4d5e6f7a8b9c0d1f",
  "event": "document.created",
  "timestamp": "2026-09-26T10:30:00.000Z",
  "data": { "documentId": "681a3b2c4d5e6f7a8b9c0d2a" },
  "resourceId": "681a3b2c4d5e6f7a8b9c0d2a"
}
  • id repeats the value of the X-Webhook-Delivery header.
  • data carries the resource id named after its type (documentId, clientId, productId, webhookId). Two events carry a list: client.deleted after several contractors are deleted at once in the app (clientIds) and document.ksefImported (documentIds, always a list, up to 100 ids); the singular field is then the first of them.
  • resourceId (and resourceIds on events with a list) is the same value, kept for older receivers.
  • A delivery does not carry the full object. Fetch the resource after receiving it (GET /documents/{id}) to read the current state; on document.sentToKSeF and document.ksefRejected check GET /documents/{id}/ksef-status.

The full body schema is WebhookEventPayload in the OpenAPI specification.

Verifying the signature

Compute HMAC-SHA256 over the raw request body with the webhook secret and compare it with the X-Webhook-Signature header in constant time. Reject anything that does not match.

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)

Always verify the signature before trusting the payload. A request without a signature or with a mismatched one did not come from Fakturnia.

Retry mechanism

The first delivery attempt happens right after the event. If your endpoint does not answer 2xx within 10 seconds, Fakturnia queues the event and retries delivery with growing intervals: 5 minutes, 30 minutes, 2 hours and 8 hours after the previous attempt. That makes up to 5 attempts in total (the first one plus 4 retries). Redirects (3xx) are not followed and count as a failure. When any attempt ends with a 2xx response, the delivery succeeded.

Diagram of the webhook retry mechanism

After 5 failed attempts the delivery gets the status FAILED. After 10 consecutive failures of the same webhook (regardless of the event) the webhook moves to the ERROR status and stops receiving events. Fix the endpoint and re-enable it with PATCH /webhooks/{id} and { "status": "ACTIVE" }; the failure streak starts from zero. Inspect the attempts with 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
}

Delivery records are kept for 30 days.

Best practices

  • Answer fast (2xx) and process asynchronously. Do the heavy work after acknowledging; otherwise deliveries may time out and get retried.
  • Be idempotent. The same event can arrive more than once. Deduplicate on the delivery id (the X-Webhook-Delivery header).
  • Treat webhooks as hints, not as the source of truth. For anything critical fetch the resource to get the authoritative state.
  • Return a non-2xx code to trigger a retry when you genuinely cannot process the request yet.
  • Keep the secret server-side and rotate it if it leaks.
  • Subscribe only to what you handle. Fewer events mean fewer deliveries to ignore and less risk of running into a failure streak.