Obsługa IPN (Instant Payment Notification)
IPN to mechanizm powiadamiania Twojego serwera o udanej transakcji. dpay.pl wysyła zapytanie POST na adres url_ipn skonfigurowany dla danej transakcji.
IPN jest wysyłany wyłącznie dla udanych transakcji (status opłacony). Nieudane płatności nie generują powiadomień IPN.
Dlaczego IPN jest ważny?
Przekierowanie klienta na url_success nie oznacza, że płatność została zrealizowana - klient mógł zamknąć przeglądarkę, stracić połączenie lub zmanipulować URL. Tylko IPN jest wiarygodnym źródłem informacji o statusie płatności.
Nigdy nie aktualizuj statusu zamówienia na podstawie przekierowania klienta. Zawsze czekaj na potwierdzenie przez IPN.
Schemat IPN v1
dpay.pl wysyła na Twój endpoint zapytanie POST z nagłówkiem Content-Type: application/json i następującym ciałem:
{
"id": "abc-def-123-456",
"amount": "29.99",
"email": "klient@example.com",
"type": "transfer",
"attempt": 1,
"version": "1",
"custom": "order-789",
"signature": "a1b2c3d4e5f6..."
}
Opis pól
| Pole | Typ | Opis |
|---|---|---|
id | string | Identyfikator transakcji dpay.pl |
amount | string | Kwota transakcji |
email | string | Adres e-mail klienta (jeśli podany) |
type | string | Typ powiadomienia: "transfer", "capture" lub "dcb" |
attempt | integer | Numer próby dostarczenia (aktualnie zawsze 1) |
version | integer | Wersja protokołu IPN (aktualnie 1) |
custom | string | Dane własne przekazane przy rejestracji (opcjonalne) |
capture_payment_id | string | Identyfikator przechwycenia - tylko dla typu "capture" |
signature | string | Podpis cyfrowy do weryfikacji autentyczności |
Typy powiadomień
| Typ | Opis |
|---|---|
transfer | Standardowa płatność zakończona sukcesem - możesz zrealizować zamówienie |
capture | Przechwycenie płatności kartowej - zawiera dodatkowe pole capture_payment_id |
dcb | Płatność Direct Carrier Billing - payload nie zawiera pola email |
Weryfikacja podpisu (signature)
Zawsze weryfikuj podpis IPN, aby upewnić się, że powiadomienie pochodzi od dpay.pl, a nie od osoby trzeciej.
Podpis generowany jest algorytmem SHA-256 z bezpośredniej konkatenacji wartości pól i Twojego Secret Hash:
sha256({id}{SecretHash}{amount}{email}{type}{attempt}{version}{custom})
W przeciwieństwie do checksum rejestracji płatności, podpis IPN nie używa separatora |. Wartości są konkatenowane bezpośrednio.
DCB (Direct Carrier Billing)
Dla powiadomień typu dcb payload nie zawiera pola email, a formuła sygnatury jest inna:
sha256({id}{SecretHash}{amount}{type}{attempt}{version}{custom})
PHP
<?php
// Odczytaj dane IPN
$data = json_decode(file_get_contents('php://input'), true);
if (!$data) {
http_response_code(400);
echo 'Invalid payload';
exit;
}
$privateKey = 'your_secret_hash'; // Twój Secret Hash
// Wygeneruj oczekiwany podpis (bez separatorów - bezpośrednia konkatenacja)
$checksum = hash('sha256',
$data['id'] . $privateKey . $data['amount'] . $data['email']
. $data['type'] . $data['attempt'] . $data['version'] . $data['custom']
);
// Zweryfikuj podpis
if ($checksum !== $data['signature']) {
// Podpis nieprawidłowy - odrzuć powiadomienie
http_response_code(200);
echo 'OK';
exit;
}
// Podpis prawidłowy - przetwórz płatność
$transactionId = $data['id'];
$amount = $data['amount'];
$type = $data['type'];
switch ($type) {
case 'transfer':
// Standardowa płatność - zaktualizuj status zamówienia na "opłacone"
markOrderAsPaid($transactionId, $amount);
break;
case 'capture':
// Przechwycenie karty - przetwórz z capture_payment_id
$captureId = $data['capture_payment_id'];
markOrderAsCaptured($transactionId, $amount, $captureId);
break;
}
// Odpowiedź 200 OK - dpay.pl nie będzie ponawiać próby
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;
// Wygeneruj oczekiwany podpis (bez separatorów - bezpośrednia konkatenacja)
const expectedSignature = crypto
.createHash('sha256')
.update(
[data.id, secretHash, data.amount, data.email,
data.type, data.attempt, data.version, data.custom].join('')
)
.digest('hex');
// Zweryfikuj podpis
if (expectedSignature !== data.signature) {
console.error('Nieprawidłowy podpis IPN');
return res.status(200).send('OK');
}
// Przetwórz płatność
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']
# Wygeneruj oczekiwany podpis (bez separatorów - bezpośrednia konkatenacja)
raw = (
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()
# Zweryfikuj podpis
if expected_signature != data['signature']:
return 'OK', 200 # Odrzuć cicho
# Przetwórz płatność
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
Najlepsze praktyki
1. Zawsze odpowiadaj kodem HTTP 200
dpay.pl oczekuje odpowiedzi z kodem 200 i treścią "OK". Jeśli Twój serwer odpowie innym kodem, dpay.pl ponowi próbę dostarczenia IPN.
2. Idempotentność
Twój endpoint IPN może otrzymać to samo powiadomienie wielokrotnie (np. z powodu timeoutu sieci). Upewnij się, że przetwarzasz każdą transakcję tylko raz:
// Sprawdź, czy transakcja nie została już przetworzona
$order = getOrderByTransactionId($data['id']);
if ($order && $order['status'] === 'paid') {
// Już przetworzone - odpowiedz OK i zakończ
http_response_code(200);
echo 'OK';
exit;
}
3. Weryfikuj kwotę
Zawsze sprawdzaj, czy kwota z IPN zgadza się z kwotą zamówienia w Twojej bazie danych:
$order = getOrderByTransactionId($data['id']);
if (floatval($data['amount']) !== floatval($order['amount'])) {
// Niezgodność kwoty - możliwa próba oszustwa
logSecurityAlert('Amount mismatch', $data);
http_response_code(200);
echo 'OK';
exit;
}
4. Nie wykonuj długich operacji synchronicznie
Jeśli przetwarzanie płatności wymaga dłuższego czasu (np. generowanie pliku, wysyłanie e-maila), dodaj zadanie do kolejki i odpowiedz natychmiast:
// Dodaj do kolejki zamiast przetwarzać synchronicznie
$queue->push('process_payment', [
'transaction_id' => $data['id'],
'amount' => $data['amount'],
]);
http_response_code(200);
echo 'OK';
5. Logowanie
Loguj wszystkie przychodzące IPN do celów diagnostycznych:
file_put_contents(
'/var/log/dpay-ipn.log',
date('Y-m-d H:i:s') . ' ' . json_encode($data) . PHP_EOL,
FILE_APPEND
);
Mechanizm ponawiania
Jeśli Twój serwer nie odpowie kodem 200, dpay.pl ponowi próbę dostarczenia IPN:
| Próba | Opóźnienie | Czas od pierwszej próby |
|---|---|---|
| 1 | Natychmiast | 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 godz |
| 7 | 64 min | ~2 godz |
| 8 | 128 min | ~4 godz |
| 9 | 256 min | ~8.5 godz |
| 10 | 512 min | ~17 godz |
Opóźnienia stosują schemat exponential backoff (2^n minut). Po 10 nieudanych próbach powiadomienie nie będzie już ponawiane. Możesz ręcznie sprawdzić status transakcji w panelu administracyjnym.
Samodzielne odpytywanie o status transakcji
Oprócz oczekiwania na IPN, możesz samodzielnie sprawdzić status transakcji za pomocą API. Jest to przydatne w kilku scenariuszach:
- IPN nie dotarł - np. z powodu problemów sieciowych po stronie Twojego serwera
- Reconciliation - okresowe uzgadnianie statusów transakcji z bazą danych
- Dodatkowe zabezpieczenie - jako uzupełnienie mechanizmu IPN
Endpoint
POST https://panel.dpay.pl/api/v1/pbl/details
Content-Type: application/json
Parametry zapytania
| Pole | Typ | Wymagane | Opis |
|---|---|---|---|
service | string | tak | Nazwa serwisu (Punktu Płatności) |
transaction_id | string | tak | UUID transakcji |
checksum | string | tak | Suma kontrolna - sha256(service|transaction_id|secret_hash) |
Przykład odpowiedzi
{
"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://mojsklep.pl/sukces",
"fail": "https://mojsklep.pl/blad",
"ipn": "https://mojsklep.pl/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"
}
]
}
Pola związane ze zwrotami
| Pole | Typ | Opis |
|---|---|---|
transaction.refunded | bool | Czy transakcja została zwrócona (flaga legacy) |
transaction.refunded_amount | number | Suma zwróconych kwot w PLN |
transaction.available_refund_amount | number | Pozostała kwota możliwa do zwrotu w PLN (value - refunded_amount, minimum 0) |
transaction.fully_refunded | bool | Czy transakcja jest w pełni zwrócona. Dla BLIK/SIBS (karty)/PayU liczone jako available_refund_amount == 0; dla pozostałych providerów odpowiada polu refunded |
refunds | array | Lista zwrotów powiązanych z transakcją. Zawsze obecna (pusta tablica przy braku zwrotów). Sortowanie rosnąco po creation_date |
refunds[].payment_id | string | Identyfikator zwrotu w formacie ZWROT-{parent_payment_id}-{8 znaków} |
refunds[].value | number | Kwota zwrotu w PLN, zawsze ujemna (np. -50.00) |
refunds[].status | string | Status zwrotu (patrz sekcja "Statusy transakcji" poniżej). Domyślnie paid |
refunds[].creation_date | string | Data utworzenia zwrotu (YYYY-MM-DD HH:MM:SS) |
refunds[].payment_date | string | Data realizacji zwrotu (YYYY-MM-DD HH:MM:SS) |
Statusy transakcji
| Status | Opis |
|---|---|
paid | Transakcja opłacona |
created | Transakcja utworzona, oczekuje na płatność |
processing | Płatność w trakcie przetwarzania |
expired | Transakcja wygasła |
captured | Płatność kartowa przechwycona |
Przykład - 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') {
// Transakcja opłacona - zaktualizuj status zamówienia
markOrderAsPaid($transactionId, $result['transaction']['value']);
}
Samodzielne odpytywanie o status to dobra praktyka jako mechanizm awaryjny. Możesz np. uruchomić zadanie cron, które co kilka minut sprawdza statusy transakcji oczekujących na płatność dłużej niż 15 minut.
Testowanie IPN
- W panelu panel.dpay.pl przejdź do Punktu Płatności.
- Użyj opcji Wyślij testowe IPN, aby wysłać przykładowe powiadomienie.
- Do testów lokalnych użyj narzędzi typu ngrok, aby udostępnić lokalny serwer pod publicznym adresem URL.
# Uruchom tunel ngrok
ngrok http 3000
# Użyj wygenerowanego adresu jako url_ipn:
# https://abc123.ngrok.io/api/ipn