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

# Verifying signatures

> Check that an event delivery came from Ingestly and was not changed on the way.

Every [event](/api-reference/events) delivery carries an `X-Ingestly-Signature` header computed with your organization's [webhook signing key](/admin/webhook-signing-keys). Verify it before you trust the body: anyone who learns your endpoint URL can send it a request, but only Ingestly holds the key.

## The signature header

```
X-Ingestly-Signature: t=1791002210,v1=7882a23da332c4ad505f987b6c98f0501e08a9199798b6ae762d1fbc6a6b9ad5
```

| Part | Meaning |
| - | - |
| `t` | When the attempt was signed, in Unix seconds. Each retry is signed again with a new timestamp |
| `v1` | The lowercase hex HMAC-SHA256 of the signed payload, keyed with a signing key. There is one `v1` for each active key, so during [key rotation](/admin/webhook-signing-keys#rotating-your-key) the header carries several |

The signed payload is the timestamp, a period and the raw request body: `{t}.{raw body}`. The HMAC key is the whole signing key string as shown in the dashboard, `whsec_` prefix included, encoded as UTF-8. Do not Base64-decode it.

## Verifying a delivery

1. Read the raw request body bytes before any framework parses them.
2. Split the header on commas and take `t` and every `v1`.
3. Reject the request when `t` is more than 5 minutes away from your clock, so a captured request cannot be replayed later.
4. Compute the HMAC-SHA256 of `{t}.{raw body}` with your key, as lowercase hex.
5. Accept the request when it equals any `v1`, comparing in constant time.

<Warning>Verify the exact bytes you received. Parsing the JSON and serializing it again changes whitespace and key order, and the signature no longer matches.</Warning>

A request with no `X-Ingestly-Signature` header did not come from an event subscription: Ingestly never sends an event unsigned. Reject it.

## Node.js

```javascript theme={null}
const crypto = require("node:crypto");
const express = require("express");

const app = express();

function verifyIngestlySignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = signatureHeader.split(",").map((part) => part.trim());
  const timestamp = parts.find((part) => part.startsWith("t="))?.slice(2);
  const signatures = parts.filter((part) => part.startsWith("v1=")).map((part) => part.slice(3));

  if (!timestamp || !/^\d+$/.test(timestamp) || signatures.length === 0) return false;

  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();

  return signatures.some((signature) => {
    const received = Buffer.from(signature, "hex");

    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

// Express: keep the body as a Buffer for this route.
app.post("/ingestly/events", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-Ingestly-Signature") ?? "";

  if (!verifyIngestlySignature(req.body, header, process.env.INGESTLY_SIGNING_KEY)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString("utf8"));

  res.sendStatus(204);
  // Process the event after acknowledging it, and skip ids you have already handled.
});
```

## Python

```python theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)


def verify_ingestly_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    timestamp = None
    signatures = []

    for part in signature_header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)

    if timestamp is None or not (timestamp.isascii() and timestamp.isdigit()) or not signatures:
        return False

    try:
        signed_at = int(timestamp)
    except ValueError:
        return False

    if abs(time.time() - signed_at) > tolerance_seconds:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("ascii") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest().encode("ascii")

    return any(hmac.compare_digest(expected, signature.encode("utf-8")) for signature in signatures)


# Flask: request.get_data() returns the raw bytes. Django: request.body.
@app.post("/ingestly/events")
def ingestly_events():
    header = request.headers.get("X-Ingestly-Signature", "")

    if not verify_ingestly_signature(request.get_data(), header, os.environ["INGESTLY_SIGNING_KEY"]):
        return "", 401

    event = request.get_json()
    # Skip ids you have already handled, then process the event.
    return "", 204
```

## Rotating keys without downtime

While you [rotate](/admin/webhook-signing-keys#rotating-your-key) the signing key, every delivery is signed with both keys, one `v1` each. The functions above accept a request when any `v1` matches, so your endpoint keeps verifying with the old key until you deploy the new one. Complete the rotation only after the new key is live.

The same header and the same check also cover the writes an [HTTP Request](/guides/webhooks-and-callbacks#verifying-the-signature) node sends, except that a [binary body](/nodes/http-action#binary-body) is signed over its Base64 encoding.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.