> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getkato.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 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:

```http theme={null}
X-Kato-Signature: t=1788454800,v1=<64-character hexadecimal digest>
```

The digest is HMAC-SHA256 over:

```text theme={null}
timestamp + "." + raw_request_body
```

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

```js theme={null}
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:

```js theme={null}
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:

1. Check the payload version, expected workspace, event type, and required fields.
2. In persistent storage, atomically accept the event ID and enqueue its work. Use a unique constraint on `(workspaceId, id)`.
3. Return `2xx` once the event is durably queued, or when the same event was already accepted.
4. 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.


## Related topics

- [Set up webhooks](/api-reference/webhooks/setup.md)
- [Create a webhook endpoint](/api-reference/webhooks/create.md)
- [Tasks API](/api-reference/tasks/overview.md)
- [Railway](/integrations/railway.md)
- [Sync client records](/recipes/sync-client-records.md)
