Przejdź do głównej zawartości

Webhooki

Webhooki wypychają zdarzenia na wskazany URL, zamiast zmuszać Cię do pollingu API. Każde zdarzenie jest podpisane HMAC sekretem przypisanym do endpointu, więc możesz zweryfikować, że naprawdę pochodzi ze Stawki, zanim na nie zareagujesz.

Webhooki są dostępne w planach Hobby i Pro. W planie Free przycisk tworzenia jest wyłączony; istniejące endpointy z wcześniejszego płatnego planu są zachowane, ale wstrzymane — po przejściu z powrotem na plan płatny wznawiają się automatycznie.

  1. W panelu wejdź w Webhooki (w swojej organizacji).
  2. Utwórz webhook → wpisz URL odbiorcy i zaznacz co najmniej jedno zdarzenie.
  3. Skopiuj sekret podpisujący pokazany jednorazowo — Stawka nigdy go nie pokaże ponownie. Przechowuj go jak każdy inny sekret.
  4. Przycisk Wyślij zdarzenie testowe w panelu wystrzeliwuje syntetyczne dostarczenie, więc możesz sprawdzić swój endpoint zanim popłyną prawdziwe zdarzenia.

Zestaw zdarzeń możesz zmienić w dowolnej chwili; sekret rotujesz ze strony szczegółów endpointa. Po rotacji poprzedni sekret przestaje weryfikować podpisy natychmiast — zaktualizuj swojego odbiorcę zanim wystrzeli kolejne zdarzenie.

ZdarzenieKiedy strzelaCo jest w data
key.createdNowy klucz API utworzony w panelu.{ keyId, keyPrefix, name, createdBy }
key.revokedKlucz unieważniony z panelu.{ keyId, keyPrefix, revokedBy } (revokedBy może być null)
key.rotatedKlucz zrotowany w miejscu — to samo id klucza, nowy sekret + prefix.{ keyId, keyPrefix, rotatedBy } (rotatedBy może być null)
quota.thresholdPierwszy raz w danym miesiącu organizacja przekracza 80% miesięcznej kwoty zapytań. Strzela raz na miesiąc per organizacja.{ planId, limit, used, percent }
quota.exceededZapytanie zostało odrzucone, bo organizacja przekroczyła miesięczną kwotę. Strzela przy każdym odrzuconym zapytaniu — traktuj jako sygnał „przestań walić”, nie jako jednorazowy alert.{ planId, limit, used, retryAfterSeconds }
subscription.upgradedPlan zmieniony na wyższy (Free → Hobby/Pro, Hobby → Pro). Free → płatny emituje upgraded.{ fromPlan, toPlan, effectiveAt }
subscription.downgradedPlan zmieniony na niższy lub anulowany (cokolwiek → Free).{ fromPlan, toPlan, effectiveAt }
rates.changedCodzienny cron TEDB wykrył co najmniej jeden ruch stawki VAT względem wczorajszego snapshotu.{ effectiveDate, changes[] } — każda zmiana to { country, field, old, new } (uproszczona forma wiersza /changes; old/new mogą być null).

Bądź wstrzemięźliwy w subskrypcjach. quota.exceeded dla popularnego klucza może wystrzelić tysiące razy na minutę pod atakiem. Subskrybuj tylko te zdarzenia, które naprawdę potrzebuje Twój odbiornik.

Każde dostarczenie — niezależnie od zdarzenia — ma ten sam zewnętrzny kształt:

{
"id": "whd_2bX5tQ9...", // id dostarczenia; takie samo przy retry
"event": "rates.changed", // jedna z nazw zdarzeń powyżej
"orgId": "org_abc123", // id Twojej organizacji
"createdAt": "2026-05-30T07:00:23.000Z",
"livemode": true, // zawsze true na produkcji
"seq": 42, // numer sekwencji per endpoint (lub null)
"data": { /* zawartość zależna od zdarzenia */ }
}

id to id wiersza dostarczenia, stabilny przy retry tego samego dostarczenia. Udany odbiornik powinien deduplikować po tym polu — jeśli już przetworzyłeś whd_2bX5tQ9... i przyszło retry, potwierdź ACK-iem bez ponownej akcji.

seq to numer sekwencji per endpoint — zobacz Numery sekwencji i wykrywanie luk poniżej. Jest częścią podpisanego body, więc obejmuje go podpis.

Każde dostarczenie to POST z tym stałym zestawem nagłówków:

NagłówekPrzykładUwagi
Content-Typeapplication/jsonBody jest zawsze w JSON.
User-AgentStawka-Webhook/1.0Stały; można dodać do allowlisty. Wersja zmienia się tylko przy łamiącej zmianie wysyłki.
Stawka-Signaturet=1748583623,v1=8b2c91a7...HMAC — zobacz Weryfikacja podpisu.
Stawka-Sequence42Obecny tylko, gdy dostarczenie ma numer sekwencji. Odzwierciedla pole seq z body — zobacz Numery sekwencji i wykrywanie luk.

