Przejdź do treści

Webhooki

Po każdej zmianie stanu płatności wysyłamy POST ze zdarzeniem na adres z panelu, z podpisem HMAC-SHA256. Odbiornik sprawdza podpis, przetwarza zdarzenie idempotentnie i odpowiada 2xx. Nieudane dostawy ponawiamy 7 razy, a każde zdarzenie możesz pobrać i ponowić przez API.

Typy zdarzeń #

Każde zdarzenie dotyczy jednej płatności i niesie ją w całości w polu transakcja. Przy zwrotach dochodzi pole zwrot. Kolejność dostaw nie jest gwarantowana, więc decyzje podejmuj po transakcja.status, nie po kolejności webhooków.

TypKiedyCo zrobić
platnosc.oczekuje Klient poszedł do operatora (produkcja) Płatność produkcyjna czeka na wynik u operatora. W piaskownicy to zdarzenie nie występuje.
platnosc.oplacona Płatność zaksięgowana Można wydać towar albo usługę. transakcja.oplacona ma czas księgowania, transakcja.metoda użytą metodę.
platnosc.odrzucona Bank albo operator odmówił Klient może spróbować ponownie z tego samego adres_platnosci (inną metodą albo kartą); wtedy dostaniesz kolejne zdarzenia tej samej płatności.
platnosc.zwrot Zwrot wykonany albo zlecony Treść ma dodatkowe pole zwrot (schemat Zwrot) z nowym zwrotem; transakcja.zwrocono i transakcja.status są już po zmianie.
platnosc.zwrot_odrzucony Operator odrzucił zlecony zwrot Kwota wraca do do_zwrotu, zwrot w polu zwrot ma status odrzucony. Dotyczy tylko produkcji.
platnosc_cykliczna.aktywna Zgoda klienta i pierwsze obciążenie Treść ma pole platnosc_cykliczna (schemat PlatnoscCykliczna) i transakcja pierwszego okresu.
platnosc_cykliczna.odnowiona Kolejny okres opłacony transakcja to obciążenie tego okresu, platnosc_cykliczna.nastepne_obciazenie to termin następnego.
platnosc_cykliczna.odmowa Obciążenie odrzucone, będzie ponowione platnosc_cykliczna.proby to liczba odmów w tym okresie, nastepne_obciazenie to termin ponowienia.
platnosc_cykliczna.wstrzymana Ponowienia wyczerpane Harmonogram stoi. Klient może podać nową kartę pod adres_zgody, wtedy przyjdzie platnosc_cykliczna.odnowiona.
platnosc_cykliczna.anulowana Rezygnacja klienta albo sklepu anulowana_przez to klient albo sklep. Kolejnych obciążeń nie będzie.

Format żądania #

Żądanie to POST z treścią application/json na adres z panelu (tylko https, publiczny host). Nagłówki Placenie-Event-Id i Placenie-Event-Type powtarzają id i typ z treści, żeby odbiornik mógł odrzucić duplikat albo nieobsługiwany typ bez parsowania JSON. Treść to obiekt zdarzenie; pole webhook ze stanem dostawy jest tylko w odpowiedziach API, nie w samym webhooku.

