Webhooks
Webhooks notify your server about events on your dpay account: paid and declined payments, refunds, recurring payments and payouts. We send every event as a POST request with a JSON body to your endpoints, signed according to Standard Webhooks and retried for about 3 days.
IPN keeps working unchanged. Webhooks give you more: separate event types (including declines with a reason code, refunds and payouts), endpoints configured in the panel, a delivery log with retries and a signature made with the endpoint secret. The two channels are independent - if you use both, the same event arrives twice, once each way. A new integration can rely on webhooks alone and skip url_ipn.
Quick start
- In the panel.dpay.pl panel go to Other > Webhooks and click Add endpoint.
- Enter an HTTPS URL and choose the events, the service scope and the mode (live or test).
- Copy the
whsec_...signing secret - we show it only once. - In your endpoint verify the signature and respond with a
2xxcode. - Click Send test event and check the result in the delivery log.
Endpoints in the panel
The Webhooks tab is in the Other menu. It is available to the main account and to team members with the API keys permission. An account can have up to 16 endpoints.
| Field | Description |
|---|---|
| URL | HTTPS on port 443 only, up to 500 characters, with no username or password in the URL. The URL must point to a server reachable from the internet - private and local network addresses and dpay domains are rejected. |
| Description | For you, e.g. “Shop - production”. |
| Events | All (including types added in the future) or selected ones. |
| Scope | All services of the account (including future ones) or selected services. Account events, i.e. payouts (payout.*), go only to endpoints that cover all services. |
| Mode | Live or test, chosen when you create the endpoint. A test endpoint receives only test events (livemode: false: services in test mode, test payments) and a live endpoint only live ones. Create two endpoints for both modes. |
In the endpoint details:
- Signing secret - every endpoint has its own
whsec_...secret, shown only on creation and on rotation. After you generate a new secret, the previous one keeps signing alongside it for 24 hours, so you can switch without downtime. - Send test event - sends
webhook.testto this endpoint only, regardless of the selected events. You will see the result in the log after a few seconds. - Delivery log - every delivery with its status (pending, delivered, failed) and all attempts: HTTP code, response time, error and the beginning of your response (up to 2 KB, kept for 90 days). You can retry a failed delivery right away, and with Retry failed - all failed deliveries from a selected period (up to 31 days and 1000 deliveries at once).
- Disable / Enable - disabling ends pending deliveries as failed. After enabling you can retry them from the log.
The endpoint list shows the last delivery and the success rate from the last 7 days. All deliveries of a single payment's events - to endpoints and to the URL from the request - are also shown in the transaction details in the history, on the Webhooks card, where you can retry failed ones.
Event catalog
| Event | Object | When |
|---|---|---|
payment.succeeded | payment | The payment was paid. |
payment.failed | payment | The payment was declined - the reason is in failure (see Decline codes) - or it expired because it was not paid within 7 days (code expired). |
payment.captured | payment | Funds were captured from a card pre-authorization - after every capture, partial ones included. amount_captured carries the captured amount, and once the full amount is captured the status changes to captured. |
refund.succeeded | refund | The refund was completed. A BLIK refund is completed once BLIK accepts it. |
refund.failed | refund | The refund was rejected - the reason is in failure. |
recurring_payment.activated | recurring_payment | The recurring payment is active - you can charge its alias. |
recurring_payment.canceled | recurring_payment | The recurring payment was canceled - canceled_by tells who canceled it. |
recurring_payment.expired | recurring_payment | The recurring payment expired. |
recurring_payment.declined | recurring_payment | The recurring payment registration was declined. |
payout.paid | payout | The payout was completed - it was sent to the bank. |
payout.failed | payout | The payout was rejected. |
Charges of a recurring payment are regular payment.* events - the payment object then has the recurring field filled in. The webhook.test event arrives only after you click Send test event and cannot be selected in the catalog.
The catalog will grow and objects may get new fields. Simply skip an unknown type and unknown fields - do not treat them as errors. An endpoint with all events selected receives new types automatically.
Event envelope
Every event has the same envelope. The type field carries the type and data.object the object the event is about.
POST /webhooks/dpay HTTP/1.1
Host: shop.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": "Order 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"
}
}
}
| Field | Description |
|---|---|
id | Event ID (evt_...), the same as in the webhook-id header and on every retry. Deduplicate by it. |
type | Event type from the catalog. |
api_version | Payload schema version (currently 2026-10-01). |
created | Event time in UTC (ISO 8601). |
livemode | true for live events, false for test events. |
service | Service name (the same service field as in the API) or null for account events and the test event. |
data.object | The event object: payment, refund, recurring_payment or payout. |
We build the event payload once, on the first attempt - every retry sends the same bytes. The object shows the state at the time of the event.
Objects
All amounts are in the minor unit (14900 is PLN 149.00) - unlike the value field in payment registration, which is in złoty. Times are in UTC in ISO 8601 format.
Payment (payment)
| Field | Description |
|---|---|
id | Payment ID - the transactionId from the registration response. |
status | pending, processing, succeeded, captured, failed, reversed or charged_back. |
amount, currency | Payment amount and currency (e.g. PLN). |
amount_refunded | Total refunded. |
amount_captured | Total captured from a card pre-authorization. |
description | Description from the registration or null. |
created, paid_at | Creation and payment time (null until the payment is paid). |
payment_method | Payment method: type and an object of the same name with details (see below), or null when the method is not known yet. |
failure | Decline reason in payment.failed (see Decline codes), null in other events. |
recurring | For a recurring payment: alias, registration (ID of the registration payment) and sequence (first or subsequent); null for other payments. |
references.merchant | Your order number from the reference field of the registration. |
references.provider | Transaction ID at the provider (e.g. BLIK, card processor), if available. |
custom | Value of the custom field from the registration. |
Payment method details (payment_method):
type | Details |
|---|---|
blik | flow: code, oneclick or recurring; for BLIK OneClick also alias (value, label). |
card | brand, last4, exp_month, exp_year, country, funding (credit, debit, charge), wallet (apple_pay, google_pay, click_to_pay or null), authorization_code. |
bank_transfer | The bank's bank and country. |
twisto | plan: standard or 3x. |
blik_pay_later, mb_way, paypal, paypo, paysafecard, multibanco | No additional fields. |
Refund (refund)
| Field | Description |
|---|---|
id | Refund ID (ZWROT-...). For a refund rejected before it was created it can be null. |
payment | ID of the refunded payment. |
status | succeeded or failed. |
amount, currency | Refund amount and currency. |
created, succeeded_at | Creation and completion of the refund. |
failure | Rejection reason in refund.failed, e.g. refund_window_expired. |
Recurring payment (recurring_payment)
| Field | Description |
|---|---|
id | ID of the registration payment. |
alias | The alias you charge the recurring payment with. |
status | pending, active, canceled, expired or declined. |
canceled_by | When canceled: merchant (you), customer (the customer in their bank) or dpay; null in other states. |
payment_method | Payment method - as in the payment object. |
terms | Terms: model, frequency, limit_amount and total_limit_amount (in the minor unit), starts_on and expires_on (YYYY-MM-DD dates). |
created | Creation of the registration payment. |
Payout (payout)
| Field | Description |
|---|---|
id | Payout ID. |
status | pending, paid or failed. |
amount, fee, currency | Net payout amount, fee and currency. |
bank_account | Only the last 4 digits of the account: {"last4": "1234"}. |
created, paid_at | Creation and completion of the payout. |
failure | In payout.failed the payout_failed code, and provider_code tells what happened to the funds (see Decline codes). |
Signature verification
Always verify the signature before you trust a webhook payload. The signature follows the Standard Webhooks standard and comes in three headers:
webhook-id- the event ID,webhook-timestamp- the send time (Unix, seconds),webhook-signature-v1,<base64>signatures separated by a space (two of them during a secret rotation window).
The HMAC key is the secret without the whsec_ prefix, decoded from base64. You sign the string {webhook-id}.{webhook-timestamp}.{raw body}:
key = base64_decode(secret without "whsec_")
signature = base64(HMAC_SHA256(key, webhook_id + "." + webhook_timestamp + "." + raw_body))
webhook-signature: v1,<signature>
Instead of writing the verification yourself, you can use a ready-made Standard Webhooks library for your language.
Compute the HMAC over the raw request body, exactly as it arrived. Do not parse the JSON and serialize it again before verification - a change in whitespace or key order breaks the signature. The timestamp is part of the signature, so rejecting requests that are too old also protects you against replay.
Example: PHP
function verifyDpayWebhook(string $rawBody, array $headers, string $secret): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
// Reject a missing ID and a timestamp older than 5 minutes (replay protection)
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);
// Store the event (idempotently by $event['id']) and respond right away
http_response_code(200);
Example: 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; // missing ID or timestamp too old - possible 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: you need the raw body, not parsed JSON
app.post('/webhooks/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);
// Store the event (idempotently by event.id) and respond right away
res.sendStatus(200);
});
Example: 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(' ')
# Reject a missing ID and a timestamp older than 5 minutes (replay protection)
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
Responses, retries and disabling an endpoint
- Respond with a
2xxcode within 10 seconds. Do longer work (database writes, e-mails) asynchronously, after the response. A redirect (3xx), any other code and a timeout count as a failed attempt. - Retries: we retry a failed delivery - up to 10 attempts in total over about 3 days: immediately, after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h and 24 h, with ±10% jitter.
- Disabling: if no delivery to an endpoint succeeds for 72 hours, we disable it and send an e-mail to the account address. After fixing the issue, enable the endpoint in the panel and retry the failed deliveries (Retry failed). The panel warns you earlier, showing since when deliveries have been failing.
- Idempotency: the same event can arrive more than once (at-least-once delivery). Deduplicate by
id- do not fulfil the same order twice. - Ordering: events can arrive in a different order than they occurred, e.g. when one of them waits for a retry. Compare
createdand the object status, and when you need certainty, fetch the current state from the API. - Concurrency: we send at most 10 requests at a time to a single endpoint.
- IP addresses: do not filter requests by IP address - we send webhooks through the Cloudflare infrastructure, without a fixed address pool. The signature is your protection.
URL in the registration request
You can also receive the events of a single payment at a URL given when registering it, with no panel setup - useful e.g. in e-commerce plugins and on platforms with many shops. Add the webhook object to the transaction registration:
{
"service": "abc123",
"value": "149.00",
"url_success": "https://shop.example/success",
"url_fail": "https://shop.example/failure",
"transactionType": "transfers",
"reference": "2026/09/123",
"webhook": {
"url": "https://shop.example/webhooks/dpay",
"events": ["payment.succeeded", "payment.failed"]
},
"checksum": "..."
}
webhook.url- a URL following the same rules as panel endpoints. At registration we check it without a DNS lookup (a temporary DNS issue does not reject the payment), and the full check runs before every send.webhook.events- an optional list of types. Without it the URL receives all events of the payment, its refunds and its recurring payment (payment.*,refund.*,recurring_payment.*) - account events (payout.*) are not available here.- A refund inherits the URL of the payment unless the refund request has its own
webhookobject (see Refunds). Charges of a recurring payment inherit the URL of the registration payment, and a card pre-authorization capture (payment.captured) - the URL of the payment, unless the capture request has its ownwebhookobject (see Cards - pre-authorization and capture). - The events are signed with the service webhook secret. Generate it in the panel: Payment Points > Services > the service > Webhook secret > Generate secret. We show it only once, and after you generate it again the previous one keeps signing for 24 hours. Without the secret, a registration with a
webhookobject fails with theWEBHOOK_SECRET_MISSINGerror. - An invalid URL rejects the registration with a code in
errors["webhook.url"]:https_required,invalid_url,credentials_in_url,port_not_allowed,own_domain,private_addressorurl_too_long. - If the same event also goes to a panel endpoint, both requests have the same
webhook-id- deduplicate by it. - Delivery attempts to the URL from the request and the retry button are in the transaction details in the history, on the Webhooks card.
The reference field (up to 64 characters, no control characters) is your order number - it comes back in references.merchant of the payment object. It does not have to be unique. The webhook and reference fields are not part of the checksum.
Event history
After an endpoint outage or for reconciliation you can fetch events from the API - newest first, page by page, filtered by type and time. These are the same envelopes that went to the endpoints, so you can handle them with the same code (an event appears on the list a few seconds after it occurs).
POST/api/v1_0/eventsEvents of the service and the account: parameters, checksum and response.Full contract in the API Reference
| Field | Required | Description |
|---|---|---|
service | yes | Service name. |
timestamp | yes | Current Unix time in seconds - it must be within 300 seconds of the server time. |
checksum | yes | sha256({service}|{hash}|{timestamp}), where hash is the service Hash key. |
types | no | List of event types, e.g. ["payment.failed"]. |
created_from, created_to | no | Time range of events (ISO 8601). |
starting_after | no | Cursor: next_starting_after from the previous page. |
limit | no | Events per page, from 1 to 100, 20 by default. |
{
"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"
}
The object in the example is shortened - the list returns full envelopes. It covers the events of the service and account events (payouts), without the endpoints' test events. The checksum includes the timestamp, so a checksum intercepted once does not grant lasting access to the history. The API accepts up to 60 requests per minute.
Best practices
- Verify the signature of every request and reject timestamps that are too old.
- Respond with
2xxright away and process the event in the background. - Deduplicate by the event
idand do not assume ordering. - Skip unknown event types and unknown fields.
- Keep separate endpoints for test and live mode.
- React to a decline based on
failure.categoryandfailure.retryable, not on themessagetext (see Decline codes).