Skip to main content

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 /webhooks and of POST /webhooks/{id}/regenerate-secret. It is never returned by GET.
  • 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 sandbox webhook and a production webhook never share one.
  • You may bring your own secret by sending signing_secret on creation or rotation. It must be whsec_ + standard base64 of 24–64 random bytes.

Headers​

HeaderValue
X-Kobana-Appinbox — the Kobana product sending the webhook.
X-Kobana-Environmentdevelopment, sandbox or production.
X-Kobana-Webhook-IdThe Webhook.id this delivery belongs to.
X-Kobana-EventEvent slug, e.g. email.agent.processed.
X-Kobana-Event-IdSystemEvent.id. Retries of the same event share it.
X-Idempotency-KeySame 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-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-signatureSame 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_body is 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_id into 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​

  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 absorbs clock skew while bounding replays.
  3. Compute signed_content from the raw body and calculate the expected HMAC for each secret you currently trust.
  4. 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.
  5. Deduplicate by X-Idempotency-Key if 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 by webhook-id (the delivery id) if you want to record every attempt.
  6. Return 2xx quickly 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} exposes signing_secret_rotated_at and signing_secret_previous_expires_at so 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-Key returns 410 instead of the secret.

Hardening checklist​

  • Serve the endpoint over HTTPS with a valid certificate; https:// is required outside development.
  • Verify the signature on the raw body before any parsing or framework middleware touches it.
  • Reject deliveries whose X-Kobana-Environment does not match the environment your endpoint serves.
  • Optionally configure auth (basic, bearer or 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) or X-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-Signature in request logs.
  • Rotate on a schedule and immediately after any suspected leak.

Troubleshooting a mismatch​

SymptomLikely cause
Every delivery fails verificationWrong 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 gatewayThe 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 toleranceServer clock drift. Sync with NTP; do not widen the window beyond a few minutes.
Works, then stops after a rotationThe new secret was never deployed and the grace window ended. Rotate again and roll the new value out within the window.