Skip to main content

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.

info

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.

Rule

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

FieldTypeDescription
idstringdpay.pl transaction identifier
amountstringTransaction amount
emailstringCustomer email address (if provided)
typestringNotification type: "transfer" or "capture"
attemptintegerDelivery attempt number (1, 2, 3...)
versionstringIPN protocol version (currently "1")
customstringCustom data passed during registration (optional)
capture_payment_idstringCapture identifier - only for "capture" type
signaturestringDigital signature for authenticity verification

Notification types

TypeDescription
transferStandard payment completed successfully - you can fulfill the order
captureCard 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:

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

FieldTypeRequiredDescription
servicestringyesService name (Payment Point)
transaction_idstringyesTransaction UUID
checksumstringyesChecksum - 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

StatusDescription
paidTransaction paid
createdTransaction created, awaiting payment
processingPayment is being processed
canceledTransaction 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']);
}
tip

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

  1. In the panel.dpay.pl dashboard, go to the Payment Point.
  2. Use the Send test IPN option to send a sample notification.
  3. 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