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 typesyou want to receive. If empty, it receives all of them. - Status —
activeorinactive. - 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:
| Attempt | Delay |
|---|---|
| 1 | immediate |
| 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:
| Header | Value |
|---|---|
X-Kobana-App | dashboards — the Kobana product that sent the delivery. |
X-Kobana-Environment | development, sandbox or production. Never let a sandbox delivery touch production data. |
X-Kobana-Webhook-Id | Id of the registered endpoint this delivery belongs to. |
X-Kobana-Event | Event slug (e.g. api_key.status_changed). |
X-Kobana-Event-Id | Event id. Retries of the same event repeat it — use it to deduplicate. |
X-Kobana-Delivery-Id | Unique id of this delivery attempt. Part of the signed content. |
X-Kobana-Timestamp | Unix time (seconds) when the delivery was signed. Part of the signed content. |
X-Kobana-Signature | v1,<base64 HMAC> — one or more tokens separated by a space. |
webhook-id, webhook-timestamp, webhook-signature | The 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_bodyis 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_idinto 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
- Read
webhook-id,webhook-timestampandwebhook-signature(or theX-Kobana-*equivalents). Reject the request if any is missing. - Reject if
|now − timestamp| > 300seconds: five minutes absorb clock skew while bounding replays. - Build the signed content from the raw body and compute the expected HMAC for each secret you trust.
- Compare with each
v1,token using a constant-time comparison — never==, which returns at the first differing byte and leaks the digest through timing. - Deduplicate by
X-Kobana-Event-Idto process each event once, or bywebhook-idto record every attempt. - Return
2xxquickly 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 */ }
}
| Field | Description |
|---|---|
id | Unique event ID. |
type | Event type (e.g. api_key.status_changed). |
created_at | ISO 8601 timestamp (UTC) when the event was generated. |
environment | production or sandbox. |
version | Payload schema version (YYYY-MM-DD string). |
data | The full resource in its current state. |
changes | For *.updated and *.status_changed events, a field: {from, to} map. May be absent on create/delete events. |
Available events
| Resource | Events |
|---|---|
| API token | api_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-Idto avoid processing duplicates on retries. - Fast response: answer
2xxwithin 30s. Push heavy work to an asynchronous queue. - Versioning: the
versionfield 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
*.