Webhooks
Webhooks are your source of truth for dpay Connect events. Instead of polling the API in a loop, you receive a push to your endpoint whenever a merchant's onboarding status, a payment, a refund, a payout, or the consent to act on the merchant's behalf changes.
Configuration
Add endpoints in the partner portal, on the Webhooks tab:
- URL - HTTPS on port 443, publicly reachable (private network addresses and dpay domains are rejected).
- Events - all of them (including those added in the future) or selected types.
- Mode - live or test. A sandbox partner account has test endpoints only and receives only events with
livemode: false.
Each endpoint has its own signing secret (whsec_...), shown only once - when you add the endpoint and when you rotate the secret. You can have up to 16 endpoints.
The portal also shows the delivery log with every attempt (HTTP status, response time, the beginning of the response body), lets you retry a single delivery or all failed deliveries from a chosen period, and send a webhook.test event.
You can also receive the events of a single payment on a URL given when registering it - see the webhook object in on-behalf payments.
Event catalogue
Events of your merchants
You receive the events of all merchants onboarded through your channel or paying through your platform - with the merchant_ref field and the same object the merchant receives.
| Event | When |
|---|---|
payment.succeeded | The payment was paid. |
payment.failed | The payment was declined or expired - with a decline code in failure. |
payment.captured | A card pre-authorisation was captured. |
refund.succeeded, refund.failed | A refund was completed or declined. |
recurring_payment.activated, recurring_payment.canceled, recurring_payment.expired, recurring_payment.declined | A recurring payment changed. |
payout.paid, payout.failed | A payout to the merchant's bank account was completed or rejected. |
Partner events
| Event | When |
|---|---|
merchant.updated | The merchant's onboarding status changed - the new status is in data.object.status (see below). |
reverification.submitted | The merchant submitted data for periodic reverification. |
authorization.revoked | The merchant revoked the consent to act on its behalf. The revocation covers all scopes at once and takes effect immediately - stop sending On-Behalf-Of requests for this merchant_ref. |
invitation.identity_verified | The invited person verified their identity (eID path only - do not rely on this event, the manual path does not emit it). |
invitation.completed | The invited person finished their flow - the only guaranteed success terminal of an invitation. |
invitation.signed | The invited representative signed the agreement. |
partner_payout.paid, partner_payout.declined | dpay paid out or declined the payout of your margin (see ISV settlements). These events concern you, not a merchant - they have no merchant_ref. |
The catalogue may grow. Design your handler to ignore an unknown type instead of failing. Do not assume a closed list.
Onboarding status (merchant.updated)
data.object.status is the overall status of the merchant's account (see the account lifecycle). Every change produces one event.
| Status | Meaning |
|---|---|
in_progress | Onboarding in progress (data, documents). |
awaiting_signature | The agreement is waiting for signatures. |
partially_signed | Some representatives signed the agreement. |
agreement_signed | The agreement is signed. |
pending_transfer | Waiting for the activation fee. |
under_review | Manual review by dpay. |
completed | The account is active. |
blocked | The account is blocked. |
Signing an agreement on an account that is already active, blocked, or further in the process does not move the status back - the event then shows the current state of the account.
Event envelope
Every webhook is a POST with JSON in the same envelope. The event type is in type, and the object it concerns is in data.object.
POST /webhooks/dpay HTTP/1.1
Content-Type: application/json
webhook-id: evt_01k6a8n3x6qj5r2m9w4t7b0c1d
webhook-timestamp: 1790503200
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "evt_01k6a8n3x6qj5r2m9w4t7b0c1d",
"type": "merchant.updated",
"api_version": "2026-10-01",
"created": "2026-09-27T10:00:00Z",
"livemode": true,
"service": null,
"merchant_ref": "invoice-client-123",
"data": {
"object": {
"object": "merchant",
"id": "invoice-client-123",
"status": "completed",
"channel": "fakturka"
}
}
}
| Field | Description |
|---|---|
id | The event id (evt_...), the same as the webhook-id header and on every retry. Deduplicate on it. |
type | The event type. |
api_version | The payload schema version. |
created | The event time in UTC. |
livemode | true in production, false in the sandbox and for test payments. |
service | The merchant's payment service, or null for account events. |
merchant_ref | Your merchant reference - the same as in the On-Behalf-Of header. Absent in partner_payout.*. |
data.object | The event object: merchant, payment, refund, recurring_payment, payout, invitation, authorization, reverification, or partner_payout. |
A payment:
{
"id": "evt_01k6a8q2m4pz7h8c3v5n9t2x6y",
"type": "payment.succeeded",
"api_version": "2026-10-01",
"created": "2026-09-27T10:05:00Z",
"livemode": true,
"service": "fakturka-shop",
"merchant_ref": "invoice-client-123",
"data": {
"object": {
"object": "payment",
"id": "8F2A11C4-5B6D-4E7F-8A9B-0C1D2E3F4A5B",
"status": "succeeded",
"amount": 14900,
"currency": "PLN",
"amount_refunded": 0,
"amount_captured": 0,
"description": "Invoice FV/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": "FV/2026/09/123", "provider": "1234567890" },
"custom": null
}
}
}
- Amounts are in grosze (
amount: 14900is PLN 149.00) - unlike thevaluefield in payment registration, which is in złoty. - The payment
idis thetransactionIdfrom the registration response. - The payment
statusispending,processing,succeeded,captured,failed,reversed, orcharged_back. - In
payment.failed,failurecarriescode(the dpay decline code),message,category(customer,merchant,risk, orsystem),retryable, andprovider_code(the provider's code, if any). Full list: Decline codes. The payment, refund, recurring payment and payout objects are described on the merchant Webhooks page. - Invitation objects contain no personal data of the invited person:
{"object": "invitation", "id": "your-invitation-reference", "role": "representative", "status": "completed"}.
Signature verification
Always verify the signature before trusting the content of a webhook. dpay signs every webhook following Standard Webhooks:
webhook-id- the event id,webhook-timestamp- the send time (Unix seconds),webhook-signature- space-separatedv1,<base64>signatures (two during a secret rotation window).
The key is the endpoint secret without the whsec_ prefix, base64-decoded. 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 Standard Webhooks library for your language.
Compute the HMAC over the raw request body exactly as received (byte for byte). Do not parse and re-serialise the JSON before verification - changing whitespace or key order breaks the signature. The timestamp is part of the signature, so it also protects against replay attacks.
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 stale timestamp (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;
}
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 || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false; // missing id or stale timestamp - 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));
});
}
Respond quickly and idempotently
- Respond with
2xximmediately. Do heavy work (database updates, e-mails) asynchronously after responding. A slow response is treated as a failure and the webhook is retried. - Be idempotent. The same event may arrive more than once. Deduplicate on
id(thewebhook-idheader) - do not book the same payment twice. - Do not filter by IP address. Webhooks are sent through Cloudflare infrastructure without a fixed address pool - the signature is the protection.
Retries and endpoint disabling
A success is a 2xx response within 10 seconds, without redirects. Otherwise dpay retries the delivery - up to 10 attempts over about 3 days: immediately, then after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 14 h, 20 h, and 24 h (with ±10% jitter).
If no delivery to an endpoint succeeds for 72 hours, we disable it and e-mail the partner portal users. After fixing the problem, enable the endpoint in the portal and retry failed deliveries from a chosen period (up to 31 days).
A new endpoint secret takes effect immediately, and the previous one keeps signing for another 24 hours - during that time the webhook-signature header carries two signatures. Update the secret on your side within this window. The other portal users receive an e-mail about the rotation.
Next steps
- Account lifecycle - the statuses in
merchant.updated. - On-behalf payments -
payment.*events and the URL in the request. - On-behalf payouts -
payout.*events.