Przewodniki
Idempotencja i bezpieczne ponawianie
Faktura w KSeF to dokument z numerem bez luk, więc dubel jest gorszy niż opóźnienie. Dwa mechanizmy pilnują, żeby każde zamówienie dało dokładnie jedną fakturę.
Po co to jest
Wysłałeś zamówienie, a połączenie zerwało się przed odpowiedzią. Czy faktura powstała? Bez idempotencji ponowienie mogłoby wystawić drugą fakturę z kolejnym numerem i wysłać ją do KSeF. Z idempotencją ponawiasz bez obaw: dostaniesz tę samą fakturę.
Dwa poziomy ochrony
- Nagłówek
Idempotency-Keyw każdymPOST. Pierwszą odpowiedź pamiętamy przez 24 godziny i zwracamy ją dla tego samego klucza i tej samej treści. - Naturalny klucz zamówienia. Niezależnie od nagłówka jedno
order.external_iddaje jedną fakturę, a jednorefund.external_idjedną korektę. Ponowne wysłanie zamówienia zwraca istniejącą fakturę z kodem200zamiast201, także po 24 godzinach.
Jak działa Idempotency-Key
| Sytuacja | Odpowiedź |
|---|---|
| Pierwsze żądanie z kluczem | Zwykła odpowiedź, np. 201 |
| Ten sam klucz i ta sama treść w ciągu 24 h | Ta sama odpowiedź i nagłówek Idempotent-Replayed: true |
| Ten sam klucz, inna treść | 409 idempotency_key_reused |
| Klucz pusty lub dłuższy niż 255 znaków | 400 idempotency_key_invalid |
curl -X POST https://app.ksefshop.pl/v1/invoices \
-H "Authorization: Bearer $KSEF_API_KEY" \
-H "Idempotency-Key: order-SO-1001" \
-H "Content-Type: application/json" \
-d '{
"order": {
"external_id": "SO-1001",
"number": "1001",
"paid_at": "2026-10-19T09:13:05Z",
"currency": "PLN",
"payment_method": "card",
"buyer": {
"nip": "5260250274",
"name": "Testowa Firma sp. z o.o.",
"address_line1": "ul. Prosta 20",
"address_line2": "00-850 Warszawa",
"email": "jan@example.com"
},
"lines": [
{
"name": "Kubek ceramiczny 350 ml",
"sku": "KUB-350",
"quantity": 2,
"unit_price": "49.00",
"vat_rate": "23"
},
{
"name": "Książka: Podstawy księgowości",
"sku": "KS-001",
"quantity": 1,
"unit_price": "59.90",
"discount": "9.90",
"vat_rate": "5"
}
],
"shipping": [
{
"name": "Kurier InPost",
"price": "15.99",
"vat_rate": "23"
}
]
}
}'<?php
$payload = [
'order' => [
'external_id' => 'SO-1001',
'number' => '1001',
'paid_at' => '2026-10-19T09:13:05Z',
'currency' => 'PLN',
'payment_method' => 'card',
'buyer' => [
'nip' => '5260250274',
'name' => 'Testowa Firma sp. z o.o.',
'address_line1' => 'ul. Prosta 20',
'address_line2' => '00-850 Warszawa',
'email' => 'jan@example.com',
],
'lines' => [
[
'name' => 'Kubek ceramiczny 350 ml',
'sku' => 'KUB-350',
'quantity' => 2,
'unit_price' => '49.00',
'vat_rate' => '23',
],
[
'name' => 'Książka: Podstawy księgowości',
'sku' => 'KS-001',
'quantity' => 1,
'unit_price' => '59.90',
'discount' => '9.90',
'vat_rate' => '5',
],
],
'shipping' => [
['name' => 'Kurier InPost', 'price' => '15.99', 'vat_rate' => '23'],
],
],
];
$ch = curl_init('https://app.ksefshop.pl/v1/invoices');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('KSEF_API_KEY'),
'Idempotency-Key: order-SO-1001',
'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['status'], true), PHP_EOL;const res = await fetch("https://app.ksefshop.pl/v1/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KSEF_API_KEY}`,
"Idempotency-Key": "order-SO-1001",
"Content-Type": "application/json",
},
body: JSON.stringify({
order: {
external_id: "SO-1001",
number: "1001",
paid_at: "2026-10-19T09:13:05Z",
currency: "PLN",
payment_method: "card",
buyer: {
nip: "5260250274",
name: "Testowa Firma sp. z o.o.",
address_line1: "ul. Prosta 20",
address_line2: "00-850 Warszawa",
email: "jan@example.com",
},
lines: [
{
name: "Kubek ceramiczny 350 ml",
sku: "KUB-350",
quantity: 2,
unit_price: "49.00",
vat_rate: "23",
},
{
name: "Książka: Podstawy księgowości",
sku: "KS-001",
quantity: 1,
unit_price: "59.90",
discount: "9.90",
vat_rate: "5",
},
],
shipping: [
{ name: "Kurier InPost", price: "15.99", vat_rate: "23" },
],
},
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
console.log(data.id, data.status);import os
import requests
resp = requests.post(
"https://app.ksefshop.pl/v1/invoices",
headers={
"Authorization": f"Bearer {os.environ['KSEF_API_KEY']}",
"Idempotency-Key": "order-SO-1001",
},
json={
"order": {
"external_id": "SO-1001",
"number": "1001",
"paid_at": "2026-10-19T09:13:05Z",
"currency": "PLN",
"payment_method": "card",
"buyer": {
"nip": "5260250274",
"name": "Testowa Firma sp. z o.o.",
"address_line1": "ul. Prosta 20",
"address_line2": "00-850 Warszawa",
"email": "jan@example.com",
},
"lines": [
{
"name": "Kubek ceramiczny 350 ml",
"sku": "KUB-350",
"quantity": 2,
"unit_price": "49.00",
"vat_rate": "23",
},
{
"name": "Książka: Podstawy księgowości",
"sku": "KS-001",
"quantity": 1,
"unit_price": "59.90",
"discount": "9.90",
"vat_rate": "5",
},
],
"shipping": [
{"name": "Kurier InPost", "price": "15.99", "vat_rate": "23"},
],
},
},
timeout=30,
)
data = resp.json()
if not resp.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
print(data["id"], data["status"])Jak dobrać klucz
Klucz ma opisywać operację, a nie próbę. Dobre klucze to order-SO-1001 dla faktury i refund-RF-1 dla korekty. Losowy UUID też jest w porządku, jeśli wygenerujesz go raz i zapiszesz razem z zamówieniem. Nowy UUID przy każdej próbie niszczy cały sens idempotencji.
Co ponawiać
- Błąd sieci lub timeout: ponów z tym samym kluczem.
429 rate_limited: odczekaj tyle sekund, ile mówiRetry-After.5xx: ponów z wykładniczym opóźnieniem (1, 2, 4, 8 s...).- Pozostałe
4xx: nie ponawiaj, popraw żądanie. Wyjątek to409 pdf_not_readyprzy pobieraniu PDF.
# --retry ponawia przy błędach sieci, 429 i 5xx i respektuje Retry-After
curl --retry 5 --retry-connrefused -X POST https://app.ksefshop.pl/v1/invoices \
-H "Authorization: Bearer $KSEF_API_KEY" \
-H "Idempotency-Key: order-SO-1001" \
-H "Content-Type: application/json" \
-d @order-SO-1001.json<?php
function create_invoice(array $order): array
{
$key = 'order-' . $order['external_id']; // ten sam klucz przy każdej próbie
for ($attempt = 0; $attempt < 5; $attempt++) {
$ch = curl_init('https://app.ksefshop.pl/v1/invoices');
$retryAfter = 0;
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('KSEF_API_KEY'),
'Idempotency-Key: ' . $key,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['order' => $order], JSON_UNESCAPED_UNICODE),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, 'Retry-After:') === 0) {
$retryAfter = (int) trim(substr($line, 12));
}
return strlen($line);
},
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($body !== false && $status < 500 && $status !== 429) {
$data = json_decode($body, true);
if ($status >= 400) {
throw new RuntimeException($data['error']['code'] . ': ' . $data['error']['message']);
}
return $data; // 201 nowa, 200 istniejąca lub powtórka
}
sleep($retryAfter ?: 2 ** $attempt);
}
throw new RuntimeException('KSeF Faktury API unavailable, retry later');
}async function createInvoice(order) {
const key = `order-${order.external_id}`; // ten sam klucz przy każdej próbie
for (let attempt = 0; attempt < 5; attempt++) {
let res = null;
try {
res = await fetch("https://app.ksefshop.pl/v1/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.KSEF_API_KEY}`,
"Idempotency-Key": key,
"Content-Type": "application/json",
},
body: JSON.stringify({ order }),
signal: AbortSignal.timeout(30_000),
});
} catch {
// błąd sieci albo timeout: ponów
}
if (res && res.status < 500 && res.status !== 429) {
const data = await res.json();
if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`);
return data; // 201 nowa, 200 istniejąca lub powtórka
}
const wait = Number(res?.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000));
}
throw new Error("KSeF Faktury API unavailable, retry later");
}import os
import time
import requests
def create_invoice(order: dict) -> dict:
key = f"order-{order['external_id']}" # ten sam klucz przy każdej próbie
for attempt in range(5):
resp = None
try:
resp = requests.post(
"https://app.ksefshop.pl/v1/invoices",
headers={
"Authorization": f"Bearer {os.environ['KSEF_API_KEY']}",
"Idempotency-Key": key,
},
json={"order": order},
timeout=30,
)
except requests.RequestException:
pass # błąd sieci albo timeout: ponów
if resp is not None and resp.status_code < 500 and resp.status_code != 429:
data = resp.json()
if not resp.ok:
raise RuntimeError(f"{data['error']['code']}: {data['error']['message']}")
return data # 201 nowa, 200 istniejąca lub powtórka
retry_after = resp.headers.get("Retry-After") if resp is not None else None
time.sleep(int(retry_after) if retry_after else 2**attempt)
raise RuntimeError("KSeF Faktury API unavailable, retry later")Idempotencja dotyczy wszystkich POST: /v1/invoices, /v1/invoices/{id}/corrections i /v1/webhook_endpoints. Żądania GET i DELETE są idempotentne z natury.