Guides
Webhooks
Instead of polling for an invoice's status, register a URL. When KSeF accepts or rejects a document, we send a signed event.
Event types
Events are created when a document reaches a final state.
| Event type | When |
|---|---|
invoice.accepted | KSeF accepted the invoice: it has a KSeF number and the PDF with the QR code is ready or about to be. |
invoice.rejected | KSeF rejected the invoice. Code and text in last_error (e.g. ksef_duplicate_number). |
invoice.skipped | The order cannot be invoiced (e.g. no NIP, no VAT rate). Reason in last_error. |
correction.accepted | KSeF accepted the correction invoice. |
correction.rejected | KSeF rejected the correction invoice. |
correction.skipped | The refund cannot become a correction (e.g. the original invoice was rejected). |
Registering an endpoint
Register a URL through the API. The whsec_... secret comes only in this one response: store it right away. enabled_events: ["*"] means every type. Live mode needs an https:// URL; test mode also allows http://. Each mode has up to 16 endpoints.
curl -X POST https://app.ksefshop.pl/v1/webhook_endpoints \
-H "Authorization: Bearer $KSEF_API_KEY" \
-H "Idempotency-Key: webhook-shop-backend" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/ksef/webhook",
"enabled_events": [
"*"
],
"description": "Shop backend"
}'<?php
$payload = [
'url' => 'https://example.com/ksef/webhook',
'enabled_events' => ['*'],
'description' => 'Shop backend',
];
$ch = curl_init('https://app.ksefshop.pl/v1/webhook_endpoints');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('KSEF_API_KEY'),
'Idempotency-Key: webhook-shop-backend',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$data = json_decode($body, true);
if ($status >= 400) {
throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
}
echo var_export($data['id'], true), ' ', var_export($data['secret'], true), PHP_EOL;const res = await fetch("https://app.ksefshop.pl/v1/webhook_endpoints", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KSEF_API_KEY}`,
"Idempotency-Key": "webhook-shop-backend",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/ksef/webhook",
enabled_events: ["*"],
description: "Shop backend",
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data.id, data.secret);import os
import requests
resp = requests.post(
"https://app.ksefshop.pl/v1/webhook_endpoints",
headers={
"Authorization": f"Bearer {os.environ['KSEF_API_KEY']}",
"Idempotency-Key": "webhook-shop-backend",
},
json={
"url": "https://example.com/ksef/webhook",
"enabled_events": ["*"],
"description": "Shop backend",
},
timeout=30,
)
data = resp.json()
if not resp.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data["id"], data["secret"])What arrives
A POST with an Event in the body and two headers: KSeF-Signature: t=<unix>,v1=<hex> and KSeF-Event-Id. data.object holds the invoice or correction as it was at the time of the event.
{
"id": "evt_Qa1Ws2Ed3Rf4Tg5Yh6Uj7Ik8",
"object": "event",
"type": "invoice.accepted",
"livemode": false,
"created": 1760865214,
"data": {
"object": {
"id": "inv_4fJ2kP9xQwErTy7uI0oPaSdF",
"object": "invoice",
"livemode": false,
"status": "accepted",
"order": {
"external_id": "SO-1001",
"number": "1001"
},
"number": "FV/2026/10/1",
"ksef_number": "4235405606-20261019-0E7570400000-57",
"ksef_environment": "test",
"issue_date": "2026-10-19",
"currency": "PLN",
"total_gross": "163.99",
"buyer_nip": "5260250274",
"pdf_url": "https://app.ksefshop.pl/pdf/cmgxa1b2c0000/Qk9tZ2FsX3ZhbGlkX3Rva2VuX2V4YW1w",
"last_error": null,
"created": 1760865185,
"updated": 1760865214,
"corrections": []
}
}
}Verifying the signature
v1 is an HMAC-SHA256 keyed with your secret over the text t + "." + raw body. To trust an event:
- Split the
KSeF-Signatureheader intotandv1. - Reject the event when
tis older than 5 minutes (replay protection). - Compute the HMAC over the raw body, before your framework parses it. Re-serialized JSON has different bytes and the signature will not match.
- Compare in constant time (
hash_equals,timingSafeEqual,compare_digest).
# Send your receiver a test event signed exactly like ours
SECRET=whsec_shownOnlyOnceExampleValue0000
BODY='{"id":"evt_test","object":"event","type":"invoice.accepted","livemode":false,"created":1760865214,"data":{"object":{"id":"inv_test"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8000/ksef/webhook \
-H "Content-Type: application/json" \
-H "KSeF-Signature: t=$T,v1=$SIG" \
-H "KSeF-Event-Id: evt_test" \
-d "$BODY"<?php
$secret = getenv('KSEF_WEBHOOK_SECRET'); // whsec_...
$payload = file_get_contents('php://input'); // raw body, before json_decode
$header = $_SERVER['HTTP_KSEF_SIGNATURE'] ?? '';
$parts = [];
foreach (explode(',', $header) as $item) {
[$k, $v] = array_pad(explode('=', $item, 2), 2, '');
$parts[$k] = $v;
}
$t = (int) ($parts['t'] ?? 0);
if ($t === 0 || abs(time() - $t) > 300 || empty($parts['v1'])) {
http_response_code(400);
exit;
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
if (!hash_equals($expected, $parts['v1'])) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Drop duplicates by $_SERVER['HTTP_KSEF_EVENT_ID'], store the event, do the rest in the background
http_response_code(200);import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.KSEF_WEBHOOK_SECRET; // whsec_...
// express.raw: the signature covers the raw body, not parsed JSON
app.post("/ksef/webhook", express.raw({ type: "application/json" }), (req, res) => {
const parts = Object.fromEntries(
(req.get("KSeF-Signature") ?? "").split(",").map((p) => p.split("=", 2)),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300 || !parts.v1) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", secret).update(`${t}.${req.body}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(400);
const event = JSON.parse(req.body);
// Drop duplicates by req.get("KSeF-Event-Id"), store the event, do the rest in the background
console.log(event.type, event.data.object.id);
res.sendStatus(200);
});
app.listen(8000);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["KSEF_WEBHOOK_SECRET"].encode() # whsec_...
@app.post("/ksef/webhook")
def ksef_webhook():
payload = request.get_data() # raw body bytes
header = request.headers.get("KSeF-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts.get("t", "0"))
except ValueError:
abort(400)
if not t or abs(time.time() - t) > 300 or "v1" not in parts:
abort(400)
expected = hmac.new(SECRET, f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts["v1"]):
abort(400)
event = request.get_json()
# Drop duplicates by KSeF-Event-Id, store the event, do the rest in the background
print(event["type"], event["data"]["object"]["id"])
return "", 200The Shell tab sends your receiver a test event signed exactly like ours, so you can check verification locally without KSeF.
Responses and retries
Any 2xx answer counts as delivered. Answer quickly and do heavy work in the background. When the answer is not 2xx or never comes, we retry:
| Attempt | After |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
| 6 | 10 hours |
| 7 | 24 hours |
| 8 | 24 hours |
After eight retries (about 3 days in total) we stop, but the event stays at /v1/events.
Duplicates and order
Delivery is at least once and not ordered. The same event can arrive twice, so store handled KSeF-Event-Id values and skip repeats. When you need the current state, fetch the document with GET /v1/invoices/{id}.
Reconciling with the event list
If your server was down, fetch the events you did not handle:
curl "https://app.ksefshop.pl/v1/events?limit=10" \
-H "Authorization: Bearer $KSEF_API_KEY"<?php
$ch = curl_init('https://app.ksefshop.pl/v1/events?limit=10');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('KSEF_API_KEY'),
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$data = json_decode($body, true);
if ($status >= 400) {
throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
}
echo count($data['data']), ' objects', PHP_EOL;const res = await fetch("https://app.ksefshop.pl/v1/events?limit=10", {
headers: {
Authorization: `Bearer ${process.env.KSEF_API_KEY}`,
},
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data.data.length, data.has_more);import os
import requests
resp = requests.get(
"https://app.ksefshop.pl/v1/events?limit=10",
headers={
"Authorization": f"Bearer {os.environ['KSEF_API_KEY']}",
},
timeout=30,
)
data = resp.json()
if not resp.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(len(data["data"]), data["has_more"])Local testing
Our server has to reach your URL, so localhost will not work. For tests use a tunnel (e.g. ngrok or Cloudflare Tunnel) and register its URL in test mode.