Skip to content

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.

  1. Go to Webhooks in the dashboard (under your organization).
  2. Create webhook → enter the receiver URL and pick at least one event.
  3. Copy the signing secret that’s revealed once — Stawka never shows it again. Store it like any other secret.
  4. 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.

EventWhen it firesWhat’s in data
key.createdA new API key is minted in the dashboard.{ keyId, keyPrefix, name, createdBy }
key.revokedA key is revoked from the dashboard.{ keyId, keyPrefix, revokedBy } (revokedBy may be null)
key.rotatedA key is rotated in place — same key id, new secret + prefix.{ keyId, keyPrefix, rotatedBy } (rotatedBy may be null)
quota.thresholdFirst time in the month the org crosses 80% of its monthly request quota. Fires once per month per org.{ planId, limit, used, percent }
quota.exceededA 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.upgradedPlan moves to a higher tier (Free → Hobby/Pro, Hobby → Pro). Free → paid emits upgraded.{ fromPlan, toPlan, effectiveAt }
subscription.downgradedPlan moves to a lower tier or is cancelled (anything → Free).{ fromPlan, toPlan, effectiveAt }
rates.changedDaily 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.

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.

Every delivery is a POST with this fixed set of headers:

HeaderExampleNotes
Content-Typeapplication/jsonBody is always JSON.
User-AgentStawka-Webhook/1.0Stable; safe to allowlist. The version bumps only on a breaking dispatch change.
Stawka-Signaturet=1748583623,v1=8b2c91a7...HMAC — see Signature verification.
Stawka-Sequence42Present 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.

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.

  1. 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.
  2. Reject anything more than ±5 minutes off. Compare t to your server clock; outside that window, return 400.
  3. HMAC-SHA256 of ${t}.${rawBody} with your endpoint secret. Compare in constant time to the 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"),
);
}

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.

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:

  • Expressexpress.raw({ type: "application/json" }) on the webhook route, then req.body is a Buffer. Don’t put express.json() ahead of it for that path.
  • Next.js (app router)const raw = await req.text() in the route handler, before any req.json().
  • Fastify — add a content-type parser that keeps the raw buffer, or read request.rawBody with fastify-raw-body.
  • Flaskrequest.get_data() (returns bytes); don’t touch request.json first.
  • Djangorequest.body (raw bytes).
  • Go net/httpio.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.

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 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.

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.

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:

AttemptDelay before next
130 s
21 min
32 min
44 min
58 min
616 min
732 min
81 h
92 h
104 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.

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.

  • 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.created arriving before its key.revoked for 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.

Your receiver’s HTTP status is the entire ack contract:

You returnStawka does
2xxMarks 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 sTreated 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 — a 2xx was received.
  • dead — all 11 attempts exhausted (or the endpoint was deleted / disabled / plan-paused before the delivery landed). No further attempts.

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: 42 header (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.

A rates.changed event reaching your receiver requires all three:

  1. 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).
  2. The endpoint is enabled in the dashboard.
  3. rates.changed is 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.

  • Event payload reference — the per-event data shape is documented per-endpoint where the event is most relevant. For rates.changed, see /changes; the webhook carries a slim { country, field, old, new } per change (no id/snapshotDate/detectedAt — call /changes if you need those).
  • Setup walk-through and edit UI — in the dashboard at Webhooks.
  • Signing secret rotation — dashboard’s webhook detail page.