Webhooki
Po każdej zmianie stanu płatności wysyłamy POST ze zdarzeniem na adres z panelu, z podpisem HMAC-SHA256. Odbiornik sprawdza podpis, przetwarza zdarzenie idempotentnie i odpowiada 2xx. Nieudane dostawy ponawiamy 7 razy, a każde zdarzenie możesz pobrać i ponowić przez API.
Typy zdarzeń #
Każde zdarzenie dotyczy jednej płatności i niesie ją w całości w polu transakcja. Przy zwrotach dochodzi pole zwrot. Kolejność dostaw nie jest gwarantowana, więc decyzje podejmuj po transakcja.status, nie po kolejności webhooków.
| Typ | Kiedy | Co zrobić |
|---|---|---|
| platnosc.oczekuje | Klient poszedł do operatora (produkcja) | Płatność produkcyjna czeka na wynik u operatora. W piaskownicy to zdarzenie nie występuje. |
| platnosc.oplacona | Płatność zaksięgowana | Można wydać towar albo usługę. transakcja.oplacona ma czas księgowania, transakcja.metoda użytą metodę. |
| platnosc.odrzucona | Bank albo operator odmówił | Klient może spróbować ponownie z tego samego adres_platnosci (inną metodą albo kartą); wtedy dostaniesz kolejne zdarzenia tej samej płatności. |
| platnosc.zwrot | Zwrot wykonany albo zlecony | Treść ma dodatkowe pole zwrot (schemat Zwrot) z nowym zwrotem; transakcja.zwrocono i transakcja.status są już po zmianie. |
| platnosc.zwrot_odrzucony | Operator odrzucił zlecony zwrot | Kwota wraca do do_zwrotu, zwrot w polu zwrot ma status odrzucony. Dotyczy tylko produkcji. |
| platnosc_cykliczna.aktywna | Zgoda klienta i pierwsze obciążenie | Treść ma pole platnosc_cykliczna (schemat PlatnoscCykliczna) i transakcja pierwszego okresu. |
| platnosc_cykliczna.odnowiona | Kolejny okres opłacony | transakcja to obciążenie tego okresu, platnosc_cykliczna.nastepne_obciazenie to termin następnego. |
| platnosc_cykliczna.odmowa | Obciążenie odrzucone, będzie ponowione | platnosc_cykliczna.proby to liczba odmów w tym okresie, nastepne_obciazenie to termin ponowienia. |
| platnosc_cykliczna.wstrzymana | Ponowienia wyczerpane | Harmonogram stoi. Klient może podać nową kartę pod adres_zgody, wtedy przyjdzie platnosc_cykliczna.odnowiona. |
| platnosc_cykliczna.anulowana | Rezygnacja klienta albo sklepu | anulowana_przez to klient albo sklep. Kolejnych obciążeń nie będzie. |
Format żądania #
Żądanie to POST z treścią application/json na adres z panelu (tylko https, publiczny host). Nagłówki Placenie-Event-Id i Placenie-Event-Type powtarzają id i typ z treści, żeby odbiornik mógł odrzucić duplikat albo nieobsługiwany typ bez parsowania JSON. Treść to obiekt zdarzenie; pole webhook ze stanem dostawy jest tylko w odpowiedziach API, nie w samym webhooku.
POST /placenie/webhook HTTP/1.1
Host: twojsklep.pl
Content-Type: application/json
User-Agent: Placenie-Webhook/1
Placenie-Event-Id: evt_0a1b2c3d4e5f6g7h8i9j
Placenie-Event-Type: platnosc.oplacona
Placenie-Signature: t=1759490472,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8c2
{
"obiekt": "zdarzenie",
"id": "evt_0a1b2c3d4e5f6g7h8i9j",
"typ": "platnosc.oplacona",
"tryb": "test",
"utworzone": "2026-10-03T10:01:12+00:00",
"transakcja": {
"obiekt": "platnosc",
"id": "tr_8h2k1j0f9e8d7c6b5a4z",
"tryb": "test",
"status": "oplacona",
"kwota": 12900,
"waluta": "PLN",
"zwrocono": 0,
"do_zwrotu": 12900,
"numer_zamowienia": "ZAM-2026-001",
"opis": "Zamówienie ZAM-2026-001",
"email_klienta": "[email protected]",
"adres_powrotu": "https://twojsklep.pl/zamowienie/ZAM-2026-001/dziekujemy",
"metoda": "blik",
"metadane": {
"koszyk": "c_8812",
"klient_crm": "4471"
},
"adres_platnosci": "https://placenie.pl/zaplac/tr_8h2k1j0f9e8d7c6b5a4z",
"utworzona": "2026-10-03T10:00:00+00:00",
"oplacona": "2026-10-03T10:01:12+00:00",
"zwroty": []
}
}
{
"obiekt": "zdarzenie",
"id": "evt_1b2c3d4e5f6g7h8i9j0k",
"typ": "platnosc.zwrot",
"tryb": "test",
"utworzone": "2026-10-03T12:30:00+00:00",
"transakcja": {
"obiekt": "platnosc",
"id": "tr_8h2k1j0f9e8d7c6b5a4z",
"tryb": "test",
"status": "oplacona",
"kwota": 12900,
"waluta": "PLN",
"zwrocono": 2900,
"do_zwrotu": 10000,
"numer_zamowienia": "ZAM-2026-001",
"opis": "Zamówienie ZAM-2026-001",
"email_klienta": "[email protected]",
"adres_powrotu": "https://twojsklep.pl/zamowienie/ZAM-2026-001/dziekujemy",
"metoda": "blik",
"metadane": {
"koszyk": "c_8812",
"klient_crm": "4471"
},
"adres_platnosci": "https://placenie.pl/zaplac/tr_8h2k1j0f9e8d7c6b5a4z",
"utworzona": "2026-10-03T10:00:00+00:00",
"oplacona": "2026-10-03T10:01:12+00:00",
"zwroty": [
{
"obiekt": "zwrot",
"id": "zw_9f8e7d6c5b4a3z2y1x0w",
"platnosc": "tr_8h2k1j0f9e8d7c6b5a4z",
"kwota": 2900,
"waluta": "PLN",
"status": "wykonany",
"zrodlo": "api",
"utworzony": "2026-10-03T12:30:00+00:00"
}
]
},
"zwrot": {
"obiekt": "zwrot",
"id": "zw_9f8e7d6c5b4a3z2y1x0w",
"platnosc": "tr_8h2k1j0f9e8d7c6b5a4z",
"kwota": 2900,
"waluta": "PLN",
"status": "wykonany",
"zrodlo": "api",
"utworzony": "2026-10-03T12:30:00+00:00"
}
}
Podpis i jego weryfikacja #
Nagłówek Placenie-Signature ma postać t=<czas unix>,v1=<hex>. v1 to HMAC-SHA256 z tekstu <t>.<surowa treść żądania>, liczony sekretem podpisu z panelu (Integracja i klucze, Sekret podpisu). Znacznik t chroni przed odtworzeniem starego żądania: odrzuć, gdy różni się od bieżącego czasu o więcej niż 300 sekund.
- Licz podpis z surowych bajtów treści, nie z obiektu po parsowaniu i ponownej serializacji.
- Porównuj w stałym czasie:
hash_equals,crypto.timingSafeEqual,hmac.compare_digest. - Dziś nagłówek niesie jeden podpis
v1. Weryfikacja, która przyjmuje żądanie, gdy pasuje którykolwiek z podpisówv1w nagłówku, pozostanie poprawna także przy więcej niż jednym. - Po kliknięciu Wymień sekret w panelu nowe dostawy są podpisane nowym sekretem od razu. Podmień sekret w odbiorniku w tym samym kroku.
# 1. Rozbij naglowek na t i v1
Placenie-Signature: t=1759490472,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8c2
# 2. Odrzuc, gdy |teraz - t| > 300 sekund
# 3. Policz HMAC-SHA256 sekretem z panelu z tekstu "<t>.<surowa tresc zadania>"
hmac_sha256(sekret, "1759490472." + tresc) == v1
# 4. Porownaj w stalym czasie (hash_equals, timingSafeEqual, hmac.compare_digest)
<?php
// Odbiornik webhooka Placenie: sprawdzenie podpisu i odpowiedz 200.
// $sekret to „Sekret podpisu” z panelu (Integracja i klucze), trzymany w konfiguracji.
function placenieSprawdzPodpis(string $naglowek, string $tresc, string $sekret, int $tolerancja = 300): bool
{
$czas = null;
$podpisy = [];
foreach (explode(',', $naglowek) as $czesc) {
[$k, $v] = array_pad(explode('=', trim($czesc), 2), 2, null);
if ($k === 't' && ctype_digit((string) $v)) {
$czas = (int) $v;
} elseif ($k === 'v1' && $v !== null) {
$podpisy[] = $v;
}
}
if ($czas === null || $podpisy === [] || abs(time() - $czas) > $tolerancja) {
return false;
}
$oczekiwany = hash_hmac('sha256', $czas.'.'.$tresc, $sekret);
foreach ($podpisy as $p) {
if (hash_equals($oczekiwany, $p)) {
return true;
}
}
return false;
}
$tresc = file_get_contents('php://input');
$sekret = getenv('PLACENIE_WEBHOOK_SEKRET');
if (! placenieSprawdzPodpis($_SERVER['HTTP_PLACENIE_SIGNATURE'] ?? '', $tresc, $sekret)) {
http_response_code(400);
exit('Nieprawidłowy podpis');
}
$zdarzenie = json_decode($tresc, true);
// Idempotencja: ten sam identyfikator zdarzenia moze przyjsc wiecej niz raz.
if (zdarzenieJuzPrzetworzone($zdarzenie['id'])) {
http_response_code(200);
exit;
}
$transakcja = $zdarzenie['transakcja'];
if ($zdarzenie['typ'] === 'platnosc.oplacona') {
oznaczZamowienieJakoOplacone($transakcja['numer_zamowienia'], $transakcja['id'], $transakcja['kwota']);
}
zapiszPrzetworzone($zdarzenie['id']);
http_response_code(200);
// Odbiornik webhooka Placenie (Node 18+, Express). Potrzebna SUROWA tresc zadania,
// wiec express.raw zamiast express.json: podpis liczy sie z bajtow, nie z obiektu.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCJA_S = 300;
export function sprawdzPodpis(naglowek, tresc, sekret) {
const czesci = Object.fromEntries(
String(naglowek || '')
.split(',')
.map((c) => c.trim().split('=', 2)),
);
const czas = Number(czesci.t);
if (!Number.isInteger(czas) || !czesci.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - czas) > TOLERANCJA_S) return false;
const oczekiwany = createHmac('sha256', sekret).update(`${czas}.${tresc}`).digest('hex');
const a = Buffer.from(oczekiwany, 'hex');
const b = Buffer.from(czesci.v1, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
const app = express();
app.post('/placenie/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const tresc = req.body.toString('utf8');
if (!sprawdzPodpis(req.get('Placenie-Signature'), tresc, process.env.PLACENIE_WEBHOOK_SEKRET)) {
return res.status(400).send('Nieprawidłowy podpis');
}
const zdarzenie = JSON.parse(tresc);
// Idempotencja po identyfikatorze zdarzenia (ten sam w naglowku Placenie-Event-Id).
if (await zdarzenieJuzPrzetworzone(zdarzenie.id)) return res.sendStatus(200);
if (zdarzenie.typ === 'platnosc.oplacona') {
const t = zdarzenie.transakcja;
await oznaczZamowienieJakoOplacone(t.numer_zamowienia, t.id, t.kwota);
}
await zapiszPrzetworzone(zdarzenie.id);
res.sendStatus(200);
});
# Odbiornik webhooka Placenie (Flask). Podpis liczy sie z surowych bajtow zadania.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
TOLERANCJA_S = 300
app = Flask(__name__)
def sprawdz_podpis(naglowek: str, tresc: bytes, sekret: str) -> bool:
czesci = dict(c.strip().split("=", 1) for c in (naglowek or "").split(",") if "=" in c)
try:
czas = int(czesci["t"])
podpis = czesci["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - czas) > TOLERANCJA_S:
return False
oczekiwany = hmac.new(sekret.encode(), f"{czas}.".encode() + tresc, hashlib.sha256).hexdigest()
return hmac.compare_digest(oczekiwany, podpis)
@app.post("/placenie/webhook")
def webhook():
tresc = request.get_data()
if not sprawdz_podpis(request.headers.get("Placenie-Signature", ""), tresc, os.environ["PLACENIE_WEBHOOK_SEKRET"]):
abort(400)
zdarzenie = json.loads(tresc)
# Idempotencja po identyfikatorze zdarzenia (ten sam w naglowku Placenie-Event-Id).
if zdarzenie_juz_przetworzone(zdarzenie["id"]):
return "", 200
if zdarzenie["typ"] == "platnosc.oplacona":
t = zdarzenie["transakcja"]
oznacz_zamowienie_jako_oplacone(t["numer_zamowienia"], t["id"], t["kwota"])
zapisz_przetworzone(zdarzenie["id"])
return "", 200
Ponowienia #
Dostawę kończy dowolna odpowiedź 2xx w ciągu 8 sekund (treść odpowiedzi ignorujemy). Każda inna odpowiedź, przekroczenie czasu albo błąd połączenia uruchamia ponowienia w rosnących odstępach. Po ostatniej nieudanej próbie dostawa dostaje status porzucony; zdarzenie dalej jest dostępne przez GET /v1/zdarzenia/{id} i można je ponowić.
| Próba | Kiedy | Łącznie od zdarzenia |
|---|---|---|
| 1 | od razu po zdarzeniu | 0 |
| 2 | po 1 min od poprzedniej | 1 min |
| 3 | po 5 min od poprzedniej | 6 min |
| 4 | po 15 min od poprzedniej | 21 min |
| 5 | po 1 h od poprzedniej | 1 h 21 min |
| 6 | po 3 h od poprzedniej | 4 h 21 min |
| 7 | po 6 h od poprzedniej | 10 h 21 min |
| 8 | po 12 h od poprzedniej | 22 h 21 min |
Odpowiadaj 2xx od razu po zapisaniu zdarzenia, a cięższą pracę (wysyłka e-maila, fakturowanie) wykonuj po odpowiedzi albo w kolejce. Odbiornik, który liczy coś przez 10 sekund, dostanie to samo zdarzenie jeszcze raz.
Idempotentne przetwarzanie #
To samo zdarzenie może dotrzeć więcej niż raz: po ponowieniu, gdy Twoja odpowiedź 2xx nie dotarła do nas, albo po ręcznym ponowieniu z panelu czy przez API. Ponowna dostawa ma nowy podpis, ale ten sam id (Placenie-Event-Id).
- Zapisuj
idprzetworzonych zdarzeń (tabela z unikalnym indeksem albo klucz w pamięci podręcznej z czasem życia kilku dni). - Gdy
idjuż jest, odpowiedz 2xx bez ponownego przetwarzania. - Decyzję podejmuj po
transakcja.statusitransakcja.zwrocono, nie po typie zdarzenia ani po kolejności dostaw. Zdarzenieplatnosc.odrzuconamoże dotrzeć poplatnosc.oplacona, jeśli klient zapłacił w drugiej próbie, a pierwsza dostawa czekała na ponowienie. - W razie wątpliwości potwierdź stan u źródła:
GET /v1/platnosci/{id}alboGET /v1/zdarzenia/{id}.
Testowanie #
- Uruchom odbiornik pod publicznym adresem
https. Lokalnie: tunel (cloudflared, ngrok). Adresy prywatne ihttpsą odrzucane przy zapisie. - Wpisz adres w panelu (Integracja i klucze, Webhook o statusie) i skopiuj sekret podpisu.
- Utwórz płatność kluczem testowym i ustaw wynik: przez bank testowy (BLIK
777777) albo przezPOST /v1/platnosci/{id}/symulacja. Webhook wychodzi tą samą drogą co w produkcji. - Stan dostawy sprawdzisz w polu
webhookzdarzenia:status(czeka,dostarczony,porzucony),proby,ostatni_kod,ostatni_blad,nastepna_proba. To samo widać w panelu przy transakcji.
curl -X POST "https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja" \
-H "Authorization: Bearer plc_test_..." \
-H "Content-Type: application/json" \
-d '{
"wynik": "oplacona",
"metoda": "blik"
}'
$ch = curl_init('https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer plc_test_...',
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'wynik' => 'oplacona',
'metoda' => 'blik',
]),
CURLOPT_RETURNTRANSFER => true,
]);
$platnosc = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($platnosc['blad']['kod'].': '.$platnosc['blad']['opis']);
}
const odpowiedz = await fetch('https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja', {
method: 'POST',
headers: {
Authorization: 'Bearer plc_test_...',
'Content-Type': 'application/json',
},
body: JSON.stringify({
wynik: 'oplacona',
metoda: 'blik',
}),
});
const platnosc = await odpowiedz.json();
if (!odpowiedz.ok) {
throw new Error(`${platnosc.blad.kod}: ${platnosc.blad.opis}`);
}
import requests
odpowiedz = requests.post(
"https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja",
headers={"Authorization": "Bearer plc_test_..."},
json={
"wynik": "oplacona",
"metoda": "blik",
},
)
platnosc = odpowiedz.json()
if not odpowiedz.ok:
raise RuntimeError(f"{platnosc['blad']['kod']}: {platnosc['blad']['opis']}")
{
"obiekt": "zdarzenie",
"id": "evt_0a1b2c3d4e5f6g7h8i9j",
"typ": "platnosc.oplacona",
"tryb": "test",
"utworzone": "2026-10-03T10:01:12+00:00",
"transakcja": { "obiekt": "platnosc", "id": "tr_8h2k1j0f9e8d7c6b5a4z", "status": "oplacona" },
"webhook": {
"status": "czeka",
"proby": 2,
"ostatni_kod": 500,
"ostatni_blad": "Odpowiedź HTTP 500",
"nastepna_proba": "2026-10-03T10:07:12+00:00"
}
}
Ponowna dostawa z panelu i przez API #
Z panelu: otwórz transakcję w zakładce Transakcje. Pod listą dostaw webhooka przy każdej pozycji jest przycisk Wyślij ponownie.
Przez API: POST /v1/zdarzenia/{id}/ponow wysyła webhook zdarzenia jeszcze raz, z nowym podpisem i tą samą treścią. Odpowiedź 202 niesie zdarzenie ze stanem nowej dostawy. Bez adresu webhooka w panelu dostaniesz 409 webhook_nieustawiony. Listę zdarzeń do ponowienia wybierzesz filtrami GET /v1/zdarzenia?typ=…&od=….
curl -X POST "https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow" \
-H "Authorization: Bearer plc_test_..." \
-d ''
$ch = curl_init('https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer plc_test_...',
],
CURLOPT_RETURNTRANSFER => true,
]);
$zdarzenie = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($zdarzenie['blad']['kod'].': '.$zdarzenie['blad']['opis']);
}
const odpowiedz = await fetch('https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow', {
method: 'POST',
headers: {
Authorization: 'Bearer plc_test_...',
},
});
const zdarzenie = await odpowiedz.json();
if (!odpowiedz.ok) {
throw new Error(`${zdarzenie.blad.kod}: ${zdarzenie.blad.opis}`);
}
import requests
odpowiedz = requests.post(
"https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow",
headers={"Authorization": "Bearer plc_test_..."},
)
zdarzenie = odpowiedz.json()
if not odpowiedz.ok:
raise RuntimeError(f"{zdarzenie['blad']['kod']}: {zdarzenie['blad']['opis']}")
curl "https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20" \
-H "Authorization: Bearer plc_test_..."
$ch = curl_init('https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Authorization: Bearer plc_test_...',
],
CURLOPT_RETURNTRANSFER => true,
]);
$lista = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
throw new RuntimeException($lista['blad']['kod'].': '.$lista['blad']['opis']);
}
const odpowiedz = await fetch('https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20', {
method: 'GET',
headers: {
Authorization: 'Bearer plc_test_...',
},
});
const lista = await odpowiedz.json();
if (!odpowiedz.ok) {
throw new Error(`${lista.blad.kod}: ${lista.blad.opis}`);
}
import requests
odpowiedz = requests.get(
"https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20",
headers={"Authorization": "Bearer plc_test_..."},
)
lista = odpowiedz.json()
if not odpowiedz.ok:
raise RuntimeError(f"{lista['blad']['kod']}: {lista['blad']['opis']}")
Lista kontrolna odbiornika #
- Weryfikuje podpis z surowej treści, w stałym czasie, z tolerancją 300 sekund.
- Odpowiada 2xx w mniej niż 8 sekund, resztę robi po odpowiedzi.
- Jest idempotentny po
idzdarzenia. - Wydaje towar tylko przy
transakcja.status == "oplacona"i sprawdzakwotaorazwalutawzględem zamówienia. - Obsługuje
platnosc.zwrotiplatnosc.zwrot_odrzucony(polezwrot,transakcja.zwrocono). - Nie polega na kolejności zdarzeń ani na powrocie klienta na
adres_powrotu. - Loguje
Placenie-Event-Idprzy każdej decyzji, żeby dało się dojść, co i kiedy się stało.