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

# Webhook Signatures

> Verify that alert and sample request webhooks were sent by Requesty using a signing secret and Standard Webhooks signatures

Requesty signs the webhooks it sends to your endpoints so you can confirm that each delivery came from Requesty and was not modified in transit. Signatures follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so any Standard Webhooks or Svix library can verify them.

> [!NOTE]
>
> **[Manage your signing secret](https://app.requesty.ai/admin-panel/alerts)** in the Requesty Console.

## What Gets Signed

Every webhook sent on behalf of your organization is signed with your organization's signing secret:

* **[Spending alerts](/features/alerts)**, for every webhook type (JSON, Slack, and Teams)
* **[Sample requests](/features/sample-requests)**, sent to the `webhook_url` in a request's `sample` configuration

Webhooks go out unsigned until your organization has a signing secret. Once a secret exists, Requesty never sends a delivery without its signature.

> [!NOTE]
>
> Slack and Microsoft Teams ignore the signature headers, so signing changes nothing for those destinations. Verification matters for your own JSON endpoints and sample request receivers.

## Getting Your Signing Secret

Only organization admins can view or rotate the signing secret.

1. Go to the [Admin Panel](https://app.requesty.ai/admin-panel) and navigate to the **Alerts** tab
2. Find the **Webhook signing secret** card
3. Click the eye icon to reveal the secret, then the copy icon to copy it

Requesty generates a secret automatically the first time you save a webhook URL. If your organization has no secret yet, click **Generate signing secret**.

The secret starts with `whsec_`. Store it like a password, for example in your secret manager or an environment variable such as `REQUESTY_WEBHOOK_SECRET`, and never commit it to source control.

## Signature Headers

Each delivery includes these headers:

| Header | Example | Description |
| - | - | - |
| `webhook-id` | `3f1c2a6e-9b7d-4e21-8c5a-2d6f0b1e7a90` | Unique message ID. For alerts this is the payload's `id`; for sample requests it is the `request_id`. It stays the same across retries. |
| `webhook-timestamp` | `1792513922` | Unix time in seconds when this delivery attempt was sent |
| `webhook-signature` | `v1,b/o9ib7jtjMnZSHWJIg6hcH1p4mgNG6i3lra/jbd5Jk=` | One or more space-separated signatures. There is more than one while a rotated secret is still in its grace period. |
| `X-Requesty-Event` | `org.balance.below_absolute` | Event type, such as an alert `type` or `sample.request` |
| `User-Agent` | `Requesty-Webhooks/1.0` | Identifies Requesty's webhook sender |

Requesty signs each attempt again, so a retry carries the same `webhook-id` with a new `webhook-timestamp` and `webhook-signature`.

## Verifying Signatures

> [!WARNING]
>
> Verify against the raw request body exactly as received. Parsing the JSON and serializing it again changes whitespace and key order, and the signature will no longer match.

### With a Library

The Standard Webhooks libraries accept the `whsec_` secret as shown in the console. They check the signature and reject timestamps more than 5 minutes from the current time.

```javascript Node.js (Express) theme={"dark"}
import express from "express";
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.REQUESTY_WEBHOOK_SECRET);
const app = express();

app.post(
  "/requesty/webhooks",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;
    try {
      event = webhook.verify(req.body, req.headers);
    } catch (err) {
      return res.status(400).send("Invalid signature");
    }

    // Handle the event, e.g. by event.type for alerts
    res.sendStatus(200);
  }
);
```

```python Python (Flask) theme={"dark"}
import os

from flask import Flask, abort, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

webhook = Webhook(os.environ["REQUESTY_WEBHOOK_SECRET"])
app = Flask(__name__)


@app.post("/requesty/webhooks")
def requesty_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        abort(400)

    # Handle the event, e.g. by event["type"] for alerts
    return "", 200
```

Install with `npm install standardwebhooks` or `pip install standardwebhooks`. Libraries for other languages are listed on [standardwebhooks.com](https://www.standardwebhooks.com/). The [Svix libraries](https://docs.svix.com/receiving/verifying-payloads/how) verify the same signatures.

### Manually

If you cannot use a library, compute the signature yourself:

1. Build the signed content by joining the `webhook-id`, the `webhook-timestamp`, and the raw body with periods: `{webhook-id}.{webhook-timestamp}.{body}`
2. Remove the `whsec_` prefix from your secret and base64-decode the rest to get the signing key
3. Compute the HMAC-SHA256 of the signed content with that key and base64-encode the result
4. Split `webhook-signature` on spaces. Each entry has the form `v1,{signature}`. Accept the delivery if any `v1` signature matches yours, using a constant-time comparison
5. Reject the delivery if `webhook-timestamp` is more than 5 minutes from the current time, to protect against replayed requests

```javascript Node.js theme={"dark"}
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyRequestyWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) {
    throw new Error("Missing signature headers");
  }

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
    throw new Error("Timestamp outside tolerance");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  const valid = signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature, "base64");
    return (
      received.length === expected.length &&
      crypto.timingSafeEqual(received, expected)
    );
  });
  if (!valid) {
    throw new Error("No matching signature");
  }

  return JSON.parse(rawBody);
}
```

```python Python theme={"dark"}
import base64
import hashlib
import hmac
import json
import time

TOLERANCE_SECONDS = 5 * 60


def verify_requesty_webhook(secret: str, headers: dict, raw_body: bytes) -> dict:
    headers = {k.lower(): v for k, v in headers.items()}
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    signatures = headers["webhook-signature"]

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        raise ValueError("Timestamp outside tolerance")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(
        hmac.new(key, signed_content, hashlib.sha256).digest()
    ).decode()

    for entry in signatures.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return json.loads(raw_body)

    raise ValueError("No matching signature")
```

### Handling Retries

Failed deliveries are retried, so your endpoint may receive the same message more than once. Use `webhook-id` to detect duplicates: store the IDs you have processed and skip any you have already handled.

## Rotating the Secret

Rotate the secret on a schedule, or immediately if it may have been exposed.

1. In the **Webhook signing secret** card, click the rotate icon
2. Under **Stop signing with the current secret**, choose when the current secret is retired:
   * **Immediately**, use this if the current secret was exposed
   * **After 1 hour**, **2 hours**, **4 hours**, **8 hours**, or **24 hours** (the default)
3. Click **Rotate secret**

The new secret takes effect for the next delivery. Until the grace period ends, every delivery carries a signature from each active secret in `webhook-signature`, so your endpoint keeps verifying with either the old or the new secret. Update your endpoint to the new secret before the grace period ends.

> [!NOTE]
>
> An organization can have at most 5 active signing secrets at once, counting the current secret and any still in their grace period. If you reach the limit, wait for one to expire or rotate with **Immediately**.

## Related Features

> **[Spending Alerts](/features/alerts)**
>
> Configure spend thresholds and the webhook that receives alert notifications.

> **[Sample Requests](/features/sample-requests)**
>
> Mirror a share of requests and responses to your webhook for analysis.


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