Jak poprawnie obsłużyć webhooki płatności?
Zweryfikuj podpis HMAC na surowej treści żądania i sprawdź znacznik czasu. Zapisz identyfikator zdarzenia i obsłuż każde zdarzenie dokładnie raz, bo ten sam webhook może przyjść kilka razy. Odpowiedz szybko kodem 2xx, a cięższą pracę przenieś do kolejki.
Czym są webhooki płatności i dlaczego są źródłem prawdy
Webhook to żądanie HTTP POST, które system płatności wysyła na adres Twojego serwera, gdy coś się wydarzy: płatność została utworzona, opłacona, odrzucona, zwrócona. W Oplac najważniejsze zdarzenia to payment.created, payment.succeeded i payment.failed. Twój serwer dowiaduje się o zmianie statusu niezależnie od tego, co zrobił klient w przeglądarce.
- Przekierowanie po płatności służy do pokazania klientowi komunikatu, nie do zmiany statusu zamówienia.
- Webhook przychodzi także wtedy, gdy klient nigdy nie wrócił na Twoją stronę.
- Status zamówienia zmieniasz wyłącznie na podstawie zweryfikowanego webhooka albo zapytania do API.
Ta zasada zamyka całą klasę błędów, w których zamówienie jest oznaczone jako opłacone, bo ktoś ręcznie wszedł na adres strony sukcesu.
Jak podpis HMAC potwierdza, że webhook pochodzi od systemu płatności
Adres webhooka jest publiczny, więc każdy może wysłać na niego żądanie z dowolną treścią. Dlatego każdy webhook Oplac jest podpisany kluczem, który znasz tylko Ty i Oplac. Nagłówek Oplac-Signature zawiera znacznik czasu t i podpis v1, czyli HMAC SHA-256 z ciągu złożonego ze znacznika czasu, kropki i surowej treści żądania.
- Licz podpis na surowych bajtach treści, zanim framework sparsuje JSON. Ponowna serializacja zmienia kolejność pól albo odstępy i podpis się nie zgadza.
- Porównuj podpisy funkcją o stałym czasie, żeby nie ujawniać informacji przez różnicę czasu odpowiedzi.
- Odrzucaj żądania ze znacznikiem czasu starszym niż kilka minut, co chroni przed powtórzeniem przechwyconego żądania.
- Trzymaj klucz podpisu w zmiennej środowiskowej, nie w kodzie ani w repozytorium.
Weryfikacja w PHP wygląda tak:
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_OPLAC_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $payload, getenv('OPLAC_WEBHOOK_SECRET'));
if (hash_equals($expected, $parts['v1'] ?? '') === false) {
http_response_code(400);
exit;
}
if (abs(time() - (int) $parts['t']) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
W Node.js to samo robi moduł crypto:
const crypto = require('crypto');
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto
.createHmac('sha256', secret)
.update(parts.t + '.' + rawBody)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || '');
if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
}
return false;
}
Ponowienia, gdy Twój serwer nie odpowiada na webhook
Serwer bywa niedostępny: wdrożenie, restart, przeciążenie, błąd w kodzie. Dlatego system płatności ponawia webhook, jeśli nie dostał odpowiedzi 2xx w określonym czasie. Oplac ponawia dostarczenie z rosnącymi odstępami, a panel pokazuje historię prób dla każdego zdarzenia i pozwala wysłać je ponownie ręcznie.
- Odpowiadaj kodem 2xx dopiero wtedy, gdy zdarzenie jest bezpiecznie zapisane.
- Odpowiadaj szybko: zapisz zdarzenie i wróć, a wysyłkę maila czy wystawienie faktury wykonaj w kolejce.
- Kod 4xx albo 5xx oznacza dla nadawcy, że trzeba spróbować ponownie.
- Nie zwracaj błędu dla zdarzeń, których nie obsługujesz. Odpowiedz 2xx i zignoruj je, inaczej będą wracać.
Ponowienia są dobre, bo nic nie ginie. Mają jednak skutek uboczny: to samo zdarzenie może dotrzeć do Ciebie więcej niż raz.
Idempotencja, dzięki której każde zdarzenie obsłużysz dokładnie raz
Idempotencja oznacza, że powtórzenie tej samej operacji daje ten sam wynik co jednokrotne wykonanie. Przy webhookach to podstawa: jeśli payment.succeeded przyjdzie dwa razy, klient nie może dostać dwóch licencji, dwóch faktur ani dwóch maili z potwierdzeniem.
- Każde zdarzenie ma unikalny identyfikator. Zapisuj go w tabeli z ograniczeniem unikalności.
- Zapis identyfikatora i zmiana stanu zamówienia idą w jednej transakcji bazy danych.
- Jeśli identyfikator już istnieje, odpowiedz 2xx i nic więcej nie rób.
- Zmieniaj stan warunkowo: ustaw „opłacone” tylko wtedy, gdy zamówienie jest jeszcze „oczekujące”.
INSERT INTO webhook_events (event_id, received_at) VALUES (:event_id, NOW());
-- błąd unikalności oznacza, że zdarzenie było już obsłużone
UPDATE orders SET status = 'paid'
WHERE id = :order_id AND status = 'pending';
Idempotencja działa też w drugą stronę: gdy Twój serwer tworzy płatność albo zwrot przez API, wysyła nagłówek Idempotency-Key. Jeśli połączenie zerwie się po wysłaniu żądania, ponowienie z tym samym kluczem nie utworzy drugiej płatności, tylko zwróci wynik pierwszej. Tak zabezpieczone są endpointy tworzące płatności i zwroty w API płatności.
Kolejność zdarzeń nie jest gwarantowana
Webhooki mogą przychodzić w innej kolejności, niż zdarzenia nastąpiły. Ponowienie starszego zdarzenia może dotrzeć po nowszym, a dwa zdarzenia wysłane w tej samej sekundzie mogą się wyprzedzić w sieci. Kod, który zakłada kolejność, prędzej czy później cofnie status zamówienia.
-
Nie cofaj stanu: zdarzenie
payment.failednie powinno zmienić płatności, która jest już opłacona. - Przy wątpliwości pobierz aktualny stan płatności z API zamiast polegać na treści webhooka.
- Zapisuj znacznik czasu zdarzenia i ignoruj starsze od ostatnio zastosowanego.
Architektura odbiornika webhooków płatności
Odbiornik webhooków płatności najlepiej działa jako cienka warstwa: przyjmuje żądanie, weryfikuje podpis, zapisuje zdarzenie i odpowiada. Cała logika biznesowa, czyli zmiana statusu zamówienia, wystawienie faktury, wysyłka maila czy nadanie dostępu, dzieje się w procesie roboczym, który czyta zdarzenia z kolejki. Taki podział ma trzy zalety.
- Odpowiedź dla nadawcy jest szybka i nie zależy od wolnych usług, na przykład serwera pocztowego albo KSeF.
- Błąd w logice biznesowej nie powoduje ponowień webhooka, bo zdarzenie jest już zapisane, a proces roboczy ponawia pracę sam.
- Masz pełną historię zdarzeń w bazie i możesz przetworzyć je ponownie po naprawie błędu.
Tabela zdarzeń przechowuje identyfikator zdarzenia, typ, surową treść, znacznik czasu, stan przetworzenia i liczbę prób. Proces roboczy bierze zdarzenia w stanie „nowe”, przetwarza je w transakcji i oznacza jako „obsłużone”. Zdarzenia, które kilka razy z rzędu kończą się błędem, trafiają do osobnego stanu, który ktoś z zespołu przegląda.
Rotacja kluczy podpisu i osobne środowiska
Klucz podpisu webhooków jest sekretem tego samego rzędu co klucz API. Piaskownica i produkcja mają osobne klucze, więc zdarzenie testowe nigdy nie przejdzie weryfikacji na produkcji. Gdy klucz trzeba wymienić, na przykład po odejściu osoby z dostępem, przez krótki czas Twój kod powinien akceptować podpis starym i nowym kluczem, a potem stary usuwasz.
- Osobny klucz dla każdego adresu webhooka.
- Klucze w menedżerze sekretów albo zmiennych środowiskowych, nigdy w logach.
- Log odrzuconych podpisów z adresem IP i znacznikiem czasu, bez treści sekretu.
- Alert, gdy liczba odrzuconych podpisów nagle rośnie.
Monitorowanie dostarczania
Webhooki działają w tle, więc ich awaria jest cicha: zamówienia przestają zmieniać status, a zespół dowiaduje się o tym od klientów. Warto mierzyć czas od utworzenia zdarzenia do jego obsłużenia i liczbę zdarzeń czekających w kolejce. Panel Oplac pokazuje dla każdego adresu historię dostarczeń, kody odpowiedzi i czas odpowiedzi, więc widzisz problem po swojej stronie, zanim zauważą go klienci.
Jak testować webhooki przed produkcją
- Użyj piaskownicy z kluczem testowym i osobnym kluczem podpisu.
- Wyślij to samo zdarzenie dwa razy i sprawdź, że stan zmienił się raz.
- Zmień jeden bajt treści i sprawdź, że podpis zostaje odrzucony.
- Wyłącz serwer na chwilę i sprawdź, że ponowienie dociera po jego powrocie.
- Wyślij zdarzenia w odwrotnej kolejności i sprawdź, że stan się nie cofa.
Symulator na stronie Oplac pokazuje payload, nagłówek podpisu i znaczniki czasu dla payment.created, payment.succeeded i payment.failed, z podpisem liczonym w przeglądarce kluczem testowym. Możesz na nim sprawdzić swój kod weryfikacji, zanim założysz konto. Pełną specyfikację zdarzeń znajdziesz w dokumentacji API, a architekturę bezpieczeństwa na stronie bezpieczeństwo płatności internetowych.
Lista kontrolna przed uruchomieniem webhooków na produkcji
- Adres webhooka działa po HTTPS i nie wymaga logowania ani ciasteczek.
- Podpis jest weryfikowany na surowej treści, a znacznik czasu ma limit wieku.
- Identyfikator zdarzenia ma ograniczenie unikalności w bazie.
- Odpowiedź 2xx wraca szybko, a praca biznesowa idzie w kolejce.
- Stan zamówienia nigdy się nie cofa z opłaconego na nieopłacone.
- Zespół dostaje alert, gdy kolejka zdarzeń rośnie albo rośnie liczba odrzuconych podpisów.
Jeśli wszystkie punkty są spełnione, webhooki płatności przestają być źródłem nocnych telefonów i stają się najpewniejszą częścią integracji.