Zdarzenia i webhooki
Webhooki
Webhook to POST z obiektem event, który Oplac wysyła na Twój adres HTTPS, gdy coś się dzieje: płatność opłacona, zwrot wykonany, odnowienie nieudane. Każde żądanie ma podpis HMAC SHA-256 w nagłówku Oplac-Signature, więc możesz mieć pewność, że przyszło od nas i nie zostało zmienione. Odpowiadasz 2xx, a gdy nie odpowiesz, ponawiamy przez 3 doby.
Klucz testowy w przykładach
Wklej klucz sk_test_ z panelu (zakładka Deweloperzy), a każdy przykład będzie gotowy do uruchomienia. Klucz zostaje w tej karcie przeglądarki, nie wysyłamy go nigdzie.
Przykłady używają Twojego klucza testowego. Produkcyjnego klucza nie przyjmujemy.
Adres webhooka i sekret#
Adres zapisujesz żądaniem POST /v1/webhook_endpoints (albo w panelu, w zakładce Deweloperzy), osobno dla trybu testowego i produkcyjnego: jeden adres na tryb. Odpowiedź zawiera sekret whsec_, który wraca tylko przy utworzeniu i przy rotacji (rotate_secret: true), więc zapisz go od razu w konfiguracji serwera. Ponowny POST zastępuje adres i wydaje nowy sekret, stary przestaje działać.
- Adres musi być publiczny, po HTTPS, nie może wskazywać sieci wewnętrznej ani lokalnego hosta (sprawdzamy to przy zapisie i przy każdej wysyłce).
-
enabled_eventszawęża wysyłkę do wybranych typów;["*"](domyślnie) wysyła wszystko. -
Nagłówki żądania od Oplac:
Content-Type: application/json,User-Agent: Oplac-Webhooks/1.0,Oplac-Signature.
# tak wygląda żądanie, które Oplac wysyła na Twój adres
curl https://sklep.example.com/oplac/webhook \
-H "Content-Type: application/json" \
-H "User-Agent: Oplac-Webhooks/1.0" \
-H "Oplac-Signature: t=1760000001,v1=5f0c9e2b7c1d4e8a9b2f1c3d5e7f9a0b6c4d2e1f0a9b8c7d6e5f4a3b2c1d0e9f" \
-d '{"id": "evt_Ab12Cd34Ef56Gh", "object": "event", "type": "payment.succeeded", "data": {"object": {"id": "pay_Ab12Cd34Ef56Gh", "status": "succeeded"}}}'
POST /oplac/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Oplac-Webhooks/1.0
Oplac-Signature: t=1760000001,v1=5f0c9e2b7c1d4e8a9b2f1c3d5e7f9a0b6c4d2e1f0a9b8c7d6e5f4a3b2c1d0e9f
{
"id": "evt_Ab12Cd34Ef56Gh",
"object": "event",
"type": "payment.succeeded",
"api_version": "2026-10-03",
"livemode": false,
"created": 1760000000,
"data": {
"object": {
"id": "pay_Ab12Cd34Ef56Gh",
"object": "payment",
"amount": 12900,
"currency": "PLN",
"status": "succeeded",
"method": "blik",
"metadata": {"order_id": "10482"},
"livemode": false
}
},
"request": {"id": "req_7mK2pQ9rT4vX1bN8cD3fG6hJ", "idempotency_key": "zamowienie-10482"},
"pending_webhooks": 1
}
Weryfikacja podpisu Oplac-Signature#
Nagłówek ma postać t=<czas Unix>,v1=<podpis>. Podpis to HMAC SHA-256 (zapis szesnastkowy, małe litery) liczony sekretem whsec_ z ciągu <t>.<surowa treść żądania>. Weryfikacja ma trzy kroki, w tej kolejności.
- 01Weź surową treść żądania (bajty, zanim cokolwiek ją sparsuje). Treść po
json_decodei ponownymjson_encodemoże różnić się odstępami i kolejnością pól, a wtedy podpis się nie zgodzi. - 02Policz HMAC SHA-256 z
t + "." + treśći porównaj zv1funkcją o stałym czasie (hash_equals,crypto.timingSafeEqual,hmac.compare_digest). - 03Odrzuć żądanie, gdy
tróżni się od bieżącego czasu o więcej niż 300 sekund. To ochrona przed powtórzeniem przechwyconego żądania; tolerancja 5 minut wystarcza na różnice zegarów.
Niezgodny podpis albo stary znacznik to odpowiedź 400 bez przetwarzania. Nigdy nie weryfikuj podpisu ciągiem z adresu ani z pola treści: liczy się wyłącznie nagłówek.
# ręczne sprawdzenie podpisu w terminalu: policz HMAC z "<t>.<treść>" i porównaj z v1
T=1760000001
TRESC='{"id":"evt_Ab12Cd34Ef56Gh","type":"payment.succeeded"}'
printf '%s.%s' "$T" "$TRESC" | openssl dgst -sha256 -hmac "$OPLAC_WEBHOOK_SECRET"
<?php
// odbiorca webhooka: surowa treść, podpis, 2xx w ciągu 10 sekund, obsługa idempotentna po id
$tresc = file_get_contents('php://input');
$naglowek = $_SERVER['HTTP_OPLAC_SIGNATURE'] ?? '';
function podpisPoprawny(string $tresc, string $naglowek, string $sekret, int $tolerancja = 300): bool
{
$czesci = [];
foreach (explode(',', $naglowek) as $para) {
[$k, $v] = array_pad(explode('=', trim($para), 2), 2, '');
$czesci[$k] = $v;
}
$t = (int) ($czesci['t'] ?? 0);
if ($t <= 0 || ! isset($czesci['v1']) || abs(time() - $t) > $tolerancja) {
return false;
}
return hash_equals(hash_hmac('sha256', $t . '.' . $tresc, $sekret), $czesci['v1']);
}
if (! podpisPoprawny($tresc, $naglowek, getenv('OPLAC_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($tresc, true);
if (! zdarzenieJuzObsluzone($event['id'])) { // tabela obsłużonych id: to samo zdarzenie może przyjść dwa razy
if ($event['type'] === 'payment.succeeded' && $event['livemode'] === true) {
oznaczZamowienieOplacone($event['data']['object']['metadata']['order_id']);
}
zapiszObsluzone($event['id']);
}
http_response_code(200);
import crypto from 'node:crypto';
import express from 'express';
const app = express();
function podpisPoprawny(tresc, naglowek, sekret, tolerancja = 300) {
const czesci = Object.fromEntries(naglowek.split(',').map((p) => p.trim().split('=')));
const t = Number(czesci.t);
if (!t || !czesci.v1 || Math.abs(Date.now() / 1000 - t) > tolerancja) return false;
const oczekiwany = crypto.createHmac('sha256', sekret).update(`${t}.${tresc}`).digest('hex');
return oczekiwany.length === czesci.v1.length && crypto.timingSafeEqual(Buffer.from(oczekiwany), Buffer.from(czesci.v1));
}
// express.raw zachowuje surową treść: nie używaj tu express.json()
app.post('/oplac/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
if (!podpisPoprawny(req.body.toString('utf8'), req.get('Oplac-Signature') || '', process.env.OPLAC_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // najpierw odpowiedź, potem praca: 10 sekund na odpowiedź
if (await zdarzenieJuzObsluzone(event.id)) return; // to samo zdarzenie może przyjść dwa razy
if (event.type === 'payment.succeeded' && event.livemode === true) {
await oznaczZamowienieOplacone(event.data.object.metadata.order_id);
}
await zapiszObsluzone(event.id);
});
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
def podpis_poprawny(tresc: bytes, naglowek: str, sekret: str, tolerancja: int = 300) -> bool:
czesci = dict(p.strip().split("=", 1) for p in naglowek.split(",") if "=" in p)
try:
t = int(czesci.get("t", 0))
except ValueError:
return False
if t <= 0 or "v1" not in czesci or abs(time.time() - t) > tolerancja:
return False
oczekiwany = hmac.new(sekret.encode(), f"{t}.".encode() + tresc, hashlib.sha256).hexdigest()
return hmac.compare_digest(oczekiwany, czesci["v1"])
@app.post("/oplac/webhook")
def webhook():
tresc = request.get_data() # surowe bajty, nie request.json
if not podpis_poprawny(tresc, request.headers.get("Oplac-Signature", ""), os.environ["OPLAC_WEBHOOK_SECRET"]):
return "", 400
event = json.loads(tresc)
if not zdarzenie_juz_obsluzone(event["id"]): # to samo zdarzenie może przyjść dwa razy
if event["type"] == "payment.succeeded" and event["livemode"] is True:
oznacz_zamowienie_oplacone(event["data"]["object"]["metadata"]["order_id"])
zapisz_obsluzone(event["id"])
return "", 200
Odpowiedź i ponowienia#
Odpowiedz kodem 2xx w ciągu 10 sekund. Treść odpowiedzi jest ignorowana. Dłuższą pracę (wysyłka maila, generowanie PDF) zrób po odpowiedzi albo w kolejce; sama weryfikacja podpisu i zapis do bazy mieszczą się w limicie z dużym zapasem.
Każda inna odpowiedź albo jej brak uruchamia ponowienia: po 1 minucie, 5 minutach, 30 minutach, 2 godzinach, 6 godzinach i 12 godzinach, a potem co 24 godziny, łącznie przez 3 doby od powstania zdarzenia. Stan doręczeń widzisz w panelu i w polu delivery zdarzenia (GET /v1/events). Po naprawie odbiorcy nie musisz czekać na kolejną próbę: POST /v1/events/{id}/resend wysyła zdarzenie od razu, a lista zdarzeń z delivered=false pozwala nadrobić zaległości jedną pętlą.
| Twoja odpowiedź | Co robi Oplac |
|---|---|
| 200 do 299 | zdarzenie doręczone, koniec |
| 3xx | nie podążamy za przekierowaniem; traktowane jak błąd, ponowienie |
| 4xx, 5xx, timeout 10 s, błąd połączenia | ponowienie według harmonogramu |
Obsługa idempotentna#
To samo zdarzenie może przyjść więcej niż raz: po timeout Twojego serwera, po ręcznej wysyłce z panelu, po resend. Dlatego odbiorca zapisuje obsłużone event.id (tabela z unikalnym indeksem) i pomija powtórki. Drugie zabezpieczenie: zmiana stanu zamówienia jako operacja warunkowa („oznacz jako opłacone, jeśli jeszcze nie jest”), a nie przyrost licznika. Kolejność zdarzeń nie jest gwarantowana, więc stan obiektu bierz z data.object albo potwierdź żądaniem GET /v1/payments/{id}.
-
Sprawdzaj
livemode: zdarzenia z piaskownicy nie mogą zmieniać prawdziwych zamówień, nawet jeśli adres odbiorcy jest wspólny. -
Nieznane typy zdarzeń pomijaj z odpowiedzią
200; nowe typy dochodzą bez zmiany wersji API. -
Loguj
event.id,typeirequest.id; z tymi trzema wartościami odtworzymy każdą sytuację.
Testowanie odbiorcy#
Na adres testowy wyślij zdarzenie próbne POST /v1/test_helpers/webhooks z polem type, zapłać płatność testową kodem BLIK 777 123 albo kliknij Wyślij zdarzenie testowe w panelu. Odbiorca lokalny bez publicznego adresu? Wystaw go tunelem HTTPS i zapisz adres tunelu jako testowy; adres produkcyjny zostaje osobny. Historia każdej próby z kodem odpowiedzi jest w panelu, w zakładce Deweloperzy.
Powiązane
Klucz testowy dostajesz od razu po zapisie
Adres e-mail, kod z maila i piaskownica z kodami BLIK. Produkcję włączasz sam w panelu: plan, dostawca płatności, klucz sk_live_. Wolisz, żeby wdrożenie w sklepie zrobił nasz zespół? To też jest za darmo.