Skip to main content

Webhooks

Webhooks are HTTP notifications sent by the Magic Dashboard API whenever a relevant event happens in your Organization — an API token created, deactivated, rotated or revoked, and so on.

Instead of polling, you register a public HTTP endpoint and receive a POST with the event payload as soon as it happens.

Registration​

Manage endpoints in Dashboard → Settings → API → Webhooks. Each endpoint has:

  • URL — public HTTPS destination on your server.
  • Subscribed events — the event types you want to receive. If empty, it receives all of them.
  • Status — active or inactive.
  • Secret — secret string used to sign each delivery (HMAC-SHA256). Generated automatically; can be rotated.
  • Auth headers — optionally, extra headers (Basic, Bearer, custom) injected in every request.

Delivery​

Each event triggers a POST request to the registered URL:

POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Kobana-Webhooks/1.0
X-Kobana-App: dashboards
X-Kobana-Environment: production
X-Kobana-Webhook-Id: 7c1e9d2a-3b4f-4c5d-8e6f-0a1b2c3d4e5f
X-Kobana-Event: api_key.status_changed
X-Kobana-Event-Id: 3d1f0c9a-5b2e-4c7d-9e1f-8a6b4c2d0e9f
X-Kobana-Delivery-Id: 9b2e4a10-7c3f-4d8e-a1b2-c3d4e5f60718
X-Kobana-Timestamp: 1753272000
X-Kobana-Signature: v1,MEUCIQD3…
webhook-id: 9b2e4a10-7c3f-4d8e-a1b2-c3d4e5f60718
webhook-timestamp: 1753272000
webhook-signature: v1,MEUCIQD3…

{...JSON payload, in snake_case...}

The headers are described in Headers and signature, below.

Success​

Any 2xx response within 30 seconds marks the delivery as successful. Anything else (timeout, 4xx, 5xx) is treated as a failure.

Retry​

Failures are redelivered with exponential backoff:

AttemptDelay
1immediate
2+1 min
3+5 min
4+30 min
5+2 h
6+6 h
7+24 h

After 7 attempts, the delivery is permanently marked as failed. Failed deliveries stay available in the dashboard for manual redelivery.

Headers and signature (Kobana standard)​

Every delivery is a POST with Content-Type: application/json and the headers below — the same across all Kobana products, so a single receiver can take webhooks from several products and environments and route them safely:

HeaderValue
X-Kobana-Appdashboards — the Kobana product that sent the delivery.
X-Kobana-Environmentdevelopment, sandbox or production. Never let a sandbox delivery touch production data.
X-Kobana-Webhook-IdId of the registered endpoint this delivery belongs to.
X-Kobana-EventEvent slug (e.g. api_key.status_changed).
X-Kobana-Event-IdEvent id. Retries of the same event repeat it — use it to deduplicate.
X-Kobana-Delivery-IdUnique id of this delivery attempt. Part of the signed content.
X-Kobana-TimestampUnix time (seconds) when the delivery was signed. Part of the signed content.
X-Kobana-Signaturev1,<base64 HMAC> — one or more tokens separated by a space.
webhook-id, webhook-timestamp, webhook-signatureThe same three values, in the Standard Webhooks layout — verifiable with an off-the-shelf library.

The legacy headers X-Signature (t=<unix>,v1=<hex> over "{t}.{body}"), X-Delivery-Id, X-Event-Id, X-Event-Type, X-Timestamp, X-App and X-Environment are still sent, deprecated, for existing integrations. Migrate to the X-Kobana-* headers: they will be removed in a future version of the contract.

How the signature is computed​

key = base64_decode(secret without the "whsec_" prefix)
signed_content = delivery_id + "." + timestamp + "." + raw_body
signature = "v1," + base64(HMAC_SHA256(key, signed_content))
  • raw_body is the request body exactly as received, byte for byte. Do not parse and re-serialize it: key order, whitespace or escaping would change and the signature would no longer match.
  • Binding the delivery_id into the signed content means a captured request cannot be replayed under a different id to bypass your deduplication; the timestamp bounds replays in time.
  • The secret is whsec_ + base64 of 32 random bytes and is shown once. Older secrets (without the prefix, or with hex after it) keep working: the key is the base64-decoded value after the prefix, or the UTF-8 bytes of the secret when there is no prefix.
  • During a secret rotation the header carries one token per still-valid secret (v1,AAA… v1,BBB…). A delivery is valid if any token matches any secret you hold.