Nazwy nagłówków są niewrażliwe na wielkość liter (HTTP). Nie uwierzytelniaj po User-Agent — to wygodna etykieta, nie poświadczenie. Podpis to jedyna rzecz, która dowodzi, że zapytanie przyszło ze Stawki.

Stawka wysyła nagłówek Stawka-Signature przy każdym zapytaniu:

Stawka-Signature: t=1748583623,v1=8b2c91a7e9d4f5c1...
  • t — UNIX-sekundy z momentu podpisania.
  • v1 — hex SHA-256 HMAC z ${t}.${rawBody} (lower case), z kluczem-sekretem Twojego endpointa. Prefiks ${t}. to obrona przed replayem: atakujący, który przechwyci jeden podpisany body, nie może go wysyłać w nieskończoność.

Format wzorowany na webhookach Stripe’a — odbiorcy znający ten wzorzec mają o jedną nową rzecz mniej do nauki. v1 to znacznik wersji; gdybyśmy musieli zmienić algorytm, przez okno wycofania wysyłaliśmy oba: v1 i v2.

  1. Odczytaj surowy body PRZED parsowaniem JSON. Białe znaki, kolejność kluczy i formatowanie liczb wpływają na HMAC. Nie serializuj ponownie z obiektu — podpisuj dokładnie te bajty, które otrzymałeś.
  2. Odrzuć wszystko z różnicą ponad ±5 minut. Porównaj t z zegarem serwera; poza tym oknem zwróć 400.
  3. HMAC-SHA256 z ${t}.${rawBody} Twoim sekretem endpointa. Porównaj w stałym czasie z polem v1 (hex).
import crypto from "node:crypto";
function verifyStawkaSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const t = Number.parseInt(parts.t, 10);
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > 5 * 60) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex"),
);
}

Wywołanie crypto.timingSafeEqual ma znaczenie — === na stringach hex wycieka informacje o czasie, co pozwala atakującemu odzyskać sekret bajt po bajcie. Ta sama lekcja w każdym języku: porównuj w stałym czasie.

Najczęstszy błąd weryfikacji to podpisanie ponownie zserializowanego body zamiast bajtów, które wysłaliśmy. Większość frameworków parsuje JSON przed uruchomieniem Twojego handlera, a JSON.stringify(parsedBody) nie odtworzy naszych dokładnych bajtów (kolejność kluczy, odstępy i formatowanie liczb się różnią). Pobierz surowy body tylko dla trasy webhooka:

  • Expressexpress.raw({ type: "application/json" }) na trasie webhooka, wtedy req.body to Buffer. Nie wstawiaj express.json() przed nim dla tej ścieżki.
  • Next.js (app router)const raw = await req.text() w handlerze trasy, przed jakimkolwiek req.json().
  • Fastify — dodaj parser content-type, który zachowa surowy bufor, albo czytaj request.rawBody z fastify-raw-body.
  • Flaskrequest.get_data() (zwraca bytes); nie dotykaj wcześniej request.json.
  • Djangorequest.body (surowe bytes).
  • Go net/httpio.ReadAll(r.Body); masz już surowe bajty.

Policz HMAC z tych bajtów, a JSON sparsuj z tych samych bajtów dopiero po pomyślnym sprawdzeniu podpisu.

import hashlib
import hmac
import time
def verify_stawka_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 5 * 60:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))

hmac.compare_digest to porównanie w stałym czasie. Zwróć uwagę, że body jest sklejane jako bajty (f"{t}.".encode() + raw_body), więc bajt spoza UTF-8 w payloadzie nigdy nie zepsuje sumy.

