Generating checksums
A checksum is a security mechanism that protects the integrity of data transmitted between your server and dpay.pl. Every API request requires a valid checksum generated using your Secret Hash.
How it works
A checksum is a SHA-256 hash generated by joining specific fields, with or without a separator depending on the endpoint. dpay.pl verifies the checksum on its side. If it does not match the expected value, the request is rejected.
The Secret Hash is a private key. Never expose it publicly, include it in frontend code, or commit it to a source code repository. Store it only in server-side environment variables.
Checksum formats for different endpoints
Payment registration (payments/register)
sha256({service}|{SecretHash}|{value}|{url_success}|{url_fail}|{url_ipn})
| Element | Description |
|---|---|
service | Service name from the dashboard |
SecretHash | Your private key |
value | Payment amount (for example, "29.99") |
url_success | Success URL |
url_fail | Failure URL |
url_ipn | IPN notification URL |
Separator: | (pipe)
value fieldBefore calculating the checksum, the server normalises value to two decimal places. The value "10" is hashed as "10.00", and "10.5" as "10.50". Always generate the checksum from the normalised value, or the checksums will not match.
Refunds, transaction details, and payout details (pbl/refund, pbl/details, pbl/check-refund-availability, pbl/withdraws/details)
For these endpoints, the server does not use a fixed field list. The checksum is calculated from the values of all body fields in the order in which they are sent, excluding the checksum field. Your Secret Hash is appended at the end:
sha256({field_1}|{field_2}|...|{field_N}|{SecretHash})
Example - full refund (body: service, transaction_id, checksum):
sha256({service}|{transaction_id}|{SecretHash})
Example - partial refund (body: service, transaction_id, value, checksum):
sha256({service}|{transaction_id}|{value}|{SecretHash})
Separator: | (pipe) - the Secret Hash is also preceded by a separator.
The checksum must include every field sent in the body, including optional fields such as reason for a refund. If you add a field to the request but omit it from the checksum, the checksum will not match. An invalid checksum for these endpoints returns HTTP 401 with the Unauthorized request message, not HTTP 400.
Direct Carrier Billing (DCB) - registration through the payments API
DCB registration through the standard payment registration endpoint (transactionType: dcb_gateway) uses the standard registration checksum:
sha256({service}|{SecretHash}|{value}|{url_success}|{url_fail}|{url_ipn})
The value field is normalised to two decimal places in the same way as for standard payment registration.
Direct Carrier Billing (DCB) - legacy registration endpoint (secure.dpay.pl/dcb/register)
sha256({GUID}|{SecretHash}|{value}|{url_success}|{url_fail})
| Element | Description |
|---|---|
GUID | Payment Point GUID identifier |
SecretHash | Your private key |
value | Amount in Polish zloty with two decimal places (for example, "10.23") |
url_success | Success URL |
url_fail | Failure URL |
Separator: | (pipe)
In the legacy request body, provide value in grosz (for example, 1023), but include the amount converted to Polish zloty with two decimal places in the checksum (1023 in the body becomes "10.23" in the checksum). The legacy checksum does not contain url_ipn.
Verifying an IPN signature
sha256({id}{SecretHash}{amount}{email}{type}{attempt}{version}{custom})
| Element | Description |
|---|---|
id | Transaction identifier |
SecretHash | Your private key |
amount | Transaction amount |
email | Customer email address |
type | Notification type |
attempt | Delivery attempt number |
version | Protocol version |
custom | Custom data |
No separator - concatenate the values directly without any separating character.
Verifying an IPN signature - DCB (Direct Carrier Billing)
For dcb notifications, the payload does not contain the email field:
sha256({id}{SecretHash}{amount}{type}{attempt}{version}{custom})
No separator - as with standard IPN, but without email.
Implementation examples
PHP
<?php
/**
* Generate a checksum for payment registration
*/
function generatePaymentChecksum(
string $service,
string $secretHash,
string $value,
string $urlSuccess,
string $urlFail,
string $urlIpn
): string {
$data = implode('|', [
$service,
$secretHash,
$value,
$urlSuccess,
$urlFail,
$urlIpn,
]);
return hash('sha256', $data);
}
/**
* Generate a checksum for refunds, transaction details, and payout details.
*
* $fields = values of ALL body fields in the order in which they are sent
* (excluding the checksum field), including optional fields such as reason.
*/
function generateRequestChecksum(array $fields, string $secretHash): string
{
$data = implode('|', [...array_values($fields), $secretHash]);
return hash('sha256', $data);
}
// Full refund (body: service, transaction_id, checksum)
// $checksum = generateRequestChecksum([$service, $transactionId], $secretHash);
// Partial refund (body: service, transaction_id, value, checksum)
// $checksum = generateRequestChecksum([$service, $transactionId, '15.00'], $secretHash);
/**
* Generate a checksum for DCB (legacy endpoint secure.dpay.pl/dcb/register).
*
* Amount in Polish zloty with two decimal places (for example, "10.23"),
* even though value is supplied in grosz in the request body. No url_ipn.
* DCB registration through the payments API uses generatePaymentChecksum().
*/
function generateDcbChecksum(
string $guid,
string $secretHash,
string $valueInZlotych,
string $urlSuccess,
string $urlFail
): string {
$data = implode('|', [
$guid,
$secretHash,
$valueInZlotych,
$urlSuccess,
$urlFail,
]);
return hash('sha256', $data);
}
/**
* Verify an IPN signature
*/
function verifyIpnSignature(array $data, string $secretHash): bool
{
$expected = hash('sha256',
$data['id'] . $secretHash . $data['amount'] . $data['email']
. $data['type'] . $data['attempt'] . $data['version'] . $data['custom']
);
return hash_equals($expected, $data['signature']);
}
// === Usage example ===
$service = getenv('DPAY_SERVICE');
$secretHash = getenv('DPAY_SECRET_HASH');
// Payment checksum
$checksum = generatePaymentChecksum(
$service,
$secretHash,
'29.99',
'https://myshop.example/success',
'https://myshop.example/failure',
'https://myshop.example/api/ipn'
);
echo $checksum; // for example, "a1b2c3d4e5f6..."
JavaScript (Node.js)
const crypto = require('crypto');
/**
* Generate a checksum for payment registration
*/
function generatePaymentChecksum(service, secretHash, value, urlSuccess, urlFail, urlIpn) {
const data = [service, secretHash, value, urlSuccess, urlFail, urlIpn].join('|');
return crypto.createHash('sha256').update(data).digest('hex');
}
/**
* Generate a checksum for refunds, transaction details, and payout details.
* fields = values of ALL body fields in the order in which they are sent
* (excluding the checksum field), including optional fields such as reason.
*/
function generateRequestChecksum(fields, secretHash) {
const data = [...fields, secretHash].join('|');
return crypto.createHash('sha256').update(data).digest('hex');
}
// Full refund (body: service, transaction_id, checksum):
// generateRequestChecksum([service, transactionId], secretHash)
// Partial refund (body: service, transaction_id, value, checksum):
// generateRequestChecksum([service, transactionId, '15.00'], secretHash)
/**
* Generate a checksum for DCB (legacy endpoint secure.dpay.pl/dcb/register).
* Amount in Polish zloty with two decimal places (for example, "10.23"),
* even though value is supplied in grosz in the request body. No url_ipn.
* DCB registration through the payments API uses generatePaymentChecksum().
*/
function generateDcbChecksum(guid, secretHash, valueInZlotych, urlSuccess, urlFail) {
const data = [guid, secretHash, valueInZlotych, urlSuccess, urlFail].join('|');
return crypto.createHash('sha256').update(data).digest('hex');
}
/**
* Verify an IPN signature
*/
function verifyIpnSignature(data, secretHash) {
const raw = [
data.id, secretHash, data.amount, data.email,
data.type, data.attempt, data.version, data.custom,
].join('');
const expected = crypto.createHash('sha256').update(raw).digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(data.signature, 'hex')
);
}
// === Usage example ===
const service = process.env.DPAY_SERVICE;
const secretHash = process.env.DPAY_SECRET_HASH;
const checksum = generatePaymentChecksum(
service,
secretHash,
'29.99',
'https://myshop.example/success',
'https://myshop.example/failure',
'https://myshop.example/api/ipn'
);
console.log(checksum);
Python
import hashlib
import hmac
import os
def generate_payment_checksum(
service: str,
secret_hash: str,
value: str,
url_success: str,
url_fail: str,
url_ipn: str,
) -> str:
"""Generate a checksum for payment registration."""
data = '|'.join([service, secret_hash, value, url_success, url_fail, url_ipn])
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def generate_request_checksum(fields: list[str], secret_hash: str) -> str:
"""Checksum for refunds, transaction details, and payout details.
fields - values of ALL body fields in the order in which they are sent
(excluding the checksum field), including optional fields such as reason.
"""
data = '|'.join([*fields, secret_hash])
return hashlib.sha256(data.encode('utf-8')).hexdigest()
# Full refund (body: service, transaction_id, checksum):
# generate_request_checksum([service, transaction_id], secret_hash)
# Partial refund (body: service, transaction_id, value, checksum):
# generate_request_checksum([service, transaction_id, '15.00'], secret_hash)
def generate_dcb_checksum(
guid: str,
secret_hash: str,
value_in_zlotych: str,
url_success: str,
url_fail: str,
) -> str:
"""Checksum for DCB (legacy endpoint secure.dpay.pl/dcb/register).
Amount in Polish zloty with two decimal places (for example, "10.23"),
even though value is supplied in grosz in the request body. No url_ipn.
DCB registration through the payments API uses generate_payment_checksum().
"""
data = '|'.join([guid, secret_hash, value_in_zlotych, url_success, url_fail])
return hashlib.sha256(data.encode('utf-8')).hexdigest()
def verify_ipn_signature(data: dict, secret_hash: str) -> bool:
"""Verify an IPN signature."""
raw = (
data['id'] + secret_hash + data['amount'] + data['email']
+ data['type'] + str(data['attempt']) + data['version'] + data['custom']
)
expected = hashlib.sha256(raw.encode('utf-8')).hexdigest()
return hmac.compare_digest(expected, data['signature'])
# === Usage example ===
service = os.environ['DPAY_SERVICE']
secret_hash = os.environ['DPAY_SECRET_HASH']
checksum = generate_payment_checksum(
service,
secret_hash,
'29.99',
'https://myshop.example/success',
'https://myshop.example/failure',
'https://myshop.example/api/ipn',
)
print(checksum)
Common mistakes
1. Incorrect field order
Fields must appear in exactly the order specified in the documentation. Changing their order produces a different checksum.
2. Extra whitespace
Make sure field values contain no extra spaces, newline characters, or other whitespace:
// WRONG - extra space
$value = '29.99 ';
// CORRECT
$value = trim('29.99');
3. Character encoding
Use UTF-8 encoding. Special characters in URLs, including non-ASCII characters, can produce different results depending on the encoding.
4. Amount without two decimal places
The server normalises value to two decimal places before calculating the registration checksum:
// WRONG - the server hashes "10" as "10.00", so the checksums will not match
$value = '10';
// CORRECT
$value = number_format(10, 2, '.', ''); // "10.00"
5. Omitting an optional field (refunds, transaction details, and payout details)
For the pbl/refund, pbl/details, pbl/check-refund-availability, and pbl/withdraws/details endpoints, the checksum includes all body fields in the order in which they are sent. If you add an optional field such as reason but do not include it in the checksum, you receive HTTP 401 with Unauthorized request.
6. Timing-attack-safe comparison
When verifying an IPN signature, use a comparison method that is resistant to timing attacks:
// WRONG - vulnerable to a timing attack
if ($expected === $data['signature']) { ... }
// CORRECT - resistant to timing attacks
if (hash_equals($expected, $data['signature'])) { ... }
// CORRECT - Node.js
crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(signature, 'hex')
);
# CORRECT - Python
import hmac
hmac.compare_digest(expected, signature)
Debugging checksums
If you receive an Invalid checksum error (payment registration, HTTP 400) or Unauthorized request (refunds, transaction details, or payout details, HTTP 401), follow these steps:
- Print the input data - check the exact value of every field.
- Check the separator - use
|(pipe) for payment registration, refunds, and DCB. For an IPN signature, concatenate values without a separator. - Verify amount normalisation -
valuein the registration checksum always has two decimal places ("10.00", not"10"). - Check the complete field list - for refunds, transaction details, and payout details, include every body field in the order sent, including optional fields.
- Verify the Secret Hash - make sure you are using the current key from the dashboard.
- Compare with an online tool - use any SHA-256 generator and compare its result with the output from your code.
// Debugging - print the data before hashing
$raw = $service . '|' . $secretHash . '|' . $value . '|' .
$urlSuccess . '|' . $urlFail . '|' . $urlIpn;
error_log('Checksum input: ' . $raw);
error_log('Checksum output: ' . hash('sha256', $raw));