Handling IPN (Instant Payment Notification)
IPN is a mechanism that notifies your server about a successful transaction. dpay.pl sends a POST request to the url_ipn configured for that transaction.
IPN is sent only for successful transactions (paid status). Failed payments do not generate IPN notifications.
Why IPN matters
Redirecting the customer to url_success does not mean the payment was completed. The customer may have closed the browser, lost the connection, or manipulated the URL. Only IPN is a reliable source of payment status information.
Never update an order status based on the customer redirect. Always wait for IPN confirmation.
IPN v1 schema
dpay.pl sends a POST request to your endpoint with the Content-Type: application/json header and the following body:
{
"id": "abc-def-123-456",
"amount": "29.99",
"email": "customer@example.com",
"type": "transfer",
"attempt": 1,
"version": 1,
"custom": "order-789",
"signature": "a1b2c3d4e5f6..."
}
Field descriptions
| Field | Type | Description |
|---|---|---|
id | string | dpay.pl transaction identifier |
amount | string | Transaction amount |
email | string | Customer email address, if provided |
type | string | Notification type: "transfer", "capture", or "dcb" |
attempt | integer | Delivery attempt number (currently always 1) |
version | integer | IPN protocol version (currently 1) |
custom | string | Custom data submitted during registration (optional) |
capture_payment_id | string | Capture identifier, only for the "capture" type |
signature | string | Digital signature used to verify authenticity |
Notification types
| Type | Description |
|---|---|
transfer | Standard successful payment - you can fulfil the order |
capture | Card payment capture - includes the additional capture_payment_id field |
dcb | Direct Carrier Billing payment - the payload does not contain the email field |
Signature verification
Always verify the IPN signature to make sure the notification came from dpay.pl and not from a third party.
The signature is generated with SHA-256 by directly concatenating the field values and your Secret Hash:
sha256({id}{SecretHash}{amount}{email}{type}{attempt}{version}{custom})
Unlike the payment registration checksum, the IPN signature does not use the | separator. Concatenate the values directly.
DCB (Direct Carrier Billing)
For dcb notifications, the payload does not contain the email field, so the signature formula is different:
sha256({id}{SecretHash}{amount}{type}{attempt}{version}{custom})
PHP
<?php
// Read IPN data
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
echo 'Invalid payload';
exit;
}
$privateKey = 'your_secret_hash'; // Your Secret Hash
// Generate the expected signature (no separators - direct concatenation)
$checksum = hash('sha256',
$data['id'] . $privateKey . $data['amount'] . $data['email']
. $data['type'] . $data['attempt'] . $data['version'] . $data['custom']
);
// Verify the signature
if ($checksum !== $data['signature']) {
// Invalid signature - reject the notification
http_response_code(200);
echo 'OK';
exit;
}
// Valid signature - process the payment
$transactionId = $data['id'];
$amount = $data['amount'];
$type = $data['type'];
switch ($type) {
case 'transfer':
// Standard payment - mark the order as paid
markOrderAsPaid($transactionId, $amount);
break;
case 'capture':
// Card capture - process it with capture_payment_id
$captureId = $data['capture_payment_id'];
markOrderAsCaptured($transactionId, $amount, $captureId);
break;
}
// Respond with the exact body "OK" so dpay.pl does not retry
http_response_code(200);
echo 'OK';
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/ipn', (req, res) => {
const data = req.body;
const secretHash = process.env.DPAY_SECRET_HASH;
// Generate the expected signature (no separators - direct concatenation)
const expectedSignature = crypto
.createHash('sha256')
.update(
[data.id, secretHash, data.amount, data.email,
data.type, data.attempt, data.version, data.custom].join('')
)
.digest('hex');
// Verify the signature
if (expectedSignature !== data.signature) {
console.error('Invalid IPN signature');
return res.status(200).send('OK');
}
// Process the payment
switch (data.type) {
case 'transfer':
markOrderAsPaid(data.id, data.amount);
break;
case 'capture':
markOrderAsCaptured(data.id, data.amount, data.capture_payment_id);
break;
}
res.status(200).send('OK');
});
Python (Flask)
import hashlib
from flask import Flask, request
app = Flask(__name__)
@app.route('/api/ipn', methods=['POST'])
def handle_ipn():
data = request.get_json()
secret_hash = os.environ['DPAY_SECRET_HASH']
# Generate the expected signature (no separators - direct concatenation)
raw = (
data['id'] + secret_hash + data['amount'] + data['email']
+ data['type'] + str(data['attempt']) + str(data['version']) + data['custom']
)
expected_signature = hashlib.sha256(raw.encode('utf-8')).hexdigest()
# Verify the signature
if expected_signature != data['signature']:
return 'OK', 200 # Reject silently
# Process the payment
if data['type'] == 'transfer':
mark_order_as_paid(data['id'], data['amount'])
elif data['type'] == 'capture':
mark_order_as_captured(data['id'], data['amount'], data['capture_payment_id'])
return 'OK', 200
Best practices
1. Always respond with the exact body "OK"
dpay.pl considers an IPN delivered only when your response body is exactly "OK" (leading and trailing whitespace is ignored). The HTTP status code is not checked. A 200 response with any other body, such as an HTML page, JSON, or an empty body, causes the IPN to be retried.
HTTP/1.1 200 OK
Content-Type: text/plain
OK
Make sure your framework does not append anything to the response except the OK text, such as a debug toolbar or HTML template wrapper. Any extra character in the body means that the IPN is considered undelivered and will be retried.
2. Idempotency
Your IPN endpoint may receive the same notification more than once, for example because of a network timeout. Make sure that you process each transaction only once:
// Check whether the transaction has already been processed
$order = getOrderByTransactionId($data['id']);
if ($order && $order['status'] === 'paid') {
// Already processed - respond with OK and stop
http_response_code(200);
echo 'OK';
exit;
}
3. Verify the amount
Always check that the amount in the IPN matches the order amount in your database:
$order = getOrderByTransactionId($data['id']);
if (floatval($data['amount']) !== floatval($order['amount'])) {
// Amount mismatch - possible fraud attempt
logSecurityAlert('Amount mismatch', $data);
http_response_code(200);
echo 'OK';
exit;
}
4. Do not perform long-running operations synchronously
If payment processing takes longer, for example because you generate a file or send an email, add a job to a queue and respond immediately:
// Add a job to the queue instead of processing synchronously
$queue->push('process_payment', [
'transaction_id' => $data['id'],
'amount' => $data['amount'],
]);
http_response_code(200);
echo 'OK';
5. Logging
Log all incoming IPN notifications for diagnostic purposes:
file_put_contents(
'/var/log/dpay-ipn.log',
date('Y-m-d H:i:s') . ' ' . json_encode($data) . PHP_EOL,
FILE_APPEND
);
Retry mechanism
If your server's response body is not exactly "OK", or a timeout or connection error occurs, dpay.pl retries IPN delivery:
| Attempt | Delay | Time since first attempt |
|---|---|---|
| 1 | Immediately | 0 min |
| 2 | 2 min | 2 min |
| 3 | 4 min | 6 min |
| 4 | 8 min | 14 min |
| 5 | 16 min | 30 min |
| 6 | 32 min | ~1 hour |
| 7 | 64 min | ~2 hours |
| 8 | 128 min | ~4 hours |
| 9 | 256 min | ~8.5 hours |
| 10 | 512 min | ~17 hours |
The delays use an exponential backoff pattern (2^n minutes). After 10 failed attempts, the notification is no longer retried. You can check the transaction status manually in the administration dashboard.
Disabling retries
IPN retries can be disabled per service (Payment Point) or per user account. The service setting takes precedence over the account setting, and retries are enabled by default. When retries are disabled, dpay.pl makes only one delivery attempt. The switch is available in the Payment Point settings in the dashboard.
Querying transaction status manually
In addition to waiting for IPN, you can check the transaction status through the API. This is useful in several scenarios:
- The IPN did not arrive - for example, because of network problems on your server
- Reconciliation - periodically reconciling transaction statuses with your database
- Additional protection - as a supplement to IPN
Endpoint
POST https://panel.dpay.pl/api/v1/pbl/details
Content-Type: application/json
Request parameters
| Field | Type | Required | Description |
|---|---|---|---|
service | string | yes | Service name (Payment Point) |
transaction_id | string | yes | Transaction UUID |
checksum | string | yes | Checksum - sha256(service|transaction_id|secret_hash) |
The server verifies the checksum by joining the values of all body fields in the order in which they appear in the JSON, excluding checksum, with | separators and secret_hash at the end. The sha256(service|transaction_id|secret_hash) formula works only when the fields appear in exactly this order: service first, then transaction_id. A different order or extra fields omitted from the checksum cause an authorisation error.
Example response
{
"status": "success",
"transaction": {
"id": "abc-def-123-456",
"value": "29.99",
"rate": 2,
"minimal_fee": 20,
"permanent_fee": 0,
"status": "paid",
"payment_method": "blik",
"urls": {
"success": "https://myshop.example/success",
"fail": "https://myshop.example/failure",
"ipn": "https://myshop.example/api/ipn"
},
"creation_date": "2026-01-15 12:30:00",
"payment_date": "2026-01-15 12:31:45",
"settled": true,
"refunded": false,
"refunded_amount": 10.00,
"available_refund_amount": 19.99,
"fully_refunded": false,
"direct": false
},
"payer": {},
"refunds": [
{
"payment_id": "ZWROT-abc-def-123-456-QWERTY12",
"value": -10.00,
"status": "paid",
"creation_date": "2026-01-16 09:00:00",
"payment_date": "2026-01-16 09:00:00"
}
]
}
Refund-related fields
| Field | Type | Description |
|---|---|---|
transaction.refunded | bool | Whether the transaction was refunded (legacy flag) |
transaction.refunded_amount | number | Total refunded amount in PLN |
transaction.available_refund_amount | number | Remaining refundable amount in PLN (value - refunded_amount, minimum 0) |
transaction.fully_refunded | bool | Whether the transaction is fully refunded. For methods that support multiple partial refunds, such as BLIK and cards, this is calculated as available_refund_amount == 0; for other methods, it corresponds to refunded |
refunds | array | List of refunds linked to the transaction. Always present (an empty array when there are no refunds), sorted ascending by creation_date |
refunds[].payment_id | string | Refund identifier in the ZWROT-{parent_payment_id}-{8 characters} format |
refunds[].value | number | Refund amount in PLN, always negative (for example, -50.00) |
refunds[].status | string | Refund status (see "Transaction statuses" below). Defaults to paid |
refunds[].creation_date | string | Refund creation date (YYYY-MM-DD HH:MM:SS) |
refunds[].payment_date | string | Refund completion date (YYYY-MM-DD HH:MM:SS) |
Transaction statuses
| Status | Description |
|---|---|
paid | Transaction paid |
created | Transaction created and awaiting payment |
processing | Payment being processed |
expired | Transaction expired |
captured | Card payment captured |
Example - PHP
<?php
$service = 'my_service';
$transactionId = 'abc-def-123-456';
$secretHash = 'your_secret_hash';
$checksum = hash('sha256', $service . '|' . $transactionId . '|' . $secretHash);
$response = file_get_contents('https://panel.dpay.pl/api/v1/pbl/details', false,
stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode([
'service' => $service,
'transaction_id' => $transactionId,
'checksum' => $checksum,
]),
],
])
);
$result = json_decode($response, true);
if ($result['status'] === 'success' && $result['transaction']['status'] === 'paid') {
// Transaction paid - update the order status
markOrderAsPaid($transactionId, $result['transaction']['value']);
}
Querying status manually is a good fallback mechanism. For example, you can run a cron job every few minutes to check transactions that have been awaiting payment for more than 15 minutes.
Testing IPN
- The easiest way to test IPN is to make a test transaction in the test environment. After it is paid, dpay.pl sends a real IPN to your
url_ipn. - For local testing, use a tool such as ngrok to expose your local server at a public URL.
The Send test IPN option in the dashboard is available only for Direct Carrier Billing (DCB) services. For standard Payment Points, use test transactions.
# Start an ngrok tunnel
ngrok http 3000
# Use the generated address as url_ipn:
# https://abc123.ngrok.io/api/ipn