Skip to main content

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:

HeaderValue
X-Kobana-Appdunning — 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. charge.paid).
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-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_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.

Request authentication​

If your endpoint sits behind a gateway that requires credentials, configure authentication at registration:

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