Przejdź do treści
Oplac

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.

Tryb testowy

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_events zawęż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.
curl
# 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"}}}'
Odpowiedź
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.

  1. 01Weź surową treść żądania (bajty, zanim cokolwiek ją sparsuje). Treść po json_decode i ponownym json_encode może różnić się odstępami i kolejnością pól, a wtedy podpis się nie zgodzi.
  2. 02Policz HMAC SHA-256 z t + "." + treść i porównaj z v1 funkcją o stałym czasie (hash_equals, crypto.timingSafeEqual, hmac.compare_digest).
  3. 03Odrzuć żądanie, gdy t róż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, type i request.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.

Zamów integrację za darmo

BLIK, karty, subskrypcje i KSeF w jednym panelu.