Nagłówki
POST /placenie/webhook HTTP/1.1
Host: twojsklep.pl
Content-Type: application/json
User-Agent: Placenie-Webhook/1
Placenie-Event-Id: evt_0a1b2c3d4e5f6g7h8i9j
Placenie-Event-Type: platnosc.oplacona
Placenie-Signature: t=1759490472,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8c2
Treść zdarzenia platnosc.oplacona
{
    "obiekt": "zdarzenie",
    "id": "evt_0a1b2c3d4e5f6g7h8i9j",
    "typ": "platnosc.oplacona",
    "tryb": "test",
    "utworzone": "2026-10-03T10:01:12+00:00",
    "transakcja": {
        "obiekt": "platnosc",
        "id": "tr_8h2k1j0f9e8d7c6b5a4z",
        "tryb": "test",
        "status": "oplacona",
        "kwota": 12900,
        "waluta": "PLN",
        "zwrocono": 0,
        "do_zwrotu": 12900,
        "numer_zamowienia": "ZAM-2026-001",
        "opis": "Zamówienie ZAM-2026-001",
        "email_klienta": "[email protected]",
        "adres_powrotu": "https://twojsklep.pl/zamowienie/ZAM-2026-001/dziekujemy",
        "metoda": "blik",
        "metadane": {
            "koszyk": "c_8812",
            "klient_crm": "4471"
        },
        "adres_platnosci": "https://placenie.pl/zaplac/tr_8h2k1j0f9e8d7c6b5a4z",
        "utworzona": "2026-10-03T10:00:00+00:00",
        "oplacona": "2026-10-03T10:01:12+00:00",
        "zwroty": []
    }
}
Treść zdarzenia platnosc.zwrot (dodatkowe pole zwrot)
{
    "obiekt": "zdarzenie",
    "id": "evt_1b2c3d4e5f6g7h8i9j0k",
    "typ": "platnosc.zwrot",
    "tryb": "test",
    "utworzone": "2026-10-03T12:30:00+00:00",
    "transakcja": {
        "obiekt": "platnosc",
        "id": "tr_8h2k1j0f9e8d7c6b5a4z",
        "tryb": "test",
        "status": "oplacona",
        "kwota": 12900,
        "waluta": "PLN",
        "zwrocono": 2900,
        "do_zwrotu": 10000,
        "numer_zamowienia": "ZAM-2026-001",
        "opis": "Zamówienie ZAM-2026-001",
        "email_klienta": "[email protected]",
        "adres_powrotu": "https://twojsklep.pl/zamowienie/ZAM-2026-001/dziekujemy",
        "metoda": "blik",
        "metadane": {
            "koszyk": "c_8812",
            "klient_crm": "4471"
        },
        "adres_platnosci": "https://placenie.pl/zaplac/tr_8h2k1j0f9e8d7c6b5a4z",
        "utworzona": "2026-10-03T10:00:00+00:00",
        "oplacona": "2026-10-03T10:01:12+00:00",
        "zwroty": [
            {
                "obiekt": "zwrot",
                "id": "zw_9f8e7d6c5b4a3z2y1x0w",
                "platnosc": "tr_8h2k1j0f9e8d7c6b5a4z",
                "kwota": 2900,
                "waluta": "PLN",
                "status": "wykonany",
                "zrodlo": "api",
                "utworzony": "2026-10-03T12:30:00+00:00"
            }
        ]
    },
    "zwrot": {
        "obiekt": "zwrot",
        "id": "zw_9f8e7d6c5b4a3z2y1x0w",
        "platnosc": "tr_8h2k1j0f9e8d7c6b5a4z",
        "kwota": 2900,
        "waluta": "PLN",
        "status": "wykonany",
        "zrodlo": "api",
        "utworzony": "2026-10-03T12:30:00+00:00"
    }
}

Podpis i jego weryfikacja #

Nagłówek Placenie-Signature ma postać t=<czas unix>,v1=<hex>. v1 to HMAC-SHA256 z tekstu <t>.<surowa treść żądania>, liczony sekretem podpisu z panelu (Integracja i klucze, Sekret podpisu). Znacznik t chroni przed odtworzeniem starego żądania: odrzuć, gdy różni się od bieżącego czasu o więcej niż 300 sekund.

  • Licz podpis z surowych bajtów treści, nie z obiektu po parsowaniu i ponownej serializacji.
  • Porównuj w stałym czasie: hash_equals, crypto.timingSafeEqual, hmac.compare_digest.
  • Dziś nagłówek niesie jeden podpis v1. Weryfikacja, która przyjmuje żądanie, gdy pasuje którykolwiek z podpisów v1 w nagłówku, pozostanie poprawna także przy więcej niż jednym.
  • Po kliknięciu Wymień sekret w panelu nowe dostawy są podpisane nowym sekretem od razu. Podmień sekret w odbiorniku w tym samym kroku.
Algorytm
# 1. Rozbij naglowek na t i v1
Placenie-Signature: t=1759490472,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8c2

# 2. Odrzuc, gdy |teraz - t| > 300 sekund

# 3. Policz HMAC-SHA256 sekretem z panelu z tekstu "<t>.<surowa tresc zadania>"
hmac_sha256(sekret, "1759490472." + tresc) == v1

# 4. Porownaj w stalym czasie (hash_equals, timingSafeEqual, hmac.compare_digest)
<?php
// Odbiornik webhooka Placenie: sprawdzenie podpisu i odpowiedz 200.
// $sekret to „Sekret podpisu” z panelu (Integracja i klucze), trzymany w konfiguracji.

