VulCode
Blog

Przelewy24 REST API: płatność od rejestracji do weryfikacji

Rejestrujesz transakcję, przekierowujesz klienta do Przelewy24, odbierasz powiadomienie i potwierdzasz wpłatę metodą verify. Bez weryfikacji pieniądze nie trafią na Twoje konto. Kod w PHP 8 sprawdzony na sandboksie.

VulCodeVulCode13 min czytania
Przelewy24 REST API: płatność od rejestracji do weryfikacji - VulCode
Co zbudujeszPHP, 7 kroków
  1. RejestracjaTwój skleptransaction/register zwraca token.
  2. Płatność w P24KlientPrzekierowanie na /trnRequest/{token}.
  3. PowiadomienieP24JSON na Twój urlStatus z podpisem.
  4. WeryfikacjaTwój skleptransaction/verify potwierdza wpłatę.

Poradnik w skrócie

Klient klika "Zapłać", płaci w Przelewy24 i wraca do sklepu, a zamówienie samo zmienia status na opłacone. Za tym stoją cztery wywołania i jedna zasada, o której wiele integracji zapomina: transakcja jest potwierdzona dopiero po weryfikacji.

Przelewy24 REST API

Grupa
Faktury i płatności
Poziom
średni
Czas czytania
około 13 min
Kod
PHP

Pliki: Przelewy24.php, konfiguracja.php, checkout.php, p24-powiadomienie.php, zamowienie.php.

Kroki
7 kroków

W 7 z nich kod albo polecenia do skopiowania.

Sprawdzenie
Sandbox Przelewy24, PHP 8.3

401 dla celowo złego klucza i 96-znakowy podpis SHA-384

Dokumentacja
2 oficjalne dokumenty

Przelewy24 REST API, dokumentacja; Przelewy24, centrum pomocy dla sprzedawców

Z naszego kodu
Flaem

frozcore/src/payments/gateways/przelewy24.rs

Zanim zaczniesz

Dokumentacja Przelewy24 mówi wprost: jeśli klient zapłaci, a sprzedawca nie zweryfikuje transakcji, kwota nie trafi do sprzedawcy ani do rozliczeń i zostanie u klienta jako przedpłata.

Co musisz mieć
Przelewy24 REST API
Konto w sandboksie Przelewy24
Sandbox używa danych testowych i osobnych kluczy. Z panelu potrzebujesz ID sprzedawcy, ID sklepu (posId), klucza CRC i klucza do raportów, który w API pełni rolę hasła.
PHP 8.1 lub nowszy z curl i json
Klasa w przykładzie używa właściwości readonly z PHP 8.1. Uruchamialiśmy ją na PHP 8.3.
Publiczny adres HTTPS na powiadomienia
Przelewy24 wysyła wynik płatności na urlStatus z internetu. Na komputerze lokalnym potrzebujesz tunelu albo serwera testowego.
Tabela zamówień z kwotą
Przed rejestracją zapisujesz sessionId i kwotę, żeby potem porównać je z powiadomieniem. Tak każe dokumentacja.

Adresy i wywołania

CoAdresMetoda
Sandboxhttps://sandbox.przelewy24.pl-
Produkcjahttps://secure.przelewy24.pl-
Test dostępu/api/v1/testAccessGET
Rejestracja/api/v1/transaction/registerPOST
Weryfikacja/api/v1/transaction/verifyPUT
Zwrot/api/v1/transaction/refundPOST

Krok po kroku

7 kroków. Okno obok pokazuje plik kroku, który właśnie czytasz.

Napisz klienta API i podpis

Uwierzytelnienie to HTTP Basic: użytkownik to posId, hasło to klucz do raportów. Każde ważne żądanie ma pole sign: skrót SHA-384 z JSON-a z określonymi polami w określonej kolejności, zawsze zakończonego kluczem CRC.

Dokumentacja wymaga flag JSON_UNESCAPED_UNICODE i JSON_UNESCAPED_SLASHES. Bez nich ukośnik w adresie albo polska litera w opisie zmienią skrót i podpis nie przejdzie.

Sprawdź dane dostępowe

GET /api/v1/testAccess odpowiada 200 przy dobrych danych. Przy złych dostaniesz 401 i komunikat "Incorrect authentication". Zrób to raz przy konfiguracji, zanim zaczniesz szukać błędu w podpisie.

Zapisz zamówienie przed rejestracją