Verifying a delivery​

  1. Read webhook-id, webhook-timestamp and webhook-signature (or the X-Kobana-* equivalents). Reject the request if any is missing.
  2. Reject if |now − timestamp| > 300 seconds: five minutes absorb clock skew while bounding replays.
  3. Build the signed content from the raw body and compute the expected HMAC for each secret you trust.
  4. Compare with each v1, token using a constant-time comparison — never ==, which returns at the first differing byte and leaks the digest through timing.
  5. Deduplicate by X-Kobana-Event-Id to process each event once, or by webhook-id to record every attempt.
  6. Return 2xx quickly and do the heavy work asynchronously. Anything else goes into the retry cycle.
const crypto = require('node:crypto');

/**
* @param headers request headers, lower-cased names
* @param rawBody raw body (string or Buffer), BEFORE any JSON.parse
* @param secrets current secret (and the previous one during a rotation)
*/
function verifyKobanaWebhook(headers, rawBody, secrets) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signature = headers['webhook-signature'];
if (!id || !timestamp || !signature) throw new Error('missing signature headers');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('timestamp out of tolerance');

const content = `${id}.${timestamp}.${rawBody}`;
const received = signature.split(' ').filter((t) => t.startsWith('v1,')).map((t) => Buffer.from(t.slice(3), 'base64'));
for (const secret of [].concat(secrets)) {
const key = secret.startsWith('whsec_') ? Buffer.from(secret.slice(6), 'base64') : Buffer.from(secret, 'utf8');
const expected = crypto.createHmac('sha256', key).update(content).digest();
if (received.some((r) => r.length === expected.length && crypto.timingSafeEqual(r, expected))) return true;
}
throw new Error('no matching signature');
}
import base64, hashlib, hmac, time

def verify_kobana_webhook(headers, raw_body: bytes, secrets) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signature = headers.get("webhook-signature")
if not (msg_id and timestamp and signature):
raise ValueError("missing signature headers")
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp out of tolerance")
content = f"{msg_id}.{timestamp}.".encode() + raw_body
received = [base64.b64decode(t[3:]) for t in signature.split(" ") if t.startswith("v1,")]
for secret in ([secrets] if isinstance(secrets, str) else secrets):
key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
expected = hmac.new(key, content, hashlib.sha256).digest()
if any(hmac.compare_digest(r, expected) for r in received):
return True
raise ValueError("no matching signature")

With Express, keep the raw body by mounting express.raw({ type: 'application/json' }) on the webhook route (not express.json()), verify, and only then JSON.parse. With Django/Flask use request.body/request.get_data() — never the already-parsed object.

Payload structure​

Every event shares the same envelope:

{
"id": "evt_01HXYZABC123",
"type": "api_key.status_changed",
"created_at": "2026-05-20T12:00:00.000Z",
"environment": "production",
"version": "2026-03-01",
"data": { /* full resource */ },
"changes": { /* optional, changes since the previous state */ }
}
FieldDescription
idUnique event ID.
typeEvent type (e.g. api_key.status_changed).
created_atISO 8601 timestamp (UTC) when the event was generated.
environmentproduction or sandbox.
versionPayload schema version (YYYY-MM-DD string).
dataThe full resource in its current state.
changesFor *.updated and *.status_changed events, a field: {from, to} map. May be absent on create/delete events.

Available events​

ResourceEvents
API tokenapi_key.db.created, api_key.db.updated, api_key.db.deleted, api_key.status_changed, api_key.rotated, api_key.revoked

Best practices​

  • Idempotency: use X-Kobana-Event-Id to avoid processing duplicates on retries.
  • Fast response: answer 2xx within 30s. Push heavy work to an asynchronous queue.
  • Versioning: the version field indicates the schema. Treat new fields as additive.
  • Mandatory validation: always validate the signature before trusting the payload.
  • HTTPS: use TLS on the endpoint. HTTP endpoints are rejected in production.
  • Event list: prefer subscribing only to the events you process, not *.