Skip to main content

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.

URL in the request

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.

EventWhen
payment.succeededThe payment was paid.
payment.failedThe payment was declined or expired - with a decline code in failure.
payment.capturedA card pre-authorisation was captured.
refund.succeeded, refund.failedA refund was completed or declined.
recurring_payment.activated, recurring_payment.canceled, recurring_payment.expired, recurring_payment.declinedA recurring payment changed.
payout.paid, payout.failedA payout to the merchant's bank account was completed or rejected.

Partner events​

EventWhen
merchant.updatedThe merchant's onboarding status changed - the new status is in data.object.status (see below).
reverification.submittedThe merchant submitted data for periodic reverification.
authorization.revokedThe 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_verifiedThe invited person verified their identity (eID path only - do not rely on this event, the manual path does not emit it).
invitation.completedThe invited person finished their flow - the only guaranteed success terminal of an invitation.
invitation.signedThe invited representative signed the agreement.
partner_payout.paid, partner_payout.declineddpay paid out or declined the payout of your margin (see ISV settlements). These events concern you, not a merchant - they have no merchant_ref.
New event types

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.

StatusMeaning
in_progressOnboarding in progress (data, documents).
awaiting_signatureThe agreement is waiting for signatures.
partially_signedSome representatives signed the agreement.
agreement_signedThe agreement is signed.
pending_transferWaiting for the activation fee.
under_reviewManual review by dpay.
completedThe account is active.
blockedThe 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"
}
}
}
FieldDescription
idThe event id (evt_...), the same as the webhook-id header and on every retry. Deduplicate on it.
typeThe event type.
api_versionThe payload schema version.
createdThe event time in UTC.
livemodetrue in production, false in the sandbox and for test payments.
serviceThe merchant's payment service, or null for account events.
merchant_refYour merchant reference - the same as in the On-Behalf-Of header. Absent in partner_payout.*.
data.objectThe 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: 14900 is PLN 149.00) - unlike the value field in payment registration, which is in złoty.
  • The payment id is the transactionId from the registration response.
  • The payment status is pending, processing, succeeded, captured, failed, reversed, or charged_back.
  • In payment.failed, failure carries code (the dpay decline code), message, category (customer, merchant, risk, or system), retryable, and provider_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-separated v1,<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.

Sign the raw body

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 2xx immediately. 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 (the webhook-id header) - 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).

Secret rotation

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​