# Guide to Requesty Webhooks: Features and Best Practices

[Requesty](https://www.requesty.ai) routes your LLM traffic across model providers through one API, with budgets, keys and usage tracking for your organisation. Its webhooks do two jobs: they warn you when spending crosses a threshold you set, for a user, a group, an API key or the organisation's balance, and they mirror a sample of your chat completion requests and responses to a URL of your choice for evaluation or logging.

## What are Requesty webhooks?

Requesty sends two kinds of webhook on behalf of your organisation:

* **Spending alerts** ([docs](https://docs.requesty.ai/features/alerts.md)) fire once each time a threshold is crossed. The organisation's alert webhook has one of three types: **JSON (Generic)**, which sends the `{id, type, data}` body this guide covers, or **Slack** or **Teams**, which are meant for those services' incoming-webhook URLs and carry no event name in the body.
* **Sample requests** ([docs](https://docs.requesty.ai/features/sample-requests)) mirror a share of your chat completion requests and responses to the `webhook_url` in each request's `requesty.sample` configuration.

Both are signed with Standard Webhooks headers under one organisation-wide secret, but only once that secret exists. Until then, deliveries are unsigned.

## Requesty webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Admin Panel > Alerts** for alerts, per request for samples |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `webhook-signature` |
| Secret | One per organisation, `whsec_...`, admins only |
| Rotation | Grace period of up to 24 hours, up to 5 active secrets |
| Body | JSON |
| Event routing | Header `x-requesty-event` |
| Response timeout | 15 seconds per attempt |
| Retries | Up to 3, exponential backoff |
| Source IPs | Not published |

## Events and payloads

Requesty documents seven events. The `X-Requesty-Event` header names the event on every delivery, which matters because sample request bodies don't include a `type`:

| Event | Triggered When | Key `data` fields |
| --- | --- | --- |
| `user.budget.exceeded_percent` | A user's monthly spend reaches a percentage of their budget | `user_id`, `user_email`, `limit`, `spend`, `percentage_exceeded`, optional `group_id`, `group_name` |
| `user.budget.exceeded_absolute` | A user's monthly spend reaches a dollar amount | `user_id`, `user_email`, `spend`, `absolute_exceeded`, optional `group_id`, `group_name` |
| `group.budget.exceeded_percent` | A group's combined spend reaches a percentage of its budget | `group_id`, `group_name`, `group_admins[]`, `limit`, `spend`, `percentage_exceeded` |
| `org.balance.below_absolute` | The organisation balance drops below a dollar amount | `org_id`, `balance_threshold`, `current_balance` |
| `apikey.budget.exceeded_percent` | An API key's monthly spend reaches a percentage of its budget | `api_key_id`, `api_key_name`, `limit`, `spend`, `percentage_exceeded` |
| `apikey.budget.exceeded_absolute` | An API key's monthly spend reaches a dollar amount | `api_key_id`, `api_key_name`, `spend`, `absolute_exceeded` |
| `sample.request` | A sampled request and its response are mirrored to your `webhook_url` | `request_id`, `request`, `response` (top level, no `data`) |

Alert numbers are strings, such as `"spend": "82.40"`, and `percentage_exceeded` is a fraction, so `"0.8"` means 80%. Parse them with a decimal type.

This is the `apikey.budget.exceeded_absolute` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/requesty/latest/apikey.budget.exceeded_absolute.json):

```json
{
  "id": "3f1c2a6e-9b7d-4e21-8c5a-2d6f0b1e7a90",
  "type": "apikey.budget.exceeded_absolute",
  "data": {
    "api_key_id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "api_key_name": "production-key",
    "spend": "50.35",
    "absolute_exceeded": "50"
  }
}

```

A sample request webhook can also answer. If your receiver responds with a JSON body `{"feedback": {...}}`, Requesty attaches the whole `feedback` object to the sampled request's log. Keys may use `a-z A-Z 0-9 - _` up to 128 characters, and values must be strings holding a float, an int64 or a string under the same rules. Requesty's `sample.request` example elides its IDs, so log a real one before relying on its shape.

## Setting up Requesty webhooks

Start with the signing secret, which needs an organisation admin:

> Create a signing secret for your Requesty organisation. Until one exists, deliveries are unsigned and fail verification.

1. Open the [Admin Panel](https://app.requesty.ai/admin-panel) and go to the **Alerts** tab.
2. Find the **Webhook signing secret** card. Requesty generates a secret the first time you save a webhook URL; otherwise click **Generate signing secret**.
3. Reveal the secret with the eye icon, copy it, and store it in `REQUESTY_WEBHOOK_SECRET`, `whsec_` prefix included.

Then set up the alert webhook. An organisation has one:

1. In the **Alerts** tab, click **Add Webhook**, choose **JSON (Generic)**, enter your URL and click **Save**.
2. Click **Add Alert** to create thresholds: User % of Budget, User Absolute Spend, Group % of Budget, Org Balance Below, API Key % of Budget or API Key Absolute Spend. You can add several per type, such as 50% and 80%.

Requesty sends no challenge when you save the URL.

Sample requests are configured per API call, in the request body:

```json
{
  "requesty": {
    "sample": { "webhook_url": "https://your-webhook-url.com/webhooks/requesty", "sample_ratio": 0.1 }
  }
}

```

To route both through Hookdeck, create a source with the **Requesty** type, add the secret, and use the source URL for the alert webhook and as `webhook_url`. During development, the [Hookdeck CLI](/docs/cli) forwards the source to a local port:

```bash
npx hookdeck-cli listen 3000 requesty --path /webhooks/requesty

```

If you need to receive deliveries before the organisation has a secret, leave verification off on the source until it does.

## Securing Requesty webhooks

Requesty's docs give the steps: remove `whsec_`, base64-decode the rest to get the key, join `{webhook-id}.{webhook-timestamp}.{body}` with periods, compute HMAC-SHA256, base64-encode, and compare with each space-separated entry in `webhook-signature`. During a rotation the header carries one entry per active secret, and the function below accepts any match:

```javascript
const crypto = require('node:crypto');

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Requesty recommends a five-minute timestamp tolerance, and its own retries are re-signed with a fresh timestamp each time. Hookdeck keeps the original `webhook-timestamp` when it retries or replays and has no tolerance of its own, so behind Hookdeck the check above leaves age out.

The receiver below stores both kinds of event, keyed by `webhook-id`, and names them from the header:

```javascript
const express = require('express');

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

// spendEvents.save(webhookId, name, body) stores the delivery and resolves once it is durable.
function createRequestyReceiver(spendEvents) {
  const app = express();
  app.post('/webhooks/requesty', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.REQUESTY_WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    const name = req.headers['x-requesty-event']; // sample bodies have no type field
    const body = parseJsonObject(req.body);
    if (!body) return res.sendStatus(400); // signed, but not a JSON object
    try {
      await spendEvents.save(req.headers['webhook-id'], name, body);
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

When Requesty posts to a Hookdeck source, a signature mismatch still gets `200`, and Hookdeck records it as failed verification without forwarding it.

> **Make Requesty webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) keeps budget alerts safe beyond Requesty's three retries and can send samples and alerts to different destinations from one URL.

## Requesty webhook limitations and pain points

### Unsigned until a secret exists

**The Problem:** Early deliveries have no signature headers and fail verification.

**Why It Happens:** Requesty only signs once the organisation has a signing secret.

**Workarounds:**

* Save a webhook URL or click **Generate signing secret** before you rely on verification.

**How Hookdeck Can Help:** You can run a Hookdeck source with verification off until the secret exists, then turn it on.

### Only three retries

**The Problem:** A budget alert sent during a short outage is lost, and each threshold fires only once.

**Why It Happens:** Requesty retries up to three times with exponential backoff and doesn't publish the intervals. Alerts fire once per threshold crossing.

**Workarounds:**

* Keep the receiver available, and check spend through Requesty's dashboard or API after an incident.

**How Hookdeck Can Help:** Hookdeck receives each alert immediately and [retries](/docs/retries) your service until it succeeds, so a once-only alert isn't lost to a short outage.

### One secret for the whole organisation

**The Problem:** Every receiver, for alerts and samples alike, has to hold the same secret.

**Why It Happens:** Requesty has one signing secret per organisation.

**Workarounds:**

* Rotate with a grace period, which can be up to 24 hours, and update every receiver before it ends. Choose **Immediately** only if the secret was exposed.

**How Hookdeck Can Help:** Verify once on a Hookdeck source and fan out to your services, so only Hookdeck needs the secret.

### Sample bodies have no event name

**The Problem:** A parser that routes on `type` fails on sample requests.

**Why It Happens:** Sample bodies are `{request_id, request, response}`; only alerts include `type`.

**Workarounds:**

* Route on the `X-Requesty-Event` header for every delivery.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can match on the header and send alerts and samples to separate destinations.

## Best practices

### Alert at several thresholds

Each threshold fires once per crossing. Set two or three, such as 50%, 80% and 100%, so one missed alert isn't your only warning.

### Treat sampled data as sensitive

Sample requests contain your users' prompts and model outputs. Store them under the same controls as production logs.

### Send feedback from sample receivers

If you score sampled responses, answer with `{"feedback": {...}}` so the score lands in Requesty's request log. Behind a Hookdeck source, Requesty receives Hookdeck's response rather than yours, so point `webhook_url` straight at a receiver that sends feedback.

### Deduplicate on `webhook-id`

It's the alert `id` or the sample `request_id`, and stays the same on retries. See [our webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) for how to use it.

## Conclusion

Requesty's webhooks report spending and mirror sampled requests, all signed with one organisation secret. Create that secret before anything else, because deliveries are unsigned until it exists.

[Hookdeck Event Gateway](https://hookdeck.com) adds dependable retries and routing to Requesty's alerts and samples. [Sign up at no charge](https://dashboard.hookdeck.com/signup).