function placenieSprawdzPodpis(string $naglowek, string $tresc, string $sekret, int $tolerancja = 300): bool
{
    $czas = null;
    $podpisy = [];

    foreach (explode(',', $naglowek) as $czesc) {
        [$k, $v] = array_pad(explode('=', trim($czesc), 2), 2, null);

        if ($k === 't' && ctype_digit((string) $v)) {
            $czas = (int) $v;
        } elseif ($k === 'v1' && $v !== null) {
            $podpisy[] = $v;
        }
    }

    if ($czas === null || $podpisy === [] || abs(time() - $czas) > $tolerancja) {
        return false;
    }

    $oczekiwany = hash_hmac('sha256', $czas.'.'.$tresc, $sekret);

    foreach ($podpisy as $p) {
        if (hash_equals($oczekiwany, $p)) {
            return true;
        }
    }

    return false;
}

$tresc = file_get_contents('php://input');
$sekret = getenv('PLACENIE_WEBHOOK_SEKRET');

if (! placenieSprawdzPodpis($_SERVER['HTTP_PLACENIE_SIGNATURE'] ?? '', $tresc, $sekret)) {
    http_response_code(400);
    exit('Nieprawidłowy podpis');
}

$zdarzenie = json_decode($tresc, true);

// Idempotencja: ten sam identyfikator zdarzenia moze przyjsc wiecej niz raz.
if (zdarzenieJuzPrzetworzone($zdarzenie['id'])) {
    http_response_code(200);
    exit;
}

$transakcja = $zdarzenie['transakcja'];

if ($zdarzenie['typ'] === 'platnosc.oplacona') {
    oznaczZamowienieJakoOplacone($transakcja['numer_zamowienia'], $transakcja['id'], $transakcja['kwota']);
}

zapiszPrzetworzone($zdarzenie['id']);
http_response_code(200);
// Odbiornik webhooka Placenie (Node 18+, Express). Potrzebna SUROWA tresc zadania,
// wiec express.raw zamiast express.json: podpis liczy sie z bajtow, nie z obiektu.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCJA_S = 300;

export function sprawdzPodpis(naglowek, tresc, sekret) {
  const czesci = Object.fromEntries(
    String(naglowek || '')
      .split(',')
      .map((c) => c.trim().split('=', 2)),
  );
  const czas = Number(czesci.t);
  if (!Number.isInteger(czas) || !czesci.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - czas) > TOLERANCJA_S) return false;

  const oczekiwany = createHmac('sha256', sekret).update(`${czas}.${tresc}`).digest('hex');
  const a = Buffer.from(oczekiwany, 'hex');
  const b = Buffer.from(czesci.v1, 'hex');
  return a.length === b.length && timingSafeEqual(a, b);
}

const app = express();

app.post('/placenie/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const tresc = req.body.toString('utf8');

  if (!sprawdzPodpis(req.get('Placenie-Signature'), tresc, process.env.PLACENIE_WEBHOOK_SEKRET)) {
    return res.status(400).send('Nieprawidłowy podpis');
  }

  const zdarzenie = JSON.parse(tresc);

  // Idempotencja po identyfikatorze zdarzenia (ten sam w naglowku Placenie-Event-Id).
  if (await zdarzenieJuzPrzetworzone(zdarzenie.id)) return res.sendStatus(200);

  if (zdarzenie.typ === 'platnosc.oplacona') {
    const t = zdarzenie.transakcja;
    await oznaczZamowienieJakoOplacone(t.numer_zamowienia, t.id, t.kwota);
  }

  await zapiszPrzetworzone(zdarzenie.id);
  res.sendStatus(200);
});
# Odbiornik webhooka Placenie (Flask). Podpis liczy sie z surowych bajtow zadania.
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

TOLERANCJA_S = 300
app = Flask(__name__)