package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
func VerifyStawkaSignature(rawBody []byte, header, secret string) bool {
var t int64
var v1 string
for _, kv := range strings.Split(header, ",") {
k, val, ok := strings.Cut(kv, "=")
if !ok {
continue
}
switch k {
case "t":
t, _ = strconv.ParseInt(val, 10, 64)
case "v1":
v1 = val
}
}
if t == 0 || math.Abs(float64(time.Now().Unix()-t)) > 5*60 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.%s", t, rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}

hmac.Equal to porównanie w stałym czasie. Podpisywany string to <t>.<rawBody> — ta sama konstrukcja ${t}.${rawBody} co w każdym innym języku tutaj.

Wysyłamy POST i oczekujemy 2xx jako ACK. Każda inna odpowiedź — albo jej brak w ciągu 10 sekund — liczy się jako porażka i ponownie wstawiamy do kolejki.

Harmonogram, indeksowany numerem nieudanej próby:

PróbaOdstęp przed kolejną
130 s
21 min
32 min
44 min
58 min
616 min
732 min
81 h
92 h
104 h
11— (oznaczone jako dead, brak dalszych retry)

Każde opóźnienie ma jitter ±10%, żeby rozłożyć retry między odbiorców, którzy psują się jednocześnie (np. gdy popularny host webhooków ma awarię). Łączny budżet to 11 prób.

Po 5 kolejnych nieudanych próbach endpoint jest automatycznie wstrzymany. Już wstawione dostarczenia kończą swój budżet retry; nowe zdarzenia nie wchodzą do kolejki, dopóki nie naprawisz odbiornika i nie włączysz endpointa ponownie z panelu. Pojedyncze udane dostarczenie zeruje licznik kolejnych porażek.

  • At-least-once. Wstrzymanie sieci w połowie retry może spowodować duplikat dostarczenia tego samego id. Zawsze deduplikuj.
  • Kolejność best-effort. W obrębie jednego endpointa dostarczenia z reguły przychodzą w kolejności emisji, ale retry mogą to przetasować. Nie zakładaj, że key.created przyjdzie przed key.revoked dla tego samego klucza — jeśli kolejność istotna, odczytaj stan z API.
  • Porażki są obserwowalne. Każde dostarczenie jest logowane ze statusem, kodem HTTP i ewentualnym błędem sieciowym. Strona szczegółów webhooka pokazuje ostatnie dostarczenia; psujące się wiersze są widoczne inline, więc debugujesz bez grepowania logów.

Status HTTP Twojego odbiornika to cały kontrakt ACK:

ZwracaszStawka robi
2xxOznacza dostarczenie jako success. Brak retry.
Dowolny inny status (3xx, 4xx, 5xx)Liczy się jako porażka i ponawia wg harmonogramu backoff.
Brak odpowiedzi w ciągu 10 sTraktowane jako porażka (timeout) i ponawiane.

Nie ma statusu „trwałej porażki”400 i 500 są traktowane identycznie (oba ponawiają). Zwrócenie 4xx, bo Ty odrzuciłeś payload, nie zatrzyma retry; jedynie spali budżet, aż dostarczenie przejdzie w dead. Jeśli chcesz zatrzymać dostarczenie, zwróć 2xx, żeby je potwierdzić, i odrzuć je u siebie.

Potwierdzaj szybko: zwróć 2xx gdy tylko trwale przyjmiesz zdarzenie (np. zapiszesz je do kolejki), a wolną pracę wykonaj asynchronicznie. Ciężkie przetwarzanie w obrębie zapytania grozi przekroczeniem 10-sekundowego timeoutu, który zamienia udany odbiór w ponawianą „porażkę” — i duplikat.

Wiersze dostarczeń widoczne w panelu przechodzą przez te stany:

  • pending — w kolejce lub w trakcie retry; budżet jeszcze nie wyczerpany.
  • success — otrzymano 2xx.
  • dead — wszystkie 11 prób wyczerpane (albo endpoint został usunięty / wyłączony / wstrzymany przez plan zanim dostarczenie wylądowało). Brak dalszych prób.

Każde dostarczenie do endpointa niesie monotonicznie rosnący seq, udostępniany na dwa sposoby:

  • w podpisanym body jako "seq": 42, oraz
  • w nagłówku Stawka-Sequence: 42 (wygoda, byś wykrył lukę bez parsowania body; źródłem prawdy jest pole w body, bo jest podpisane).

Śledź najwyższy seq, który przetworzyłeś dla danego endpointa. Jeśli kolejne dostarczenie przeskakuje z 41 na 43, dostarczenie 42 zostało albo zgubione, albo jeszcze nie dotarło (kolejność jest best-effort) i możesz pogodzić stan, odczytując go ponownie z API. seq jest per endpoint, nie globalny — dwa endpointy subskrybujące to samo zdarzenie dostają własne sekwencje. Może być null dla starszych dostarczeń lub ścieżek, które go nie nadają; traktuj null jako „brak informacji o luce” i pomiń sprawdzenie.

Ponieważ seq jedzie w podpisanym body, zweryfikuj podpis zanim mu zaufasz — sfałszowany nagłówek Stawka-Sequence nic nie znaczy, jeśli HMAC się nie zgadza.

Żeby zdarzenie rates.changed dotarło do Twojego odbiornika, wszystkie trzy warunki muszą być spełnione:

  1. Twoja organizacja jest w planie Hobby lub Pro (sprawdzane ponownie w chwili strzału, więc downgrade na sekundę przed wysyłką zatrzymuje dostarczenie bez ruszania endpointa).
  2. Endpoint jest włączony w panelu.
  3. rates.changed jest w zestawie subskrybowanych zdarzeń endpointa.

Ta sama bramka działa dla każdego zdarzenia. Ponowny sprawdzian planu to kontrakt „retain but pause” — Twoje endpointy przeżywają downgrade i wznawiają się w chwili powrotu do planu płatnego; nie trzeba ich konfigurować od nowa.

  • Referencja payloadu zdarzeń — kształt data per zdarzenie jest udokumentowany w endpointach, gdzie dane zdarzenia są najbardziej istotne. Dla rates.changed zobacz /changes; webhook niesie uproszczone { country, field, old, new } na zmianę (bez id/snapshotDate/detectedAt — wywołaj /changes, jeśli ich potrzebujesz).
  • Konfiguracja krok po kroku i edycja — w panelu pod Webhooki.
  • Rotacja sekretu — strona szczegółów webhooka w panelu.