Webhooki
Webhooki powiadamiają Twój serwer o zdarzeniach na koncie dpay: opłaconych i odrzuconych płatnościach, zwrotach, płatnościach cyklicznych i wypłatach. Każde zdarzenie wysyłamy żądaniem POST z JSON-em na Twoje endpointy, z podpisem zgodnym ze Standard Webhooks i z ponowieniami przez około 3 doby.
IPN działa dalej bez zmian. Webhooki dają więcej: osobne typy zdarzeń (także odmowy z kodem przyczyny, zwroty i wypłaty), endpointy konfigurowane w panelu, log dostarczeń z ponowieniami i podpis sekretem endpointu. Oba kanały są niezależne - jeśli używasz obu, to samo zdarzenie przyjdzie dwiema drogami. Nowa integracja może działać na samych webhookach i pominąć url_ipn.
Szybki start
- W panelu panel.dpay.pl przejdź do Inne > Webhooki i kliknij Dodaj endpoint.
- Podaj adres HTTPS, wybierz zdarzenia, zakres serwisów i tryb (produkcyjny albo testowy).
- Skopiuj sekret podpisu
whsec_...- pokazujemy go tylko raz. - W swoim endpoincie zweryfikuj podpis i odpowiedz kodem
2xx. - Kliknij Wyślij testowe zdarzenie i sprawdź wynik w logu dostarczeń.
Endpointy w panelu
Zakładka Webhooki jest w menu Inne. Widzi ją konto główne i użytkownicy zespołu z uprawnieniem Klucze API. Konto może mieć do 16 endpointów.
| Pole | Opis |
|---|---|
| Adres URL | Tylko HTTPS na porcie 443, do 500 znaków, bez loginu i hasła w adresie. Adres musi prowadzić do serwera dostępnego z internetu - adresy sieci prywatnych i lokalnych oraz domeny dpay są odrzucane. |
| Opis | Dla Ciebie, np. „Sklep - produkcja”. |
| Zdarzenia | Wszystkie (także typy dodane w przyszłości) albo wybrane. |
| Zakres | Wszystkie serwisy konta (także przyszłe) albo wybrane serwisy. Zdarzenia konta, czyli wypłaty (payout.*), trafiają tylko na endpointy obejmujące wszystkie serwisy. |
| Tryb | Produkcyjny albo testowy, wybierany przy tworzeniu. Endpoint testowy dostaje tylko zdarzenia testowe (livemode: false: serwisy w trybie testowym, płatności testowe), a produkcyjny tylko produkcyjne. Dla obu trybów utwórz dwa endpointy. |
W szczegółach endpointu:
- Sekret podpisu - każdy endpoint ma własny sekret
whsec_..., pokazywany tylko przy utworzeniu i przy rotacji. Po wygenerowaniu nowego sekretu poprzedni podpisuje obok jeszcze przez 24 godziny, więc podmienisz go bez przestoju. - Wyślij testowe zdarzenie - wysyła
webhook.testtylko na ten endpoint, niezależnie od wybranych zdarzeń. Wynik zobaczysz w logu po kilku sekundach. - Log dostarczeń - każde dostarczenie ze statusem (czeka, dostarczone, nieudane) i wszystkimi próbami: kodem HTTP, czasem odpowiedzi, błędem i początkiem Twojej odpowiedzi (do 2 KB, przechowywany 90 dni). Nieudane dostarczenie możesz ponowić od razu, a przyciskiem Ponów nieudane - wszystkie nieudane z wybranego okresu (do 31 dni i 1000 dostarczeń naraz).
- Wyłącz / Włącz - wyłączenie kończy czekające dostarczenia jako nieudane. Po włączeniu możesz je ponowić z logu.
Na liście endpointów widać ostatnie dostarczenie i skuteczność z ostatnich 7 dni. Wszystkie dostarczenia zdarzeń jednej płatności - na endpointy i na adres z żądania - zobaczysz też w szczegółach transakcji w historii, na karcie Webhooki, i tam ponowisz nieudane.
Katalog zdarzeń
| Zdarzenie | Obiekt | Kiedy |
|---|---|---|
payment.succeeded | payment | Płatność opłacona. |
payment.failed | payment | Płatność odrzucona - przyczyna w failure (patrz Kody odmów) - albo wygasła, bo nie została opłacona w ciągu 7 dni (kod expired). |
payment.captured | payment | Pobranie środków z preautoryzacji karty - po każdym pobraniu, także częściowym. Pobraną kwotę niesie amount_captured, a po pobraniu całości status zmienia się na captured. |
refund.succeeded | refund | Zwrot wykonany. Zwrot BLIK jest wykonany, gdy przyjmie go BLIK. |
refund.failed | refund | Zwrot odrzucony - przyczyna w failure. |
recurring_payment.activated | recurring_payment | Płatność cykliczna aktywna - możesz obciążać jej alias. |
recurring_payment.canceled | recurring_payment | Płatność cykliczna anulowana - kto ją anulował, mówi canceled_by. |
recurring_payment.expired | recurring_payment | Płatność cykliczna wygasła. |
recurring_payment.declined | recurring_payment | Rejestracja płatności cyklicznej odrzucona. |
payout.paid | payout | Wypłata zrealizowana - poszła do banku. |
payout.failed | payout | Wypłata odrzucona. |
Obciążenia płatności cyklicznej to zwykłe zdarzenia payment.* - obiekt płatności ma wtedy wypełnione pole recurring. Zdarzenie webhook.test przychodzi tylko po kliknięciu Wyślij testowe zdarzenie i nie ma go w katalogu do wyboru.
Katalog będzie się rozszerzać, a obiekty mogą dostać nowe pola. Nieznany type i nieznane pole po prostu pomiń - nie traktuj ich jak błędu. Endpoint z zaznaczonymi wszystkimi zdarzeniami dostanie nowe typy automatycznie.
Koperta zdarzenia
Każde zdarzenie ma tę samą kopertę. Typ niesie pole type, a obiekt, którego dotyczy zdarzenie - data.object.
POST /webhooki/dpay HTTP/1.1
Host: sklep.example
Content-Type: application/json
User-Agent: dpay-webhooks/1.0 (+https://dpay.pl)
webhook-id: evt_01k6a8q2m4pz7h8c3v5n9t2x6y
webhook-timestamp: 1790503500
webhook-signature: v1,Q2n7kX0fW3mR9tYp1aZs5vE8uJ6hL4cB2dN0gK7oI1M=
{
"id": "evt_01k6a8q2m4pz7h8c3v5n9t2x6y",
"type": "payment.succeeded",
"api_version": "2026-10-01",
"created": "2026-09-27T10:05:00Z",
"livemode": true,
"service": "abc123",
"data": {
"object": {
"object": "payment",
"id": "8F2A11C4-5B6D-4E7F-8A9B-0C1D2E3F4A5B",
"status": "succeeded",
"amount": 14900,
"currency": "PLN",
"amount_refunded": 0,
"amount_captured": 0,
"description": "Zamówienie 2026/09/123",
"created": "2026-09-27T10:03:12Z",
"paid_at": "2026-09-27T10:05:00Z",
"payment_method": { "type": "blik", "blik": { "flow": "code" } },
"failure": null,
"recurring": null,
"references": { "merchant": "2026/09/123", "provider": "1234567890" },
"custom": "order-789"
}
}
}
| Pole | Opis |
|---|---|
id | Identyfikator zdarzenia (evt_...), ten sam w nagłówku webhook-id i przy każdej ponownej próbie. Deduplikuj po nim. |
type | Typ zdarzenia z katalogu. |
api_version | Wersja schematu treści (dziś 2026-10-01). |
created | Czas zdarzenia w UTC (ISO 8601). |
livemode | true dla zdarzeń produkcyjnych, false dla testowych. |
service | Nazwa serwisu (to samo pole service co w API) albo null dla zdarzeń konta i zdarzenia testowego. |
data.object | Obiekt zdarzenia: payment, refund, recurring_payment albo payout. |
Treść zdarzenia budujemy raz, przy pierwszej próbie - każde ponowienie wysyła te same bajty. Obiekt pokazuje stan z chwili zdarzenia.
Obiekty
Wszystkie kwoty są w groszach (14900 to 149,00 zł) - inaczej niż pole value w rejestracji płatności, które jest w złotych. Czasy są w UTC w formacie ISO 8601.
Płatność (payment)
| Pole | Opis |
|---|---|
id | Identyfikator płatności - transactionId z odpowiedzi rejestracji. |
status | pending, processing, succeeded, captured, failed, reversed albo charged_back. |
amount, currency | Kwota płatności i waluta (np. PLN). |
amount_refunded | Suma zwrotów. |
amount_captured | Suma pobrań z preautoryzacji karty. |
description | Opis z rejestracji albo null. |
created, paid_at | Utworzenie i opłacenie (null, dopóki płatność nie jest opłacona). |
payment_method | Metoda płatności: type i obiekt o tej samej nazwie ze szczegółami (patrz niżej) albo null, gdy metoda nie jest jeszcze znana. |
failure | Przyczyna odmowy przy payment.failed (patrz Kody odmów), w innych zdarzeniach null. |
recurring | Przy płatności cyklicznej: alias, registration (id płatności rejestrującej) i sequence (first albo subsequent); w innych płatnościach null. |
references.merchant | Twój numer zamówienia z pola reference w rejestracji. |
references.provider | Identyfikator transakcji u dostawcy (np. BLIK, operator kart), jeśli jest. |
custom | Wartość pola custom z rejestracji. |
Szczegóły metody płatności (payment_method):
type | Szczegóły |
|---|---|
blik | flow: code, oneclick albo recurring; przy BLIK OneClick także alias (value, label). |
card | brand, last4, exp_month, exp_year, country, funding (credit, debit, charge), wallet (apple_pay, google_pay, click_to_pay albo null), authorization_code. |
bank_transfer | bank i country banku. |
twisto | plan: standard albo 3x. |
blik_pay_later, mb_way, paypal, paypo, paysafecard, multibanco | Bez dodatkowych pól. |
Zwrot (refund)
| Pole | Opis |
|---|---|
id | Identyfikator zwrotu (ZWROT-...). Przy zwrocie odrzuconym, zanim powstał, może być null. |
payment | Identyfikator zwracanej płatności. |
status | succeeded albo failed. |
amount, currency | Kwota zwrotu i waluta. |
created, succeeded_at | Utworzenie i wykonanie zwrotu. |
failure | Przyczyna odrzucenia przy refund.failed, np. refund_window_expired. |
Płatność cykliczna (recurring_payment)
| Pole | Opis |
|---|---|
id | Identyfikator płatności rejestrującej. |
alias | Alias, którym obciążasz płatność cykliczną. |
status | pending, active, canceled, expired albo declined. |
canceled_by | Przy anulowaniu: merchant (Ty), customer (klient w banku) albo dpay; w innych stanach null. |
payment_method | Metoda płatności - jak w obiekcie płatności. |
terms | Warunki: model, frequency, limit_amount i total_limit_amount (w groszach), starts_on i expires_on (daty RRRR-MM-DD). |
created | Utworzenie płatności rejestrującej. |
Wypłata (payout)
| Pole | Opis |
|---|---|
id | Identyfikator wypłaty. |
status | pending, paid albo failed. |
amount, fee, currency | Kwota wypłaty netto, prowizja i waluta. |
bank_account | Tylko 4 ostatnie cyfry rachunku: {"last4": "1234"}. |
created, paid_at | Utworzenie i realizacja wypłaty. |
failure | Przy payout.failed kod payout_failed, a w provider_code - co stało się ze środkami (patrz Kody odmów). |
Weryfikacja podpisu
Zawsze weryfikuj podpis, zanim zaufasz treści webhooka. Podpis jest zgodny ze standardem Standard Webhooks i jest w trzech nagłówkach:
webhook-id- identyfikator zdarzenia,webhook-timestamp- czas wysłania (Unix, sekundy),webhook-signature- podpisyv1,<base64>rozdzielone spacją (w oknie rotacji sekretu są dwa).
Kluczem HMAC jest sekret bez prefiksu whsec_, zdekodowany z base64. Podpisujesz ciąg {webhook-id}.{webhook-timestamp}.{surowe body}:
key = base64_decode(sekret bez "whsec_")
signature = base64(HMAC_SHA256(key, webhook_id + "." + webhook_timestamp + "." + raw_body))
webhook-signature: v1,<signature>
Zamiast pisać weryfikację samodzielnie, możesz użyć gotowej biblioteki Standard Webhooks dla swojego języka.
Licz HMAC na surowej treści żądania, dokładnie tak, jak przyszła. Nie parsuj JSON-a i nie serializuj go ponownie przed weryfikacją - zmiana białych znaków albo kolejności kluczy zepsuje podpis. Znacznik czasu jest częścią podpisu, więc odrzucając zbyt stare żądania chronisz się też przed powtórzeniem (replay).
Przykład: PHP
function verifyDpayWebhook(string $rawBody, array $headers, string $secret): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
// Odrzuć brak identyfikatora i znacznik czasu starszy niż 5 minut (ochrona przed replay)
if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
return false;
}
$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
if (!verifyDpayWebhook($rawBody, $headers, getenv('DPAY_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true);
// Zapisz zdarzenie (idempotentnie po $event['id']) i odpowiedz od razu
http_response_code(200);
Przykład: Node.js
const crypto = require('crypto');
function verifyDpayWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'] || '';
const timestamp = headers['webhook-timestamp'] || '';
const signatures = (headers['webhook-signature'] || '').split(' ');
if (!id || !/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false; // brak identyfikatora albo za stary znacznik czasu - możliwy replay
}
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64');
return signatures.some((entry) => {
const [version, value] = entry.split(',');
return version === 'v1' && value && value.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
});
}
// Express: potrzebujesz surowego body, nie sparsowanego JSON-a
app.post('/webhooki/dpay', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyDpayWebhook(req.body.toString('utf8'), req.headers, process.env.DPAY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
// Zapisz zdarzenie (idempotentnie po event.id) i odpowiedz od razu
res.sendStatus(200);
});
Przykład: Python
import base64
import hashlib
import hmac
import time
def verify_dpay_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
webhook_id = headers.get('webhook-id', '')
timestamp = headers.get('webhook-timestamp', '')
signatures = headers.get('webhook-signature', '').split(' ')
# Odrzuć brak identyfikatora i znacznik czasu starszy niż 5 minut (ochrona przed replay)
if not webhook_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix('whsec_'))
signed = f'{webhook_id}.{timestamp}.'.encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures:
version, _, value = entry.partition(',')
if version == 'v1' and hmac.compare_digest(expected, value):
return True
return False
Odpowiedź, ponowienia i wyłączenie endpointu
- Odpowiedz kodem
2xxw ciągu 10 sekund. Dłuższą pracę (zapis w bazie, e-maile) wykonaj asynchronicznie, po odpowiedzi. Przekierowanie (3xx), inny kod i przekroczenie czasu to nieudana próba. - Ponowienia: nieudane dostarczenie ponawiamy - łącznie do 10 prób w około 3 doby: od razu, po 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h i 24 h, z rozrzutem ±10%.
- Wyłączenie: jeśli przez 72 godziny żadne dostarczenie na endpoint się nie uda, wyłączamy go i wysyłamy e-mail na adres konta. Po naprawie włącz endpoint w panelu i ponów nieudane dostarczenia (Ponów nieudane). Panel ostrzega wcześniej, od kiedy dostarczenia nie przechodzą.
- Idempotencja: to samo zdarzenie może przyjść więcej niż raz (dostarczenie „co najmniej raz”). Deduplikuj po
id- nie realizuj tego samego zamówienia dwa razy. - Kolejność: zdarzenia mogą przyjść w innej kolejności, niż powstały, np. gdy jedno czeka na ponowienie. Porównuj
createdi status obiektu, a gdy potrzebujesz pewności, pobierz aktualny stan z API. - Równoległość: na jeden endpoint wysyłamy najwyżej 10 żądań naraz.
- Adresy IP: nie filtruj żądań po adresie IP - webhooki wysyłamy przez infrastrukturę Cloudflare, bez stałej puli adresów. Zabezpieczeniem jest podpis.
Adres w żądaniu rejestracji
Zdarzenia pojedynczej płatności możesz też odebrać na adres podany przy jej rejestracji, bez konfiguracji w panelu - przydatne np. we wtyczkach e-commerce i na platformach z wieloma sklepami. Obiekt webhook dodajesz do rejestracji transakcji:
{
"service": "abc123",
"value": "149.00",
"url_success": "https://sklep.example/sukces",
"url_fail": "https://sklep.example/blad",
"transactionType": "transfers",
"reference": "2026/09/123",
"webhook": {
"url": "https://sklep.example/webhooki/dpay",
"events": ["payment.succeeded", "payment.failed"]
},
"checksum": "..."
}
webhook.url- adres według tych samych reguł co endpointy w panelu. Przy rejestracji sprawdzamy go bez zapytania do DNS (chwilowy problem z DNS nie odrzuci płatności), a pełne sprawdzenie przechodzi przed każdą wysyłką.webhook.events- opcjonalna lista typów. Bez niej adres dostaje wszystkie zdarzenia płatności, jej zwrotów i płatności cyklicznej (payment.*,refund.*,recurring_payment.*) - zdarzeń konta (payout.*) tu nie ma.- Zwrot dziedziczy adres płatności, chyba że żądanie zwrotu ma własny obiekt
webhook(patrz Zwroty). Obciążenia płatności cyklicznej dziedziczą adres płatności rejestrującej, a pobranie z preautoryzacji karty (payment.captured) - adres płatności, chyba że żądanie capture ma własny obiektwebhook(patrz Karty - pre-autoryzacja i capture). - Zdarzenia podpisuje sekret webhooków serwisu. Wygenerujesz go w panelu: Punkty Płatności > Serwisy > wybrany serwis > Sekret webhooków > Wygeneruj sekret. Pokazujemy go tylko raz, a po ponownym wygenerowaniu poprzedni podpisuje jeszcze przez 24 godziny. Bez sekretu rejestracja z obiektem
webhookkończy się błędemWEBHOOK_SECRET_MISSING. - Błędny adres odrzuca rejestrację z kodem w
errors["webhook.url"]:https_required,invalid_url,credentials_in_url,port_not_allowed,own_domain,private_addressalbourl_too_long. - Jeśli to samo zdarzenie idzie też na endpoint z panelu, oba żądania mają ten sam
webhook-id- deduplikuj po nim. - Próby dostarczenia na adres z żądania i przycisk ponowienia są w szczegółach transakcji w historii, na karcie Webhooki.
Pole reference (do 64 znaków, bez znaków sterujących) to Twój numer zamówienia - wraca w references.merchant obiektu płatności. Nie musi być unikalny. Pola webhook i reference nie wchodzą do sumy kontrolnej.
Historia zdarzeń
Po awarii endpointu albo do uzgodnienia stanu pobierzesz zdarzenia z API - od najnowszych, stronami, z filtrem po typach i czasie. To te same koperty, które poszły do endpointów, więc obsłużysz je tym samym kodem (zdarzenie pojawia się na liście kilka sekund po wystąpieniu).
POST/api/v1_0/eventsLista zdarzeń serwisu i konta: parametry, suma kontrolna i odpowiedź.Pełny kontrakt w API Reference
| Pole | Wymagane | Opis |
|---|---|---|
service | tak | Nazwa serwisu. |
timestamp | tak | Bieżący czas Unix w sekundach - musi być w oknie 300 sekund od czasu serwera. |
checksum | tak | sha256({service}|{hash}|{timestamp}), gdzie hash to klucz Hash serwisu. |
types | nie | Lista typów zdarzeń, np. ["payment.failed"]. |
created_from, created_to | nie | Zakres czasu zdarzeń (ISO 8601). |
starting_after | nie | Kursor: next_starting_after z poprzedniej strony. |
limit | nie | Liczba zdarzeń na stronie, od 1 do 100, domyślnie 20. |
{
"status": "success",
"data": [
{ "id": "evt_01k6a8q2m4pz7h8c3v5n9t2x6y", "type": "payment.succeeded", "api_version": "2026-10-01", "created": "2026-09-27T10:05:00Z", "livemode": true, "service": "abc123", "data": { "object": { "object": "payment", "id": "8F2A11C4-5B6D-4E7F-8A9B-0C1D2E3F4A5B", "status": "succeeded" } } }
],
"has_more": true,
"next_starting_after": "evt_01k6a8q2m4pz7h8c3v5n9t2x6y"
}
W przykładzie obiekt jest skrócony - lista zwraca pełne koperty. Obejmuje zdarzenia serwisu i zdarzenia konta (wypłaty), bez zdarzeń testowych endpointów. Suma kontrolna zawiera znacznik czasu, więc przechwycona raz nie daje trwałego dostępu do historii. API przyjmuje do 60 żądań na minutę.
Dobre praktyki
- Weryfikuj podpis każdego żądania i odrzucaj zbyt stare znaczniki czasu.
- Odpowiadaj
2xxod razu, a zdarzenie przetwarzaj w tle. - Deduplikuj po
idzdarzenia i nie zakładaj kolejności. - Pomijaj nieznane typy zdarzeń i nieznane pola.
- Trzymaj osobne endpointy dla trybu testowego i produkcyjnego.
- Reaguj na odmowę według
failure.categoryifailure.retryable, a nie według tekstumessage(patrz Kody odmów).