Skip to main content

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.

Webhooks and IPN

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​

  1. In the panel.dpay.pl panel go to Other > Webhooks and click Add endpoint.
  2. Enter an HTTPS URL and choose the events, the service scope and the mode (live or test).
  3. Copy the whsec_... signing secret - we show it only once.
  4. In your endpoint verify the signature and respond with a 2xx code.
  5. 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.

FieldDescription
URLHTTPS 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.
DescriptionFor you, e.g. “Shop - production”.
EventsAll (including types added in the future) or selected ones.
ScopeAll services of the account (including future ones) or selected services. Account events, i.e. payouts (payout.*), go only to endpoints that cover all services.
ModeLive 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.test to 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​

EventObjectWhen
payment.succeededpaymentThe payment was paid.
payment.failedpaymentThe 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.capturedpaymentFunds 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.succeededrefundThe refund was completed. A BLIK refund is completed once BLIK accepts it.
refund.failedrefundThe refund was rejected - the reason is in failure.
recurring_payment.activatedrecurring_paymentThe recurring payment is active - you can charge its alias.
recurring_payment.canceledrecurring_paymentThe recurring payment was canceled - canceled_by tells who canceled it.
recurring_payment.expiredrecurring_paymentThe recurring payment expired.
recurring_payment.declinedrecurring_paymentThe recurring payment registration was declined.
payout.paidpayoutThe payout was completed - it was sent to the bank.
payout.failedpayoutThe 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.

New event types

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"
}
}
}
FieldDescription
idEvent ID (evt_...), the same as in the webhook-id header and on every retry. Deduplicate by it.
typeEvent type from the catalog.
api_versionPayload schema version (currently 2026-10-01).
createdEvent time in UTC (ISO 8601).
livemodetrue for live events, false for test events.
serviceService name (the same service field as in the API) or null for account events and the test event.
data.objectThe 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)​

FieldDescription
idPayment ID - the transactionId from the registration response.
statuspending, processing, succeeded, captured, failed, reversed or charged_back.
amount, currencyPayment amount and currency (e.g. PLN).
amount_refundedTotal refunded.
amount_capturedTotal captured from a card pre-authorization.
descriptionDescription from the registration or null.
created, paid_atCreation and payment time (null until the payment is paid).
payment_methodPayment method: type and an object of the same name with details (see below), or null when the method is not known yet.
failureDecline reason in payment.failed (see Decline codes), null in other events.
recurringFor a recurring payment: alias, registration (ID of the registration payment) and sequence (first or subsequent); null for other payments.
references.merchantYour order number from the reference field of the registration.
references.providerTransaction ID at the provider (e.g. BLIK, card processor), if available.
customValue of the custom field from the registration.

Payment method details (payment_method):

typeDetails
blikflow: code, oneclick or recurring; for BLIK OneClick also alias (value, label).
cardbrand, last4, exp_month, exp_year, country, funding (credit, debit, charge), wallet (apple_pay, google_pay, click_to_pay or null), authorization_code.
bank_transferThe bank's bank and country.
twistoplan: standard or 3x.
blik_pay_later, mb_way, paypal, paypo, paysafecard, multibancoNo additional fields.

Refund (refund)​

FieldDescription
idRefund ID (ZWROT-...). For a refund rejected before it was created it can be null.
paymentID of the refunded payment.
statussucceeded or failed.
amount, currencyRefund amount and currency.
created, succeeded_atCreation and completion of the refund.
failureRejection reason in refund.failed, e.g. refund_window_expired.

Recurring payment (recurring_payment)​

FieldDescription
idID of the registration payment.
aliasThe alias you charge the recurring payment with.
statuspending, active, canceled, expired or declined.
canceled_byWhen canceled: merchant (you), customer (the customer in their bank) or dpay; null in other states.
payment_methodPayment method - as in the payment object.
termsTerms: model, frequency, limit_amount and total_limit_amount (in the minor unit), starts_on and expires_on (YYYY-MM-DD dates).
createdCreation of the registration payment.

Payout (payout)​

FieldDescription
idPayout ID.
statuspending, paid or failed.
amount, fee, currencyNet payout amount, fee and currency.
bank_accountOnly the last 4 digits of the account: {"last4": "1234"}.
created, paid_atCreation and completion of the payout.
failureIn 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.

Sign the raw body

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 2xx code 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 created and 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 webhook object (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 own webhook object (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 webhook object fails with the WEBHOOK_SECRET_MISSING error.
  • 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_address or url_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
FieldRequiredDescription
serviceyesService name.
timestampyesCurrent Unix time in seconds - it must be within 300 seconds of the server time.
checksumyessha256({service}|{hash}|{timestamp}), where hash is the service Hash key.
typesnoList of event types, e.g. ["payment.failed"].
created_from, created_tonoTime range of events (ISO 8601).
starting_afternoCursor: next_starting_after from the previous page.
limitnoEvents 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 2xx right away and process the event in the background.
  • Deduplicate by the event id and 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.category and failure.retryable, not on the message text (see Decline codes).