Source: https://dinakar-pageloop-knowledge-base.docs-staging.pageloop.ai/concepts/signatures

# Signatures

# Signatures

Every delivery carries an `X-Kestrel-Signature` header. Verify it before you trust the body.

![Kestrel signs the timestamp and raw body; your endpoint recomputes the HMAC and compares in constant time](https://d2qvcb9ffq0p5e.cloudfront.net/faf0698b-4136-4317-868c-d1aef034d8f4/images/c8eeed45a7990a936418d9d16be9acb58cc7e1ff95764e3dbac3fb79f4af8406.svg)

## The header

```
X-Kestrel-Signature: t=1756032862,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

| Part | Meaning                                                     |
| ---- | ----------------------------------------------------------- |
| `t`  | Unix timestamp, in seconds, of when the request was signed. |
| `v1` | HMAC-SHA256 of the signed payload, hex-encoded, lowercase.  |

## What is signed

The signed payload is the timestamp, a literal `.`, and the raw request body:

```
signed_payload = t + "." + raw_body
```

The HMAC key is your subscription's signing secret, shown once when the subscription is
created and rotatable from the dashboard.

> [!WARNING]
>
> Sign the **raw** bytes. Parsing the JSON and re-serializing it will change whitespace or
> key order and the signature will not match. Most frameworks need an explicit raw-body
> option — `express.raw()` in Express, `request.get_data()` in Flask.

## Verifying manually

```python
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, signature = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)
```

> [!NOTE]
>
> Always compare with a constant-time function — `hmac.compare_digest` in Python,
> `crypto.timingSafeEqual` in Node. A plain `==` leaks timing information.

## Replay tolerance

Reject a request whose timestamp is more than **5 minutes** from your clock. Kestrel's
retry schedule finishes well inside that window, so a legitimate retry is never rejected
by a correct implementation.

## Rotating a secret

Rotation issues a second active secret and keeps the old one valid for **24 hours**, so
you can deploy the new one without dropping deliveries. During the overlap, verify against
both and accept either.

```js
const ok = candidates.some(secret => verifySignature({ payload, header, secret }));
```

Related: [Delivery and retries](/concepts/delivery-and-retries) ·
[Authentication](/api-reference/authentication)
