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

# Verifying signatures

> Confirm a delivery genuinely came from Kit and wasn't tampered with

Every delivery is signed with your endpoint's secret so you can confirm it came from Kit and the body wasn't altered in transit. Always verify before acting on a payload.

## The signature

Kit sends an `X-Kit-Signature` header with a timestamp and one or more signatures, comma-separated:

```
X-Kit-Signature: t=1753797130,v1=8f4b1c...e2
```

* `t` — Unix timestamp (seconds) of when this POST was signed.
* `v1` — HMAC-SHA256 of the string `"{t}.{raw_body}"` (the timestamp, a literal `.`, then the raw request body), keyed with your endpoint's secret and hex-encoded. During [secret rotation](#secret-rotation) the header carries one `v1` entry per valid secret.

The timestamp is part of the signed string, so an attacker can't replay an old body with a fresh timestamp.

The secret is returned in plaintext only when you [create the endpoint](/webhooks/getting-started) or [rotate the secret](#secret-rotation) — store it securely; Kit never returns it again.

## Verifying

Parse the header, rebuild the signed string from `t` and the exact bytes you received, compute the HMAC, and compare it against every `v1` entry using a constant-time comparison. Reject stale timestamps to guard against replays — 5 minutes is a sensible tolerance ([retries](/webhooks/retries) are re-signed at send time, so a legitimate late retry still carries a fresh `t`).

<CodeGroup>
  ```ruby Ruby theme={null}
  require "openssl"

  TOLERANCE = 300 # seconds

  def valid_signature?(raw_body, header, secret)
    parts = header.to_s.split(",").map(&:strip)
    timestamp = parts.find { |p| p.start_with?("t=") }&.delete_prefix("t=")
    return false if timestamp.nil? || (Time.now.to_i - timestamp.to_i).abs > TOLERANCE

    expected = OpenSSL::HMAC.hexdigest("sha256", secret, "#{timestamp}.#{raw_body}")
    parts.select { |p| p.start_with?("v1=") }.any? do |candidate|
      ActiveSupport::SecurityUtils.secure_compare(candidate.delete_prefix("v1="), expected)
    end
  end
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");

  const TOLERANCE = 300; // seconds

  function validSignature(rawBody, header, secret) {
    const parts = (header || "").split(",").map((s) => s.trim());
    const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
    if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE) {
      return false;
    }

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");
    const expectedBuf = Buffer.from(expected);
    return parts
      .filter((p) => p.startsWith("v1="))
      .some((candidate) => {
        const candidateBuf = Buffer.from(candidate.slice(3));
        return (
          candidateBuf.length === expectedBuf.length &&
          crypto.timingSafeEqual(candidateBuf, expectedBuf)
        );
      });
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  TOLERANCE = 300  # seconds

  def valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
      parts = [p.strip() for p in (header or "").split(",")]
      timestamp = next((p[2:] for p in parts if p.startswith("t=")), None)
      if timestamp is None or abs(time.time() - int(timestamp)) > TOLERANCE:
          return False

      signed = f"{timestamp}.".encode() + raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return any(
          hmac.compare_digest(p[3:], expected)
          for p in parts
          if p.startswith("v1=")
      )
  ```
</CodeGroup>

<Warning>
  Verify against the **raw request body**, byte for byte. If your framework parses JSON before you can read the raw bytes, capture the raw body first — re-serializing changes whitespace and key order and the signature won't match.
</Warning>

## Secret rotation

Rotate an endpoint's secret at any time with `POST /v4/webhook_endpoints/{id}/rotate_secret` — the response is the only place the new secret appears in plaintext. To avoid dropping deliveries while you roll it out, the old secret keeps verifying until the overlap window closes (the endpoint's `previous_secret_expires_at`). During the window Kit signs with **both** secrets, so the header carries two `v1` entries:

```
X-Kit-Signature: t=1753797130,v1=<new>,v1=<old>
```

This is why the examples above check whether **any** `v1` value matches — do that and your verification keeps working across a rotation with no code change. Once you've switched to the new secret you can close the window early with `POST /v4/webhook_endpoints/{id}/revoke_previous_secret`.

<Note>
  Rotating again while a previous rotation's window is still open returns `409` — pass `force: true` to rotate anyway, immediately expiring the older secret. A [retried](/webhooks/retries) delivery is signed with whichever secrets are valid at send time, so a late retry that lands after the window closes carries a single `v1`.
</Note>

## Idempotency

The same event can arrive more than once — a [retry](/webhooks/retries) re-POSTs the whole delivery after your server was briefly unreachable, or an upstream job retry re-emits the event in a fresh delivery. Every event carries a UUID `id` that stays the same across re-sends: record it and skip any event you've already processed. Design your handler to be safe to run twice regardless.

<Note>
  Deduplicate on the event `id`, not on `delivery_id` — a re-emitted event can arrive under a different delivery.
</Note>


## Related topics

- [Delivery format](/webhooks/delivery-format.md)
- [Rotate a webhook endpoint secret](/api-reference/webhooks/rotate-a-webhook-endpoint-secret.md)
- [Webhooks overview](/webhooks/overview.md)
- [Create a webhook endpoint](/api-reference/webhooks/create-a-webhook-endpoint.md)
- [Getting started with webhooks](/webhooks/getting-started.md)
