Webhook security
Every webhook delivery is signed with a secret that only you and Kobana know. Verifying the signature before acting on a payload guarantees three things:
- Authenticity — the request really came from Kobana, not from someone who found your endpoint URL.
- Integrity — the body was not altered in transit.
- Freshness — the request is not a replay of an old delivery.
The scheme follows the Standard Webhooks specification, so you can verify with an off-the-shelf library or with a few lines of code in any language.
The signing secret
Each webhook has its own secret in the form whsec_ + base64 of 32 random bytes, for example:
whsec_c2VjcmV0LWJ5dGVzLWV4YW1wbGUtc2VjcmV0LWJ5dGVz
- It is returned once, in the response of
POST /webhooksand ofPOST /webhooks/{id}/regenerate-secret. It is never returned byGET. - Store it in a secret manager or environment variable — never in source code. The
whsec_prefix lets secret scanners (GitHub, gitleaks) flag it if it ever lands in a repository. - Secrets are per environment: a
sandboxwebhook and aproductionwebhook never share one. - You may bring your own secret by sending
signing_secreton creation or rotation. It must bewhsec_+ standard base64 of 24–64 random bytes.
Headers
| Header | Value |
|---|---|
X-Kobana-App | inbox — the Kobana product sending the webhook. |
X-Kobana-Environment | development, sandbox or production. |
X-Kobana-Webhook-Id | The Webhook.id this delivery belongs to. |
X-Kobana-Event | Event slug, e.g. email.agent.processed. |
X-Kobana-Event-Id | SystemEvent.id. Retries of the same event share it. |
X-Idempotency-Key | Same value as X-Kobana-Event-Id on the original dispatch and every automatic retry, so an endpoint that honours idempotency keys processes each event once. A manual replay sends a fresh, random value instead, so the destination treats it as a new delivery. |
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 | Same values as the three above, in the Standard Webhooks layout. |
X-Kobana-App and X-Kobana-Environment let a single endpoint receive webhooks from several Kobana products and environments and route them safely — for instance, never let a sandbox delivery touch production data.
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 unicode 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. - During a secret rotation the header carries one token per valid secret, e.g.
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 absorbs clock skew while bounding replays. - Compute
signed_contentfrom the raw body and calculate the expected HMAC for each secret you currently trust. - Compare with each
v1,token using a constant-time comparison. Never use==: it returns as soon as a byte differs and leaks the digest through timing. - Deduplicate by
X-Idempotency-Keyif you want to process each event once — automatic retries of the same event carry the same key and a different delivery id, but a manual replay deliberately carries a new key, since it means to be acted on again — or bywebhook-id(the delivery id) if you want to record every attempt. - Return
2xxquickly and do the heavy work asynchronously. Anything else (timeout of 10 s,3xx,4xx,5xx) is retried with backoff.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* @param headers lower-cased request headers
* @param rawBody the body exactly as received (string or Buffer)
* @param secrets every secret you trust: current, plus the previous one during a rotation
*/
export 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');
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
throw new Error('timestamp outside tolerance');
}
const content = Buffer.concat([Buffer.from(`${id}.${ts}.`), Buffer.from(rawBody)]);
const received = signature
.split(' ')
.filter((token) => token.startsWith('v1,'))
.map((token) => Buffer.from(token.slice(3), 'base64'));
for (const secret of [].concat(secrets)) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(content).digest();
if (received.some((sig) => sig.length === expected.length && timingSafeEqual(sig, expected))) {
return true;
}
}
throw new Error('signature mismatch');
}
With Express, keep the raw body by mounting express.raw({ type: 'application/json' }) on the webhook route (not express.json()), then call verifyKobanaWebhook(req.headers, req.body, process.env.KOBANA_WEBHOOK_SECRET) before JSON.parse(req.body).
Python
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
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")
ts = int(timestamp)
if abs(time.time() - ts) > TOLERANCE_SECONDS:
raise ValueError("timestamp outside tolerance")
content = f"{msg_id}.{ts}.".encode() + raw_body
received = [
base64.b64decode(token[3:])
for token in signature.split(" ")
if token.startswith("v1,")
]
for secret in [secrets] if isinstance(secrets, str) else secrets:
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = hmac.new(key, content, hashlib.sha256).digest()
if any(hmac.compare_digest(sig, expected) for sig in received):
return True
raise ValueError("signature mismatch")
In Django use request.body; in Flask use request.get_data(); in FastAPI use await request.body(). All three return the raw bytes.
Ruby
require "base64"
require "openssl"
TOLERANCE_SECONDS = 300
def verify_kobana_webhook(headers, raw_body, secrets)
id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signature = headers["webhook-signature"]
raise "missing signature headers" if [id, timestamp, signature].any?(&:nil?)
ts = Integer(timestamp)
raise "timestamp outside tolerance" if (Time.now.to_i - ts).abs > TOLERANCE_SECONDS
content = "#{id}.#{ts}.#{raw_body}"
received = signature.split(" ")
.select { |token| token.start_with?("v1,") }
.map { |token| Base64.strict_decode64(token[3..]) }
Array(secrets).each do |secret|
key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
expected = OpenSSL::HMAC.digest("SHA256", key, content)
return true if received.any? do |sig|
sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected)
end
end
raise "signature mismatch"
end
In Rails the raw body is request.raw_post and the headers are request.headers["webhook-signature"] etc.
PHP
<?php
const TOLERANCE_SECONDS = 300;
function verifyKobanaWebhook(array $headers, string $rawBody, array|string $secrets): bool
{
$id = $headers['webhook-id'] ?? null;
$timestamp = $headers['webhook-timestamp'] ?? null;
$signature = $headers['webhook-signature'] ?? null;
if (!$id || !$timestamp || !$signature) {
throw new RuntimeException('missing signature headers');
}
$ts = (int) $timestamp;
if (abs(time() - $ts) > TOLERANCE_SECONDS) {
throw new RuntimeException('timestamp outside tolerance');
}
$content = "$id.$ts.$rawBody";
$received = [];
foreach (explode(' ', $signature) as $token) {
if (str_starts_with($token, 'v1,')) {
$received[] = base64_decode(substr($token, 3), true);
}
}
foreach ((array) $secrets as $secret) {
$key = base64_decode(substr($secret, strlen('whsec_')), true);
$expected = hash_hmac('sha256', $content, $key, true);
foreach ($received as $sig) {
if ($sig !== false && hash_equals($expected, $sig)) {
return true;
}
}
}
throw new RuntimeException('signature mismatch');
}
// $rawBody = file_get_contents('php://input');
Using a Standard Webhooks library
Because the headers and the algorithm follow the specification, the official standardwebhooks libraries (JavaScript, Python, Ruby, PHP, Go, Java, C#, Rust, Elixir) verify Kobana deliveries as-is:
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.KOBANA_WEBHOOK_SECRET); // the whsec_ value
const payload = wh.verify(rawBody, headers); // throws on failure
from standardwebhooks import Webhook
wh = Webhook(os.environ["KOBANA_WEBHOOK_SECRET"])
payload = wh.verify(raw_body, headers) # raises on failure
The libraries enforce the 5-minute tolerance and constant-time comparison for you. During a rotation, verify with the secret you currently hold: the header already carries a token for it.
Rotating the secret
curl -X POST https://api.inbox.kobana.com.br/v1/webhooks/$WEBHOOK_ID/regenerate-secret \
--cert ./client.crt --key ./client.key \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: $(uuidgen)" \
-d '{ "previous_secret_grace_hours": 24 }'
{
"signing_secret": "whsec_…",
"previous_secret_expires_at": "2026-09-07T15:00:00.000Z"
}
- By default the previous secret keeps signing for 24 hours (
previous_secret_grace_hours, 0–168). During that window every delivery carries two tokens, so you can deploy the new secret at your own pace with zero dropped events. - If the current secret has leaked, pass
previous_secret_grace_hours: 0— the old secret stops signing immediately. - At most two secrets are ever valid. Rotating again inside the window discards the older one.
GET /webhooks/{id}exposessigning_secret_rotated_atandsigning_secret_previous_expires_atso you can audit rotations; the secret values themselves are never readable again.- The response is returned to the first caller only. A replay with the same
X-Idempotency-Keyreturns410instead of the secret.
Hardening checklist
- Serve the endpoint over HTTPS with a valid certificate;
https://is required outsidedevelopment. - Verify the signature on the raw body before any parsing or framework middleware touches it.
- Reject deliveries whose
X-Kobana-Environmentdoes not match the environment your endpoint serves. - Optionally configure
auth(basic,beareror a custom header) on the webhook as a second factor, and check it as well. - Deduplicate by
X-Idempotency-Key/X-Kobana-Event-Id(process each event once) orX-Kobana-Delivery-Id(record each attempt); keep the ids for at least the retry horizon (~33 h). - Never log the secret, and mask
webhook-signature/X-Kobana-Signaturein request logs. - Rotate on a schedule and immediately after any suspected leak.
Troubleshooting a mismatch
| Symptom | Likely cause |
|---|---|
| Every delivery fails verification | Wrong secret (sandbox vs. production, or an old one after a rotation with 0 grace), or the body was re-serialized before hashing. |
| Fails only behind a proxy / API gateway | The gateway rewrote the body (pretty-printing, charset conversion) or dropped the webhook-* headers. Verify on the raw bytes it forwards, or use the X-Kobana-* headers. |
timestamp outside tolerance | Server clock drift. Sync with NTP; do not widen the window beyond a few minutes. |
| Works, then stops after a rotation | The new secret was never deployed and the grace window ended. Rotate again and roll the new value out within the window. |