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, prefix, name, createdBy }
key.revokedA key is revoked from the dashboard.{ keyId, prefix, name, revokedBy }
key.rotatedA key is rotated (old key revoked, new key minted as a pair).{ oldKeyId, newKeyId, prefix }
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 }
subscription.downgradedPlan moves to a lower tier or is cancelled (anything → Free).{ fromPlan, toPlan }
rates.changedDaily TEDB cron detected at least one VAT rate move vs. yesterday’s snapshot.{ effectiveDate, changes[] } — same shape as the /changes endpoint.

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

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.

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.

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 — same changes array.
  • Setup walk-through and edit UI — in the dashboard at Webhooks.
  • Signing secret rotation — dashboard’s webhook detail page.