Webhooks
Webhooks push events to a URL you control instead of you polling the API. Every event is HMAC-signed with a per-endpoint secret so you can verify it actually came from Stawka before acting on it.
Webhooks are available on Hobby and Pro plans. On Free plans the create button is disabled; existing endpoints from a prior paid plan are retained but paused — upgrade and they resume automatically.
- Go to Webhooks in the dashboard (under your organization).
- Create webhook → enter the receiver URL and pick at least one event.
- Copy the signing secret that’s revealed once — Stawka never shows it again. Store it like any other secret.
- The dashboard’s Send test event button fires a synthetic delivery so you can verify your receiver before real events flow.
You can edit the event set anytime; the secret can be rotated from the detail page. After rotation, the previous secret stops verifying signatures immediately — update your receiver before any new event fires.
Event taxonomy
Section titled “Event taxonomy”| Event | When it fires | What’s in data |
|---|---|---|
key.created | A new API key is minted in the dashboard. | { keyId, keyPrefix, name, createdBy } |
key.revoked | A key is revoked from the dashboard. | { keyId, keyPrefix, revokedBy } (revokedBy may be null) |
key.rotated | A key is rotated in place — same key id, new secret + prefix. | { keyId, keyPrefix, rotatedBy } (rotatedBy may be null) |
quota.threshold | First time in the month the org crosses 80% of its monthly request quota. Fires once per month per org. | { planId, limit, used, percent } |
quota.exceeded | A request is denied because the org is over its monthly quota. Fires every denied request — treat as a “stop hammering” signal, not a one-shot alert. | { planId, limit, used, retryAfterSeconds } |
subscription.upgraded | Plan moves to a higher tier (Free → Hobby/Pro, Hobby → Pro). Free → paid emits upgraded. | { fromPlan, toPlan, effectiveAt } |
subscription.downgraded | Plan moves to a lower tier or is cancelled (anything → Free). | { fromPlan, toPlan, effectiveAt } |
rates.changed | Daily TEDB cron detected at least one VAT rate move vs. yesterday’s snapshot. | { effectiveDate, changes[] } — each change is { country, field, old, new } (a slim form of the /changes row; old/new may be null). |
Stay narrow on what to subscribe to. quota.exceeded for a hot key
can fire thousands of times per minute under abuse. Subscribe only
to events your receiver actually needs.
Payload envelope
Section titled “Payload envelope”Every delivery — regardless of event — has the same outer shape:
{ "id": "whd_2bX5tQ9...", // delivery id; same across retries "event": "rates.changed", // one of the event names above "orgId": "org_abc123", // your organization id "createdAt": "2026-05-30T07:00:23.000Z", "livemode": true, // always true in prod "seq": 42, // per-endpoint sequence number (or null) "data": { /* event-specific */ }}The id is the delivery row id, stable across retries of the same
delivery. A successful receiver should dedupe on this — if you’ve
already processed whd_2bX5tQ9... and a retry arrives, ack it without
re-acting.
seq is the per-endpoint sequence number — see
Sequence numbers & gap detection
below. It’s part of the signed body, so it’s covered by the signature.
Request headers
Section titled “Request headers”Every delivery is a POST with this fixed set of headers:
| Header | Example | Notes |
|---|---|---|
Content-Type | application/json | Body is always JSON. |
User-Agent | Stawka-Webhook/1.0 | Stable; safe to allowlist. The version bumps only on a breaking dispatch change. |
Stawka-Signature | t=1748583623,v1=8b2c91a7... | HMAC — see Signature verification. |
Stawka-Sequence | 42 | Present only when the delivery has a sequence number. Mirrors the body’s seq — see Sequence numbers & gap detection. |
Header names are case-insensitive (HTTP). Don’t authenticate off
User-Agent — it’s a convenience label, not a credential. The
signature is the only thing that proves a request came from
Stawka.
Signature verification
Section titled “Signature verification”Stawka sends a Stawka-Signature header on every request:
Stawka-Signature: t=1748583623,v1=8b2c91a7e9d4f5c1...t— UNIX seconds at the time we signed.v1— lowercase hex SHA-256 HMAC of${t}.${rawBody}, keyed by your endpoint’s signing secret. The${t}.prefix is the replay-defence: an attacker who captures one signed body can’t resend it indefinitely.
The format mirrors Stripe’s webhook signing — receivers familiar with
that pattern have one less new thing to learn. v1 is a version
tag; if we ever need to migrate the algorithm we’ll emit both v1
and v2 during a deprecation window.
Verification steps
Section titled “Verification steps”- Read the raw request body before parsing JSON. Whitespace, key ordering, and number formatting all affect the HMAC. Don’t re-serialize from a parsed object — sign the exact bytes you received.
- Reject anything more than ±5 minutes off. Compare
tto your server clock; outside that window, return 400. - HMAC-SHA256 of
${t}.${rawBody}with your endpoint secret. Compare in constant time to thev1hex.
Node.js example
Section titled “Node.js example”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"), );}The crypto.timingSafeEqual call matters — === on the hex strings
leaks timing information that lets an attacker recover the secret
byte-by-byte. Same lesson applies in any language: compare in
constant time.
Capturing the raw body
Section titled “Capturing the raw body”The single most common verification bug is signing a re-serialized
body instead of the bytes we sent. Most frameworks parse JSON
before your handler runs, and JSON.stringify(parsedBody) will
not reproduce our exact bytes (key order, spacing, and number
formatting differ). Grab the raw body for the webhook route only:
- Express —
express.raw({ type: "application/json" })on the webhook route, thenreq.bodyis aBuffer. Don’t putexpress.json()ahead of it for that path. - Next.js (app router) —
const raw = await req.text()in the route handler, before anyreq.json(). - Fastify — add a content-type parser that keeps the raw
buffer, or read
request.rawBodywithfastify-raw-body. - Flask —
request.get_data()(returnsbytes); don’t touchrequest.jsonfirst. - Django —
request.body(rawbytes). - Go
net/http—io.ReadAll(r.Body); you already have the raw bytes.
HMAC the bytes, then parse the JSON from those same bytes once the signature checks out.
Python example
Section titled “Python example”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 is the constant-time comparison. Note the
body is concatenated as bytes (f"{t}.".encode() + raw_body) so a
non-UTF-8 byte in the payload can never corrupt the digest.
Go example
Section titled “Go example”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 is the constant-time compare. The signed string is
<t>.<rawBody> — same ${t}.${rawBody} construction as every other
language here.
Retry policy
Section titled “Retry policy”We send POST and expect 2xx to ack. Any other response — or
no response within 10 seconds — counts as a failure and we re-queue.
The schedule, indexed by failed-attempt number:
| Attempt | Delay before next |
|---|---|
| 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 | — (marked dead, no more retries) |
Each delay is jittered ±10% to spread retries across receivers that all fail at once (e.g. when a popular cloud webhook host has an outage). Total budget is 11 attempts.
Auto-disable
Section titled “Auto-disable”After 5 consecutive failures an endpoint is automatically paused. Pending deliveries finish their retry budget; new events won’t enqueue until you fix the receiver and re-enable the endpoint from the dashboard. A single successful delivery resets the consecutive-failure counter.
Delivery semantics
Section titled “Delivery semantics”- At-least-once. A network blip mid-retry can produce a duplicate
delivery of the same
id. Always dedupe. - Order is best-effort. Within a single endpoint, deliveries
generally arrive in emit order, but retries can shuffle that.
Don’t depend on
key.createdarriving before itskey.revokedfor the same key — read state from the API if order matters. - Failures are observable. Each delivery attempt is logged with its status, http response code, and any network error. The dashboard’s webhook detail page shows recent deliveries; failing rows surface inline so you can debug without grepping logs.
Responding to deliveries
Section titled “Responding to deliveries”Your receiver’s HTTP status is the entire ack contract:
| You return | Stawka does |
|---|---|
2xx | Marks the delivery success. No retry. |
Any other status (3xx, 4xx, 5xx) | Counts as a failure and retries on the backoff schedule. |
| No response within 10 s | Treated as a failure (timeout) and retried. |
There is no “permanent failure” status — a 400 and a 500 are
treated identically (both retry). So returning 4xx because you
rejected the payload won’t stop the retries; it just burns the retry
budget until the delivery goes dead. If you want a delivery to stop,
return 2xx to ack it and drop it on your side.
Ack fast: return 2xx as soon as you’ve durably accepted the
event (e.g. written it to a queue), then do slow work asynchronously.
Heavy processing inside the request risks the 10-second timeout, which
turns a successful receive into a retried “failure” — and a duplicate.
Delivery rows you’ll see in the dashboard move through these states:
pending— queued or mid-retry; budget not yet exhausted.success— a2xxwas received.dead— all 11 attempts exhausted (or the endpoint was deleted / disabled / plan-paused before the delivery landed). No further attempts.
Sequence numbers & gap detection
Section titled “Sequence numbers & gap detection”Each delivery to an endpoint carries a monotonically increasing
seq, exposed two ways:
- in the signed body as
"seq": 42, and - in the
Stawka-Sequence: 42header (a convenience so you can detect a gap without parsing the body; the body field is the source of truth since it’s signed).
Track the highest seq you’ve processed per endpoint. If the next
delivery jumps from 41 to 43, delivery 42 was either dropped or
hasn’t arrived yet (order is best-effort), and you can reconcile by
re-reading state from the API. seq is per-endpoint, not global —
two endpoints subscribed to the same event each get their own
sequence. It may be null for older deliveries or paths that don’t
assign one; treat null as “no gap information” and skip the check.
Because seq rides in the signed body, verify the signature before
trusting it — a forged Stawka-Sequence header means nothing if the
HMAC doesn’t check out.
What gates delivery
Section titled “What gates delivery”A rates.changed event reaching your receiver requires all three:
- Your org is on Hobby or Pro (re-checked at fire time, so a downgrade made one second before dispatch prevents delivery without touching the endpoint row).
- The endpoint is enabled in the dashboard.
rates.changedis in the endpoint’s subscribed events set.
The same gating applies to every event. The plan re-check is the “retain but pause” contract — your endpoints survive a downgrade and resume the moment you upgrade back; no re-setup needed.
Pointers
Section titled “Pointers”- Event payload reference — the per-event
datashape is documented per-endpoint where the event is most relevant. Forrates.changed, see/changes; the webhook carries a slim{ country, field, old, new }per change (noid/snapshotDate/detectedAt— call/changesif you need those). - Setup walk-through and edit UI — in the dashboard at Webhooks.
- Signing secret rotation — dashboard’s webhook detail page.