Handling IPN (Instant Payment Notification)
IPN is a mechanism for notifying your server about a successful transaction. dpay.pl sends a POST request to the url_ipn configured for the given transaction.
IPN is sent only for successful transactions (paid status). Failed payments do not generate IPN notifications.
Why is IPN important?
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 information about the payment status.
Never update the order status based on the customer redirect. Always wait for confirmation via IPN.
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" or "capture" |
attempt | integer | Delivery attempt number (1, 2, 3...) |
version | string | IPN protocol version (currently "1") |
custom | string | Custom data passed during registration (optional) |
capture_payment_id | string | Capture identifier - only for "capture" type |
signature | string | Digital signature for authenticity verification |
Notification types
| Type | Description |
|---|---|
transfer | Standard payment completed successfully - you can fulfill the order |
capture | Card payment capture - includes the additional capture_payment_id field |
Signature verification
Always verify the IPN signature to ensure the notification comes from dpay.pl and not a third party.
The signature is generated using the SHA-256 algorithm from the concatenation of field values and your Secret Hash:
sha256({id}|{SecretHash}|{amount}|{email}|{type}|{attempt}|{version}|{custom})
Separator: | (pipe) - identical to the payment checksum generation.
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
$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 - update the order status to "paid"
markOrderAsPaid($transactionId, $amount);
break;
case 'capture':
// Card capture - process with capture_payment_id
$captureId = $data['capture_payment_id'];
markOrderAsCaptured($transactionId, $amount, $captureId);
break;
}
// 200 OK response - dpay.pl will 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
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
raw = '|'.join([
data['id'], secret_hash, data['amount'], data['email'],
data['type'], str(data['attempt']), 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 HTTP status 200
dpay.pl expects a response with status code 200 and body "OK". If your server responds with a different status code, dpay.pl will retry the IPN delivery.
2. Idempotency
Your IPN endpoint may receive the same notification multiple times (e.g., due to a network timeout). Make sure 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 exit
http_response_code(200);
echo 'OK';
exit;
}
3. Verify the amount
Always check that the amount from 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 operations synchronously
If payment processing requires more time (e.g., generating a file, sending an email), add the task to a queue and respond immediately:
// Add to 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 does not respond with status code 200, dpay.pl will retry the 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 hr |
| 7 | 64 min | ~2 hrs |
| 8 | 128 min | ~4 hrs |
| 9 | 256 min | ~8.5 hrs |
| 10 | 512 min | ~17 hrs |
The delays follow an exponential backoff scheme (2^n minutes). After 10 failed attempts, the notification will no longer be retried. You can manually check the transaction status in the admin dashboard.
Querying the transaction status manually
In addition to waiting for IPN, you can check the transaction status yourself using the API. This is useful in several scenarios:
- IPN did not arrive - e.g., due to network issues on your server
- Reconciliation - periodic reconciliation of transaction statuses with your database
- Additional safeguard - as a supplement to the IPN mechanism
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) |
Example response
{
"service": "success",
"transaction": {
"transaction_id": "abc-def-123-456",
"value": "29.99",
"status": "paid",
"creation_date": "2026-01-15 12:30:00",
"payment_date": "2026-01-15 12:31:45",
"settled": true,
"refunded": false
}
}
Transaction statuses
| Status | Description |
|---|---|
paid | Transaction paid |
created | Transaction created, awaiting payment |
processing | Payment is being processed |
canceled | Transaction canceled |
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['service'] === 'success' && $result['transaction']['status'] === 'paid') {
// Transaction paid - update the order status
markOrderAsPaid($transactionId, $result['transaction']['value']);
}
Querying the transaction status manually is a good practice as a fallback mechanism. For example, you can run a cron job that checks the statuses of transactions awaiting payment for more than 15 minutes every few minutes.
Testing IPN
- In the panel.dpay.pl dashboard, go to the Payment Point.
- Use the Send test IPN option to send a sample notification.
- For local testing, use tools like ngrok to expose your local server under a public URL.
# Start an ngrok tunnel
ngrok http 3000
# Use the generated address as url_ipn:
# https://abc123.ngrok.io/api/ipn