Webhooks
Verify and process webhooks
Validate signatures, prevent replay, and handle duplicate deliveries safely.
Verify every webhook before parsing or processing its payload. Use the complete endpoint signing secret, including the whsec_ prefix, as a UTF-8 HMAC key. Do not base64-decode it.
Signature format
Kato sends:
X-Kato-Signature: t=1788454800,v1=<64-character hexadecimal digest>The digest is HMAC-SHA256 over:
timestamp + "." + raw_request_bodyThe timestamp is Unix time in seconds. The body must be the original bytes received over HTTP; parsing and re-serializing JSON changes those bytes and can invalidate the signature.
Node.js verifier
Save this as verify-kato-webhook.mjs in your server project. It uses Node's built-in crypto module.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyKatoWebhook({
rawBody,
signature,
secret,
now = Math.floor(Date.now() / 1000),
toleranceSeconds = 300,
}) {
if (!Buffer.isBuffer(rawBody) || typeof signature !== "string") return false;
if (typeof secret !== "string" || secret.length === 0) return false;
if (!Number.isFinite(now) || !Number.isFinite(toleranceSeconds)) return false;
if (toleranceSeconds < 0) return false;
const parts = signature.split(",").map((part) => part.trim());
const timestamps = parts.filter((part) => part.startsWith("t="));
const digests = parts.filter((part) => part.startsWith("v1="));
if (timestamps.length !== 1 || digests.length !== 1) return false;
const timestamp = timestamps[0].slice(2);
const digest = digests[0].slice(3);
if (!/^\d+$/.test(timestamp) || !/^[a-f0-9]{64}$/i.test(digest)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds)) return false;
if (Math.abs(now - seconds) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest();
const received = Buffer.from(digest, "hex");
return timingSafeEqual(expected, received);
}This rejects malformed signatures and timestamps more than five minutes in either direction, and compares equal-length digests in constant time. Keep your server clock synchronized.
Read the raw body first
In a server handler that receives a standard Request, collect the bytes before any JSON parser runs:
const rawBody = Buffer.from(await request.arrayBuffer());
const valid = verifyKatoWebhook({
rawBody,
signature: request.headers.get("x-kato-signature"),
secret: process.env.KATO_WEBHOOK_SECRET,
});
if (!valid) {
return new Response("Invalid signature", { status: 401 });
}
let event;
try {
event = JSON.parse(rawBody.toString("utf8"));
} catch {
return new Response("Invalid JSON", { status: 400 });
}This snippet belongs inside your request handler, after importing the verifier. Configure KATO_WEBHOOK_SECRET from the endpoint's one-time creation response. Apply an appropriate request-body size limit in your HTTP server.
If your framework consumes the request body automatically, configure this route to preserve the original body before verification. Never verify against JSON.stringify(request.body).
Accept events reliably
After verifying the signature and parsing JSON:
- Check the payload version, expected workspace, event type, and required fields.
- In persistent storage, atomically accept the event ID and enqueue its work. Use a unique constraint on
(workspaceId, id). - Return
2xxonce the event is durably queued, or when the same event was already accepted. - Process the queued job with retries and make downstream side effects idempotent too.
A durable queue or transactional outbox prevents a crash between recording an event and scheduling its work. An in-memory set loses deduplication state on restart. Recording an event as processed before its work is safely queued can lose work.
If durable acceptance fails, return a non-2xx response so Kato can retry. Finish the HTTP response within the 10-second delivery timeout.
Replays and ordering
The five-minute signature tolerance limits reuse of captured requests; it does not replace event deduplication. Kato signs delivery attempts with a current timestamp, so a replayed old event can have a valid new signature.
Use the signed payload's id as the deduplication key. Manual replay preserves that event ID but creates a new delivery ID. Automatic retries may reuse the same delivery ID.
Do not rely on arrival order. For synchronization, fetch current state where a public endpoint exists, or apply your own event-ordering policy.
Troubleshoot verification failures
| Symptom | Check |
|---|---|
| Every request fails | Correct endpoint secret, complete whsec_ prefix, and raw body bytes |
| Failures after JSON middleware | Capture bytes before parsing; do not stringify parsed JSON |
| Intermittent timestamp failures | Server clock drift, queued requests, and proxy delays |
| Replay passes verification but repeats work | Persistent deduplication by signed event ID |
| Endpoint becomes disabled | Delivery logs, timeouts, and repeated non-2xx responses |
Test valid, tampered, expired, and duplicated events before relying on a subscription. Never log the signing secret.
Something missing? Let us know.