def sprawdz_podpis(naglowek: str, tresc: bytes, sekret: str) -> bool:
    czesci = dict(c.strip().split("=", 1) for c in (naglowek or "").split(",") if "=" in c)
    try:
        czas = int(czesci["t"])
        podpis = czesci["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time()) - czas) > TOLERANCJA_S:
        return False
    oczekiwany = hmac.new(sekret.encode(), f"{czas}.".encode() + tresc, hashlib.sha256).hexdigest()
    return hmac.compare_digest(oczekiwany, podpis)


@app.post("/placenie/webhook")
def webhook():
    tresc = request.get_data()
    if not sprawdz_podpis(request.headers.get("Placenie-Signature", ""), tresc, os.environ["PLACENIE_WEBHOOK_SEKRET"]):
        abort(400)

    zdarzenie = json.loads(tresc)

    # Idempotencja po identyfikatorze zdarzenia (ten sam w naglowku Placenie-Event-Id).
    if zdarzenie_juz_przetworzone(zdarzenie["id"]):
        return "", 200

    if zdarzenie["typ"] == "platnosc.oplacona":
        t = zdarzenie["transakcja"]
        oznacz_zamowienie_jako_oplacone(t["numer_zamowienia"], t["id"], t["kwota"])

    zapisz_przetworzone(zdarzenie["id"])
    return "", 200

Ponowienia #

Dostawę kończy dowolna odpowiedź 2xx w ciągu 8 sekund (treść odpowiedzi ignorujemy). Każda inna odpowiedź, przekroczenie czasu albo błąd połączenia uruchamia ponowienia w rosnących odstępach. Po ostatniej nieudanej próbie dostawa dostaje status porzucony; zdarzenie dalej jest dostępne przez GET /v1/zdarzenia/{id} i można je ponowić.

PróbaKiedyŁącznie od zdarzenia
1od razu po zdarzeniu0
2 po 1 min od poprzedniej 1 min
3 po 5 min od poprzedniej 6 min
4 po 15 min od poprzedniej 21 min
5 po 1 h od poprzedniej 1 h 21 min
6 po 3 h od poprzedniej 4 h 21 min
7 po 6 h od poprzedniej 10 h 21 min
8 po 12 h od poprzedniej 22 h 21 min

Odpowiadaj 2xx od razu po zapisaniu zdarzenia, a cięższą pracę (wysyłka e-maila, fakturowanie) wykonuj po odpowiedzi albo w kolejce. Odbiornik, który liczy coś przez 10 sekund, dostanie to samo zdarzenie jeszcze raz.

Idempotentne przetwarzanie #

To samo zdarzenie może dotrzeć więcej niż raz: po ponowieniu, gdy Twoja odpowiedź 2xx nie dotarła do nas, albo po ręcznym ponowieniu z panelu czy przez API. Ponowna dostawa ma nowy podpis, ale ten sam id (Placenie-Event-Id).

  1. Zapisuj id przetworzonych zdarzeń (tabela z unikalnym indeksem albo klucz w pamięci podręcznej z czasem życia kilku dni).
  2. Gdy id już jest, odpowiedz 2xx bez ponownego przetwarzania.
  3. Decyzję podejmuj po transakcja.status i transakcja.zwrocono, nie po typie zdarzenia ani po kolejności dostaw. Zdarzenie platnosc.odrzucona może dotrzeć po platnosc.oplacona, jeśli klient zapłacił w drugiej próbie, a pierwsza dostawa czekała na ponowienie.
  4. W razie wątpliwości potwierdź stan u źródła: GET /v1/platnosci/{id} albo GET /v1/zdarzenia/{id}.

Testowanie #

  1. Uruchom odbiornik pod publicznym adresem https. Lokalnie: tunel (cloudflared, ngrok). Adresy prywatne i http są odrzucane przy zapisie.
  2. Wpisz adres w panelu (Integracja i klucze, Webhook o statusie) i skopiuj sekret podpisu.
  3. Utwórz płatność kluczem testowym i ustaw wynik: przez bank testowy (BLIK 777777) albo przez POST /v1/platnosci/{id}/symulacja. Webhook wychodzi tą samą drogą co w produkcji.
  4. Stan dostawy sprawdzisz w polu webhook zdarzenia: status (czeka, dostarczony, porzucony), proby, ostatni_kod, ostatni_blad, nastepna_proba. To samo widać w panelu przy transakcji.
curl -X POST "https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja" \
  -H "Authorization: Bearer plc_test_..." \
  -H "Content-Type: application/json" \
  -d '{
      "wynik": "oplacona",
      "metoda": "blik"
  }'
$ch = curl_init('https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer plc_test_...',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'wynik' => 'oplacona',
        'metoda' => 'blik',
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);

$platnosc = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($platnosc['blad']['kod'].': '.$platnosc['blad']['opis']);
}
const odpowiedz = await fetch('https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer plc_test_...',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    wynik: 'oplacona',
    metoda: 'blik',
  }),
});

const platnosc = await odpowiedz.json();

if (!odpowiedz.ok) {
  throw new Error(`${platnosc.blad.kod}: ${platnosc.blad.opis}`);
}
import requests

odpowiedz = requests.post(
    "https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/symulacja",
    headers={"Authorization": "Bearer plc_test_..."},
    json={
        "wynik": "oplacona",
        "metoda": "blik",
    },
)
platnosc = odpowiedz.json()

