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.
Konfiguracja
Dział zatytułowany „Konfiguracja”- W panelu wejdź w Webhooki (w swojej organizacji).
- Utwórz webhook → wpisz URL odbiorcy i zaznacz co najmniej jedno zdarzenie.
- Skopiuj sekret podpisujący pokazany jednorazowo — Stawka nigdy go nie pokaże ponownie. Przechowuj go jak każdy inny sekret.
- 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.
Lista zdarzeń
Dział zatytułowany „Lista zdarzeń”| Zdarzenie | Kiedy strzela | Co jest w data |
|---|---|---|
key.created | Nowy klucz API utworzony w panelu. | { keyId, keyPrefix, name, createdBy } |
key.revoked | Klucz unieważniony z panelu. | { keyId, keyPrefix, revokedBy } (revokedBy może być null) |
key.rotated | Klucz zrotowany w miejscu — to samo id klucza, nowy sekret + prefix. | { keyId, keyPrefix, rotatedBy } (rotatedBy może być null) |
quota.threshold | Pierwszy raz w danym miesiącu organizacja przekracza 80% miesięcznej kwoty zapytań. Strzela raz na miesiąc per organizacja. | { planId, limit, used, percent } |
quota.exceeded | Zapytanie 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.upgraded | Plan zmieniony na wyższy (Free → Hobby/Pro, Hobby → Pro). Free → płatny emituje upgraded. | { fromPlan, toPlan, effectiveAt } |
subscription.downgraded | Plan zmieniony na niższy lub anulowany (cokolwiek → Free). | { fromPlan, toPlan, effectiveAt } |
rates.changed | Codzienny 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.
Koperta payloadu
Dział zatytułowany „Koperta payloadu”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.
Nagłówki zapytania
Dział zatytułowany „Nagłówki zapytania”Każde dostarczenie to POST z tym stałym zestawem nagłówków:
| Nagłówek | Przykład | Uwagi |
|---|---|---|
Content-Type | application/json | Body jest zawsze w JSON. |
User-Agent | Stawka-Webhook/1.0 | Stały; można dodać do allowlisty. Wersja zmienia się tylko przy łamiącej zmianie wysyłki. |
Stawka-Signature | t=1748583623,v1=8b2c91a7... | HMAC — zobacz Weryfikacja podpisu. |
Stawka-Sequence | 42 | Obecny 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.
Weryfikacja podpisu
Dział zatytułowany „Weryfikacja podpisu”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.
Kroki weryfikacji
Dział zatytułowany „Kroki weryfikacji”- 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ś.
- Odrzuć wszystko z różnicą ponad ±5 minut. Porównaj
tz zegarem serwera; poza tym oknem zwróć 400. - HMAC-SHA256 z
${t}.${rawBody}Twoim sekretem endpointa. Porównaj w stałym czasie z polemv1(hex).
Przykład w Node.js
Dział zatytułowany „Przykład w Node.js”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.
Przechwytywanie surowego body
Dział zatytułowany „Przechwytywanie surowego body”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:
- Express —
express.raw({ type: "application/json" })na trasie webhooka, wtedyreq.bodytoBuffer. Nie wstawiajexpress.json()przed nim dla tej ścieżki. - Next.js (app router) —
const raw = await req.text()w handlerze trasy, przed jakimkolwiekreq.json(). - Fastify — dodaj parser content-type, który zachowa surowy
bufor, albo czytaj
request.rawBodyzfastify-raw-body. - Flask —
request.get_data()(zwracabytes); nie dotykaj wcześniejrequest.json. - Django —
request.body(surowebytes). - Go
net/http—io.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.
Przykład w Pythonie
Dział zatytułowany „Przykład w Pythonie”import hashlibimport hmacimport 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.
Przykład w Go
Dział zatytułowany „Przykład w Go”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.
Polityka retry
Dział zatytułowany „Polityka retry”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óba | Odstęp przed kolejną |
|---|---|
| 1 | 30 s |
| 2 | 1 min |
| 3 | 2 min |
| 4 | 4 min |
| 5 | 8 min |
| 6 | 16 min |
| 7 | 32 min |
| 8 | 1 h |
| 9 | 2 h |
| 10 | 4 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.
Automatyczne wyłączanie
Dział zatytułowany „Automatyczne wyłączanie”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.
Semantyka dostarczania
Dział zatytułowany „Semantyka dostarczania”- 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.createdprzyjdzie przedkey.revokeddla 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.
Odpowiadanie na dostarczenia
Dział zatytułowany „Odpowiadanie na dostarczenia”Status HTTP Twojego odbiornika to cały kontrakt ACK:
| Zwracasz | Stawka robi |
|---|---|
2xx | Oznacza 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 s | Traktowane 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— otrzymano2xx.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.
Numery sekwencji i wykrywanie luk
Dział zatytułowany „Numery sekwencji i wykrywanie luk”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.
Co bramkuje dostarczenie
Dział zatytułowany „Co bramkuje dostarczenie”Żeby zdarzenie rates.changed dotarło do Twojego odbiornika,
wszystkie trzy warunki muszą być spełnione:
- 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).
- Endpoint jest włączony w panelu.
rates.changedjest 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.
Wskazówki
Dział zatytułowany „Wskazówki”- Referencja payloadu zdarzeń — kształt
dataper zdarzenie jest udokumentowany w endpointach, gdzie dane zdarzenia są najbardziej istotne. Dlarates.changedzobacz/changes; webhook niesie uproszczone{ country, field, old, new }na zmianę (bezid/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.