Security
Two layers protect the communication: the HMAC signature (proves the payload came from Dunning and was not tampered with) and request authentication (credentials Dunning presents to your endpoint). The first is always included; the second is optional.
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 | dunning — 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. charge.paid). |
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-Webhook-Event, X-Webhook-Event-Id, X-Webhook-Delivery-Id, X-Webhook-Attempt, X-Webhook-Timestamp and X-Webhook-Signature (t=<unix>,v1=<hex> over "{t}.{body}") 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.
Request authentication​
If your endpoint sits behind a gateway that requires credentials, configure authentication at registration:
| Type | What Dunning sends |
|---|---|
None (none) | Only the HMAC signature |
Basic (basic) | Authorization: Basic <usuário:senha> |
Bearer (bearer) | Authorization: Bearer <token> |
Header (header) | A custom header with a name and value you define |
Credentials are encrypted at rest (AES-256-GCM) and never appear in the delivery trail: in the request/response capture, the signature and the authentication headers are stored as [REDACTED].
Secret rotation​
The rotation button (on the endpoint's row) generates a new secret, shown only once, and invalidates the previous one immediately (also available via POST /v1/webhook-endpoints/{id}/rotate-secret). Rotate on suspicion of a leak or as periodic hygiene; update the consumer at the same moment, since deliveries signed with the old secret will stop validating.
Protections on the Dunning side​
- HTTPS only:
http://URLs are refused at registration. - Anti-SSRF: the URL is validated at registration and revalidated on every delivery (DNS re-resolved, IP pinned, internal networks blocked). Redirects are not followed. A destination that starts resolving to an internal network is blocked permanently (
SSRF blocked, no retry). - Responses from your endpoint are captured with sensitive headers (
Set-Cookie,Authorization...) redacted.