if not odpowiedz.ok:
    raise RuntimeError(f"{platnosc['blad']['kod']}: {platnosc['blad']['opis']}")
Pole webhook w GET /v1/zdarzenia/{id} po nieudanej dostawie (skrócone)
{
  "obiekt": "zdarzenie",
  "id": "evt_0a1b2c3d4e5f6g7h8i9j",
  "typ": "platnosc.oplacona",
  "tryb": "test",
  "utworzone": "2026-10-03T10:01:12+00:00",
  "transakcja": { "obiekt": "platnosc", "id": "tr_8h2k1j0f9e8d7c6b5a4z", "status": "oplacona" },
  "webhook": {
    "status": "czeka",
    "proby": 2,
    "ostatni_kod": 500,
    "ostatni_blad": "Odpowiedź HTTP 500",
    "nastepna_proba": "2026-10-03T10:07:12+00:00"
  }
}

Ponowna dostawa z panelu i przez API #

Z panelu: otwórz transakcję w zakładce Transakcje. Pod listą dostaw webhooka przy każdej pozycji jest przycisk Wyślij ponownie.

Przez API: POST /v1/zdarzenia/{id}/ponow wysyła webhook zdarzenia jeszcze raz, z nowym podpisem i tą samą treścią. Odpowiedź 202 niesie zdarzenie ze stanem nowej dostawy. Bez adresu webhooka w panelu dostaniesz 409 webhook_nieustawiony. Listę zdarzeń do ponowienia wybierzesz filtrami GET /v1/zdarzenia?typ=…&od=….

curl -X POST "https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow" \
  -H "Authorization: Bearer plc_test_..." \
  -d ''
$ch = curl_init('https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer plc_test_...',
    ],
    CURLOPT_RETURNTRANSFER => true,
]);

$zdarzenie = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($zdarzenie['blad']['kod'].': '.$zdarzenie['blad']['opis']);
}
const odpowiedz = await fetch('https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow', {
  method: 'POST',
  headers: {
    Authorization: 'Bearer plc_test_...',
  },
});

const zdarzenie = await odpowiedz.json();

if (!odpowiedz.ok) {
  throw new Error(`${zdarzenie.blad.kod}: ${zdarzenie.blad.opis}`);
}
import requests

odpowiedz = requests.post(
    "https://placenie.pl/api/v1/zdarzenia/evt_0a1b2c3d4e5f6g7h8i9j/ponow",
    headers={"Authorization": "Bearer plc_test_..."},
)
zdarzenie = odpowiedz.json()

if not odpowiedz.ok:
    raise RuntimeError(f"{zdarzenie['blad']['kod']}: {zdarzenie['blad']['opis']}")
curl "https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20" \
  -H "Authorization: Bearer plc_test_..."
$ch = curl_init('https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer plc_test_...',
    ],
    CURLOPT_RETURNTRANSFER => true,
]);

$lista = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

if ($status >= 400) {
    throw new RuntimeException($lista['blad']['kod'].': '.$lista['blad']['opis']);
}
const odpowiedz = await fetch('https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20', {
  method: 'GET',
  headers: {
    Authorization: 'Bearer plc_test_...',
  },
});

const lista = await odpowiedz.json();

if (!odpowiedz.ok) {
  throw new Error(`${lista.blad.kod}: ${lista.blad.opis}`);
}
import requests

odpowiedz = requests.get(
    "https://placenie.pl/api/v1/zdarzenia?typ=platnosc.oczekuje&limit=20",
    headers={"Authorization": "Bearer plc_test_..."},
)
lista = odpowiedz.json()

if not odpowiedz.ok:
    raise RuntimeError(f"{lista['blad']['kod']}: {lista['blad']['opis']}")

Lista kontrolna odbiornika #

  • Weryfikuje podpis z surowej treści, w stałym czasie, z tolerancją 300 sekund.
  • Odpowiada 2xx w mniej niż 8 sekund, resztę robi po odpowiedzi.
  • Jest idempotentny po id zdarzenia.
  • Wydaje towar tylko przy transakcja.status == "oplacona" i sprawdza kwota oraz waluta względem zamówienia.
  • Obsługuje platnosc.zwrot i platnosc.zwrot_odrzucony (pole zwrot, transakcja.zwrocono).
  • Nie polega na kolejności zdarzeń ani na powrocie klienta na adres_powrotu.
  • Loguje Placenie-Event-Id przy każdej decyzji, żeby dało się dojść, co i kiedy się stało.