Szybki start
Sześć kroków od klucza testowego do opłaconej płatności testowej i odebranego webhooka. Potrzebujesz konta Placenie (bez karty) i terminala albo dowolnego języka z klientem HTTP. Bez umowy z operatorem, bez pieniędzy.
1. Konto i klucz testowy #
- Załóż konto przyciskiem Załóż konto w nagłówku. Wystarczy adres e-mail, potwierdzasz go kodem z wiadomości.
- W panelu otwórz Integracja i klucze i przy pozycji Klucz testowy kliknij Wydaj klucz.
- Skopiuj klucz
plc_test_…od razu. Pokazujemy go tylko raz, w bazie trzymamy wyłącznie jego skrót. Jeśli go zgubisz, kliknij Wymień klucz: poprzedni przestaje działać natychmiast.
Klucz testowy działa od założenia konta. Konto bez opłaconego planu ma 30 płatności testowych, opłacony plan zdejmuje ten limit. Sprawdź klucz jednym zapytaniem o listę płatności.
# Klucz pokazuje sie w panelu tylko raz; trzymaj go w zmiennej srodowiskowej, nie w repozytorium
export PLACENIE_KLUCZ="plc_test_..."
curl "https://placenie.pl/api/v1/platnosci?limit=1" \
-H "Authorization: Bearer $PLACENIE_KLUCZ"
{
"obiekt": "lista",
"adres": "/v1/platnosci",
"dane": [],
"ma_wiecej": false,
"strona": 1,
"stron": 1,
"razem": 0
}
2. Utwórz płatność #
Jedyne wymagane pole to kwota w groszach (12900 to 129,00 zł). numer_zamowienia chroni przed podwójną płatnością: ten sam numer z tą samą kwotą zwraca istniejącą płatność (200 zamiast 201), ten sam numer z inną kwotą to 409 numer_zamowienia_zajety. Nagłówek Idempotency-Key dodatkowo zabezpiecza ponowienia po przerwaniu połączenia.
W odpowiedzi dostajesz obiekt platnosc. Zapisz id przy zamówieniu, a klienta przekieruj na adres_platnosci.
curl -X POST "https://placenie.pl/api/v1/platnosci" \
-H "Authorization: Bearer plc_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c" \
-d '{
"kwota": 12900,
"waluta": "PLN",
"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",
"metadane": {
"koszyk": "c_8812",
"klient_crm": "4471"
}
}'
$ch = curl_init('https://placenie.pl/api/v1/platnosci');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer plc_test_...',
'Content-Type: application/json',
'Idempotency-Key: 0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c',
],
CURLOPT_POSTFIELDS => json_encode([
'kwota' => 12900,
'waluta' => 'PLN',
'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',
'metadane' => [
'koszyk' => 'c_8812',
'klient_crm' => '4471',
],
]),
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', {
method: 'POST',
headers: {
Authorization: 'Bearer plc_test_...',
'Content-Type': 'application/json',
'Idempotency-Key': '0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c',
},
body: JSON.stringify({
kwota: 12900,
waluta: 'PLN',
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',
metadane: {
koszyk: 'c_8812',
klient_crm: '4471',
},
}),
});
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",
headers={"Authorization": "Bearer plc_test_...", "Idempotency-Key": "0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c"},
json={
"kwota": 12900,
"waluta": "PLN",
"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",
"metadane": {
"koszyk": "c_8812",
"klient_crm": "4471",
},
},
)
platnosc = odpowiedz.json()
if not odpowiedz.ok:
raise RuntimeError(f"{platnosc['blad']['kod']}: {platnosc['blad']['opis']}")
{
"obiekt": "platnosc",
"id": "tr_8h2k1j0f9e8d7c6b5a4z",
"tryb": "test",
"status": "nowa",
"kwota": 12900,
"waluta": "PLN",
"zwrocono": 0,
"do_zwrotu": 0,
"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": null,
"metadane": {
"koszyk": "c_8812",
"klient_crm": "4471"
},
"adres_platnosci": "https://placenie.pl/zaplac/tr_8h2k1j0f9e8d7c6b5a4z",
"utworzona": "2026-10-03T10:00:00+00:00",
"oplacona": null,
"zwroty": []
}
3. Opłać ją w banku testowym #
Otwórz adres_platnosci w przeglądarce. Ekran płatności wygląda tak samo jak w produkcji, ale zamiast banku prowadzi do banku testowego.
- BLIK: kod
777777zatwierdza płatność, kod111111kończy się odmową banku (statusodrzucona, klient może spróbować ponownie z tego samego adresu). - Karta i szybki przelew: bank testowy pokazuje dwa przyciski, Zatwierdź płatność i Odrzuć w banku. Numer karty nie jest potrzebny.
Do testów automatycznych (CI) nie potrzebujesz przeglądarki: POST /v1/platnosci/{id}/symulacja ustawia wynik tą samą drogą co bank testowy, więc powstaje zdarzenie i idzie webhook. Działa tylko kluczem testowym i tylko dla płatności w stanie nowa albo odrzucona.
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']}")
4. Odbierz webhook #
W panelu, w Integracja i klucze, wpisz adres odbiornika w polu Webhook o statusie i skopiuj Sekret podpisu. Adres musi być https na publicznym hoście; do testów na własnym komputerze użyj tunelu (na przykład cloudflared albo ngrok), bo adresy lokalne i prywatne są odrzucane.
Po zaksięgowaniu płatności wysyłamy POST ze zdarzeniem platnosc.oplacona. Treść to obiekt zdarzenie z pełną płatnością w polu transakcja. Odbiornik sprawdza podpis, przetwarza zdarzenie idempotentnie po id i odpowiada kodem 2xx w ciągu 8 sekund.
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
{
"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": []
}
}
Minimalny odbiornik w trzech językach. Funkcje zdarzenieJuzPrzetworzone, oznaczZamowienieJakoOplacone i zapiszPrzetworzone to Twoja baza danych. Szczegóły podpisu, ponowień i testowania są na stronie Webhooki.
<?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
5. Sprawdź stan zapytaniem #
Webhook jest drogą główną, ale stan płatności możesz pobrać w każdej chwili. Po powrocie klienta na adres_powrotu (dopisujemy ?placenie_id=tr_…) odczytaj status zapytaniem, zamiast ufać samemu powrotowi: klient może zamknąć kartę przed powrotem albo wrócić, zanim bank zaksięguje.
Statusy: nowa, oczekuje (tylko produkcja), oplacona, odrzucona, zwrocona. Każdą zmianę stanu znajdziesz też na liście GET /v1/zdarzenia, także gdy nie masz webhooka.
curl "https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z" \
-H "Authorization: Bearer plc_test_..."
$ch = curl_init('https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z');
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => [
'Authorization: Bearer plc_test_...',
],
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', {
method: 'GET',
headers: {
Authorization: 'Bearer plc_test_...',
},
});
const platnosc = await odpowiedz.json();
if (!odpowiedz.ok) {
throw new Error(`${platnosc.blad.kod}: ${platnosc.blad.opis}`);
}
import requests
odpowiedz = requests.get(
"https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z",
headers={"Authorization": "Bearer plc_test_..."},
)
platnosc = odpowiedz.json()
if not odpowiedz.ok:
raise RuntimeError(f"{platnosc['blad']['kod']}: {platnosc['blad']['opis']}")
{
"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": []
}
6. Zwrot i co dalej #
Zwrot pełny (puste ciało) albo częściowy (kwota w groszach) zlecasz jednym wywołaniem. Odpowiedź to płatność z listą zwroty, a do odbiornika idzie zdarzenie platnosc.zwrot. Po pełnym zwrocie status płatności to zwrocona.
curl -X POST "https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/zwroty" \
-H "Authorization: Bearer plc_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c" \
-d '{
"kwota": 2900
}'
$ch = curl_init('https://placenie.pl/api/v1/platnosci/tr_8h2k1j0f9e8d7c6b5a4z/zwroty');
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_HTTPHEADER => [
'Authorization: Bearer plc_test_...',
'Content-Type: application/json',
'Idempotency-Key: 0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c',
],
CURLOPT_POSTFIELDS => json_encode([
'kwota' => 2900,
]),
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/zwroty', {
method: 'POST',
headers: {
Authorization: 'Bearer plc_test_...',
'Content-Type': 'application/json',
'Idempotency-Key': '0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c',
},
body: JSON.stringify({
kwota: 2900,
}),
});
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/zwroty",
headers={"Authorization": "Bearer plc_test_...", "Idempotency-Key": "0f9d4c2a-6b3e-4e1a-9c7d-2b8f1e3a5d6c"},
json={
"kwota": 2900,
},
)
platnosc = odpowiedz.json()
if not odpowiedz.ok:
raise RuntimeError(f"{platnosc['blad']['kod']}: {platnosc['blad']['opis']}")
{
"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"
}
]
}