sessionId to Twój unikalny identyfikator transakcji. Kwoty w Przelewy24 są w groszach: 123,00 zł to 12300. Zapisz oba pola przy zamówieniu, bo powiadomienie porównasz właśnie z nimi.

Zarejestruj transakcję i przekieruj klienta

Wymagane pola to merchantId, posId, sessionId, amount, currency, description, email, country, language, urlReturn i sign. urlStatus jest opcjonalny w schemacie, ale bez niego nie dostaniesz powiadomienia, więc w praktyce jest obowiązkowy.

Podpis rejestracji obejmuje sessionId, merchantId, amount, currency i crc, w tej kolejności. Odpowiedź zawiera token, a klienta wysyłasz na /trnRequest/{token} w tym samym środowisku.

Odbierz i sprawdź powiadomienie

Po opłaceniu Przelewy24 wysyła JSON na urlStatus, niezależnie od tego, czy klient wrócił do sklepu. Powiadomienie przychodzi tylko dla transakcji opłaconych poprawnie.

Najpierw liczysz podpis z dziesięciu pól (od merchantId do statement plus crc) i porównujesz go w czasie stałym. Potem sprawdzasz, że kwota i waluta zgadzają się z zamówieniem. Dopiero wtedy przechodzisz do weryfikacji.

Zweryfikuj transakcję

PUT /api/v1/transaction/verify z orderId z powiadomienia. Podpis obejmuje sessionId, orderId, amount, currency i crc. Odpowiedź data.status równa "success" oznacza, że pieniądze są Twoje: dopiero teraz oznaczasz zamówienie jako opłacone.

Jeśli weryfikacja nie nastąpi, Przelewy24 ponawia powiadomienie po 3, 5, 15, 30, 60, 150 i 450 minutach. Twój kod musi więc znieść to samo powiadomienie kilka razy i nie oznaczyć zamówienia dwa razy.

Obsłuż powrót klienta

urlReturn to tylko adres, na który wraca przeglądarka. Powrót nie jest dowodem zapłaty: klient może wrócić, zanim dotrze powiadomienie, albo wrócić bez płacenia. Pokaż stan zamówienia z bazy.

