Skip to main content

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.

caution

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})
ElementDescription
serviceService name from the dashboard
SecretHashYour private key
valuePayment amount (for example, "29.99")
url_successSuccess URL
url_failFailure URL
url_ipnIPN notification URL

Separator: | (pipe)

Normalising the value field

Before 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.

Every body field is included in the checksum

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})
ElementDescription
GUIDPayment Point GUID identifier
SecretHashYour private key
valueAmount in Polish zloty with two decimal places (for example, "10.23")
url_successSuccess URL
url_failFailure URL

Separator: | (pipe)

Differences from the standard registration checksum

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})
ElementDescription
idTransaction identifier
SecretHashYour private key
amountTransaction amount
emailCustomer email address
typeNotification type
attemptDelivery attempt number
versionProtocol version
customCustom 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:

  1. Print the input data - check the exact value of every field.
  2. Check the separator - use | (pipe) for payment registration, refunds, and DCB. For an IPN signature, concatenate values without a separator.
  3. Verify amount normalisation - value in the registration checksum always has two decimal places ("10.00", not "10").
  4. Check the complete field list - for refunds, transaction details, and payout details, include every body field in the order sent, including optional fields.
  5. Verify the Secret Hash - make sure you are using the current key from the dashboard.
  6. 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));