Skip to main content

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.

info

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.

Rule

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

FieldTypeDescription
idstringdpay.pl transaction identifier
amountstringTransaction amount
emailstringCustomer email address, if provided
typestringNotification type: "transfer", "capture", or "dcb"
attemptintegerDelivery attempt number (currently always 1)
versionintegerIPN protocol version (currently 1)
customstringCustom data submitted during registration (optional)
capture_payment_idstringCapture identifier, only for the "capture" type
signaturestringDigital signature used to verify authenticity

Notification types

TypeDescription
transferStandard successful payment - you can fulfil the order
captureCard payment capture - includes the additional capture_payment_id field
dcbDirect 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})
caution

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
warning

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:

AttemptDelayTime since first attempt
1Immediately0 min
22 min2 min
34 min6 min
48 min14 min
516 min30 min
632 min~1 hour
764 min~2 hours
8128 min~4 hours
9256 min~8.5 hours
10512 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

FieldTypeRequiredDescription
servicestringyesService name (Payment Point)
transaction_idstringyesTransaction UUID
checksumstringyesChecksum - sha256(service|transaction_id|secret_hash)
The checksum depends on body field order

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"
}
]
}
FieldTypeDescription
transaction.refundedboolWhether the transaction was refunded (legacy flag)
transaction.refunded_amountnumberTotal refunded amount in PLN
transaction.available_refund_amountnumberRemaining refundable amount in PLN (value - refunded_amount, minimum 0)
transaction.fully_refundedboolWhether 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
refundsarrayList of refunds linked to the transaction. Always present (an empty array when there are no refunds), sorted ascending by creation_date
refunds[].payment_idstringRefund identifier in the ZWROT-{parent_payment_id}-{8 characters} format
refunds[].valuenumberRefund amount in PLN, always negative (for example, -50.00)
refunds[].statusstringRefund status (see "Transaction statuses" below). Defaults to paid
refunds[].creation_datestringRefund creation date (YYYY-MM-DD HH:MM:SS)
refunds[].payment_datestringRefund completion date (YYYY-MM-DD HH:MM:SS)

Transaction statuses

StatusDescription
paidTransaction paid
createdTransaction created and awaiting payment
processingPayment being processed
expiredTransaction expired
capturedCard 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']);
}
tip

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

  1. 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.
  2. For local testing, use a tool such as ngrok to expose your local server at a public URL.
info

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