Uwaga
Parametr waitForResult w rejestracji każe Przelewy24 przekierować klienta dopiero po otrzymaniu wyniku transakcji. Skraca to czas, w którym klient widzi "czekamy na potwierdzenie".
Okno kodukrok 1 z 7
Przelewy24.phpNowy plik, 39 linii
<?phpdeclare(strict_types=1); final class Przelewy24{    public function __construct(        public readonly string $baseUrl,  // https://sandbox.przelewy24.pl        public readonly int $merchantId,        public readonly int $posId,        private string $apiKey,           // klucz do raportów (API) z panelu        private string $crc,              // klucz CRC z panelu    ) {}     /** SHA-384 z JSON-a o stałej kolejności pól, zawsze zakończonego kluczem CRC. */    public function sign(array $fields): string    {        $fields['crc'] = $this->crc;        return hash('sha384', json_encode($fields, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));    }     public function call(string $method, string $path, ?array $body = null): array    {        $curl = curl_init($this->baseUrl . $path);        curl_setopt_array($curl, [            CURLOPT_CUSTOMREQUEST => $method,            CURLOPT_USERPWD => $this->posId . ':' . $this->apiKey,            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],            CURLOPT_RETURNTRANSFER => true,            CURLOPT_TIMEOUT => 30,        ]);        if ($body !== null) {            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));        }        $raw = (string) curl_exec($curl);        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);        curl_close($curl);        return [$status, json_decode($raw, true)];    }}
konfiguracja.phpNowy plik, 10 linii
$p24 = new Przelewy24(    'https://sandbox.przelewy24.pl',    (int) getenv('P24_MERCHANT_ID'),    (int) getenv('P24_POS_ID'),    getenv('P24_API_KEY'),    getenv('P24_CRC'),); [$status, $body] = $p24->call('GET', '/api/v1/testAccess');// 200 przy poprawnych danych, 401 {"error":"Incorrect authentication"} przy złych
checkout.phpNowy plik, 5 linii
$sessionId = 'zamowienie-' . $order->id;  // unikalny w Twoim systemie$amount = 12300;                           // w groszach: 123,00 zł $db->prepare('UPDATE orders SET p24_session = ?, p24_amount = ? WHERE id = ?')   ->execute([$sessionId, $amount, $order->id]);
checkout.phpZmiana w pliku: 23 linie
[$status, $body] = $p24->call('POST', '/api/v1/transaction/register', [    'merchantId' => $p24->merchantId,    'posId' => $p24->posId,    'sessionId' => $sessionId,    'amount' => $amount,    'currency' => 'PLN',    'description' => 'Zamówienie ' . $order->id,    'email' => $order->email,    'country' => 'PL',    'language' => 'pl',    'urlReturn' => 'https://sklep.example/zamowienie/' . $order->id,    'urlStatus' => 'https://sklep.example/p24/powiadomienie',    'sign' => $p24->sign([        'sessionId' => $sessionId,        'merchantId' => $p24->merchantId,        'amount' => $amount,        'currency' => 'PLN',    ]),]); if ($status !== 200) {    throw new RuntimeException('P24 register: ' . json_encode($body));}header('Location: ' . $p24->baseUrl . '/trnRequest/' . $body['data']['token']);
p24-powiadomienie.phpNowy plik, 23 linie
$n = json_decode(file_get_contents('php://input'), true); $expected = $p24->sign([    'merchantId' => $n['merchantId'],    'posId' => $n['posId'],    'sessionId' => $n['sessionId'],    'amount' => $n['amount'],    'originAmount' => $n['originAmount'],    'currency' => $n['currency'],    'orderId' => $n['orderId'],    'methodId' => $n['methodId'],    'statement' => $n['statement'],]);if (!hash_equals($expected, (string) ($n['sign'] ?? ''))) {    http_response_code(400);    exit;} $order = findOrderBySessionId($n['sessionId']);if ($order === null || $order->p24_amount !== $n['amount'] || $n['currency'] !== 'PLN') {    http_response_code(400);    exit;}
p24-powiadomienie.phpZmiana w pliku: 8 linii
[$status, $body] = $p24->call('PUT', '/api/v1/transaction/verify', [    'merchantId' => $p24->merchantId,    'posId' => $p24->posId,    'sessionId' => $n['sessionId'],    'amount' => $n['amount'],    'currency' => $n['currency'],    'orderId' => $n['orderId'],    'sign' => $p24->sign([        'sessionId' => $n['sessionId'],        'orderId' => $n['orderId'],        'amount' => $n['amount'],        'currency' => $n['currency'],    ]),]); if ($status === 200 && ($body['data']['status'] ?? null) === 'success') {    markOrderPaid($order, $n['orderId']);  // dopiero tu zamówienie jest opłacone}http_response_code(200);
zamowienie.phpNowy plik, 6 linii
// zamowienie.php, adres z urlReturnif ($order->paid_at === null) {    echo 'Czekamy na potwierdzenie płatności z Przelewy24. Odśwież stronę za chwilę.';} else {    echo 'Płatność przyjęta. Dziękujemy!';}

Sprawdź, czy działa

Klasę i podpis uruchomiliśmy 6 października 2026 r. na PHP 8.3. Test dostępu z celowo złym kluczem zwrócił z sandboksu 401, a podpis dla przykładowych danych to 96-znakowy skrót SHA-384. Ten sam skrót policzysz w swoim środowisku, żeby sprawdzić, że kodowanie JSON-a się zgadza.

terminal6 października 2026Sandbox Przelewy24, PHP 8.3
$ php test.php
401 {"error":"Incorrect authentication","code":401}
sign(zamowienie-1042, 11111, 12300, PLN, crc abcdef1234567890):
f71f64482a628172732cd86ec3e112cb351ab167366178593f3e394507346408
2658a500bdef37c6566755416a6bf5de
401 dla celowo złego klucza i 96-znakowy podpis SHA-384
Co znaczą linie wyniku
  1. linie 2Test dostępu z celowo złym kluczem

    Sandbox odpowiedział 401 "Incorrect authentication". Z Twoimi danymi z sandboksu testAccess zwraca 200.

  2. linie 3Pola podpisu rejestracji

    sessionId, merchantId, kwota w groszach, waluta i na końcu crc, w tej kolejności.

  3. linie 4-5Podpis SHA-384

    96 znaków szesnastkowych w dwóch liniach. Ten sam skrót u Ciebie znaczy, że kodowanie JSON-a się zgadza.

Po czym poznasz, że działa u Ciebie

  • testAccess zwraca 200 z Twoimi danymi z sandboksu.
  • Rejestracja zwraca token, a /trnRequest/{token} otwiera stronę płatności.
  • Po testowej płatności na urlStatus przychodzi JSON z poprawnym podpisem.
  • transaction/verify zwraca data.status "success", a zamówienie zmienia stan raz.

Najczęstsze błędy

Ta sama droga co w "Co zbudujesz" na górze strony, z miejscami, w których praca staje najczęściej. Przy każdym błędzie: objaw, przyczyna i co zrobić.

Gdzie staje i dlaczego

Rejestracja

Twój sklep

transaction/register zwraca token.

  • Zatrzymuje się: 401 "Incorrect authentication"

    Zły posId albo klucz do raportów, często klucz produkcyjny użyty na sandboksie. Co zrobić: Sandbox ma osobne dane. Sprawdź je testAccess.

  • Zatrzymuje się: 400 przy rejestracji, błędny sign

    Inna kolejność pól, brak crc na końcu albo JSON z escapowanymi ukośnikami lub polskimi znakami. Co zrobić: Zachowaj kolejność z dokumentacji i flagi JSON_UNESCAPED_UNICODE oraz JSON_UNESCAPED_SLASHES. W Pythonie json.dumps dodaje spacje: użyj separators=(",", ":") i ensure_ascii=False.

Płatność w P24

Klient

Przekierowanie na /trnRequest/{token}.

    Powiadomienie

    P24

    JSON na Twój urlStatus z podpisem.

    • Zatrzymuje się: Zamówienie opłacone dwa razy w historii

      Ponowione powiadomienie obsłużone jak nowe. Co zrobić: Oznaczaj zamówienie warunkowo, na przykład UPDATE ... WHERE paid_at IS NULL, i zapisuj orderId.

    • Zatrzymuje się: Kwota w powiadomieniu inna niż w zamówieniu

      Kwota podana w złotych zamiast w groszach albo zmieniona po rejestracji. Co zrobić: Trzymaj kwoty w groszach jako liczby całkowite i odrzuć powiadomienie z inną kwotą.

    Weryfikacja

    Twój sklep

    transaction/verify potwierdza wpłatę.

    • Zatrzymuje się: Klient zapłacił, a pieniędzy nie ma w rozliczeniu

      Brak wywołania transaction/verify. Co zrobić: Weryfikuj w obsłudze powiadomienia. Bez tego kwota zostaje u klienta jako przedpłata.

    Jak to robimy u siebie

    Ten sam temat w kodzie, który utrzymujemy: Flaem.

    Zamówienie w panelu sklepu z historią zdarzeń płatności Przelewy24

    Przelewy24 w Flaem

    Flaem obsługuje Przelewy24 jako jedną z bramek płatności, w pliku frozcore/src/payments/gateways/przelewy24.rs. Podpis składamy ręcznie, pole po polu, zamiast z mapy, bo kolejność kluczy w mapie to decyzja biblioteki, a nie nasza.

    Powiadomienie bez weryfikacji nie jest u nas płatnością. Sprawdzamy podpis w czasie stałym, a potem, czy merchantId i posId z powiadomienia są nasze, zanim cokolwiek trafi do księgi.

    frozcore/src/payments/gateways/przelewy24.rsrust, 14 linii
    fn notification_sign(&self, body: &serde_json::Value) -> Option<String> {    Some(Self::sign(&[        ("merchantId", SignValue::Int(body["merchantId"].as_i64()?)),        ("posId", SignValue::Int(body["posId"].as_i64()?)),        ("sessionId", SignValue::Str(body["sessionId"].as_str()?)),        ("amount", SignValue::Int(body["amount"].as_i64()?)),        ("originAmount", SignValue::Int(body["originAmount"].as_i64()?)),        ("currency", SignValue::Str(body["currency"].as_str()?)),        ("orderId", SignValue::Int(body["orderId"].as_i64()?)),        ("methodId", SignValue::Int(body["methodId"].as_i64()?)),        ("statement", SignValue::Str(body["statement"].as_str()?)),        ("crc", SignValue::Str(&self.crc)),    ]))}

    Podpis powiadomienia w Flaem: te same dziesięć pól w tej samej kolejności co w PHP.

    Powiązane strony i źródła

    Usługa, narzędzia i hasła, do których odsyła ten poradnik, i kolejne poradniki.

    Oficjalna dokumentacja

    Wolisz, żeby zrobił to ktoś, kto już to robił?

    Opisz swój system. Odpiszemy w godzinę z planem integracji i wyceną.

    Pytania