# Guide to Castle Webhooks: Features and Best Practices

Castle webhooks let your application act on Castle's risk decisions without waiting for a user to come back. When a policy matches a suspicious login, or an account or device is added to one of your Castle Lists, Castle can notify your backend so it can suspend the account, open a review case or alert your security team. If you use Castle, the bot detection and account-abuse prevention API, webhooks are how those decisions reach the rest of your stack.

This guide covers how Castle webhooks are attached to policies and lists, how to verify `X-Castle-Signature`, what the payloads contain, and how to run a Castle receiver safely in production.

## What are Castle webhooks?

In Castle, a webhook is an integration on a Policy or a List rather than a subscription to event types. Open the Policy or List in the Castle Dashboard, go to its **Integrations** tab and add a webhook. From then on, Castle sends a JSON `POST` to your URL whenever that policy matches, or whenever an item is added to, archived from or restored to that list.

Each body names its event in a top-level `type` field, such as `$policy.matched`. The leading `$` is part of the value.

Every request carries `X-Castle-Signature`, a base64 HMAC-SHA256 of the raw body. The key is your Castle API secret, the same secret your servers use to call Castle's API. There is no separate webhook secret.

## Castle webhook features

| Feature | Details |
| --- | --- |
| Configuration | The **Integrations** tab of a Policy or List in the Castle Dashboard |
| Subscription model | Per policy or per list |
| Event field | `type` in the body, for example `$policy.matched` |
| Signature header | `X-Castle-Signature` |
| Scheme | HMAC-SHA256, base64 digest |
| Signed content | The raw request body, nothing else |
| Secret | Your Castle API secret. Enterprise plans can choose which API secret signs webhooks |
| Replay window | None. No timestamp is sent or signed |
| Success response | Any `2xx`. `3xx` counts as a failure |
| Retries | Up to six additional attempts with exponential backoff |
| Source IPs | Production webhooks come from `34.194.74.182`, `23.20.75.190` and `34.194.26.104` |

If your endpoint uses HTTPS, Castle validates the connection, so the certificate must be valid.

## Common events

| `type` | Fires when |
| --- | --- |
| `$policy.matched` | The conditions of a policy matched |
| `$list_item.created` | A new item was added to a List. New items are active by default and match against incoming events |
| `$list_item.archived` | An item was archived and is no longer in the list, which is equivalent to removing it |
| `$list_item.unarchived` | A previously archived item is back in the list |

Castle's introduction also says webhooks can fire when a policy evaluation returns a particular action, such as challenge or deny. The event table has no separate event for that, so expect it as `$policy.matched` and read the policy's decision from `data.policy.action`.

Every body has the same envelope: `api_version` (`v1` in Castle's examples), `app_id`, `created_at`, `type` and `data`. Here is Castle's list example:

```json
{
  "api_version": "v1",
  "app_id": "598653631847139",
  "created_at": "2023-09-22T10:02:44.292Z",
  "type": "$list_item.created",
  "data": {
    "id": "42bc2f4d-64d1-4291-a77f-61c64bd410a0",
    "primary_value": "68776fee69c9c520022ea3267b5216b1",
    "secondary_value": null,
    "auto_archives_at": null,
    "author": {
      "type": "$castle_policy",
      "identifier": "25e54542-7794-48ee-aca0-e0bbe6c82247",
      "details": {
        "policy": {
          "id": "25e54542-7794-48ee-aca0-e0bbe6c82247",
          "revision_id": "",
          "name": "Webhook Test",
          "action": "allow"
        }
      }
    },
    "comment": null,
    "list": {
      "id": "a7f498f2-5788-40f0-9ebd-fdef3029ad59",
      "name": "Multi Accounting",
      "primary_field": "user.id",
      "secondary_field": null
    }
  }
}

```

A `$policy.matched` body is larger. `data.policy` holds the policy's `id`, `name` and `action`, and `data.event` holds the full Castle event that was evaluated: the user, device, IP and email details, scores for `account_abuse`, `account_takeover` and `bot`, and the SDKs involved. That nested event has its own `type` and `status`, such as `$login` and `$succeeded`. Those describe the user action Castle scored, not the webhook, so always route on the top-level `type`.

Castle shows no example for `$list_item.archived` or `$list_item.unarchived`. Expect the list item shape above, but code defensively until you have seen one.

## Setting up Castle webhooks

1. In the Castle Dashboard, open the Policy or List you want notifications for.
2. Go to the **Integrations** tab and click **Add Integration**. You can also start from a policy or list entry directly.
3. Enter your endpoint URL and save.
4. Store your Castle API secret server-side, for example as `CASTLE_API_SECRET`. On Enterprise plans, check which API secret is selected for webhook signing.

To test, Castle suggests making a policy that is easy to trigger, for example one that matches the email domain `example.com`, then sending Castle a request with `params.email` set to `test@example.com` and watching for the webhook.

## Securing Castle webhooks

Castle calls signature checking optional but highly recommended. Compute an HMAC-SHA256 of the raw request body with your API secret, base64-encode it, and compare it to `X-Castle-Signature` in constant time.

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

function verifyCastleSignature(rawBody, signatureHeader, apiSecret) {
  if (!signatureHeader || !apiSecret) return false;
  const expected = crypto.createHmac('sha256', apiSecret).update(rawBody).digest('base64');
  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/castle', express.raw({ type: '*/*' }), (req, res) => {
  const apiSecret = process.env.CASTLE_API_SECRET;
  if (!apiSecret) return res.sendStatus(500); // fail closed

  if (!verifyCastleSignature(req.body, req.get('x-castle-signature'), apiSecret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Route on event.type ($policy.matched, $list_item.created, ...). Persist, then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac
import os

def verify_castle_signature(raw_body: bytes, signature_header, api_secret: str) -> bool:
    if not signature_header or not api_secret:
        return False
    digest = hmac.new(api_secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(expected, signature_header.strip().encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_castle_signature(raw_body, request.headers.get("x-castle-signature"),
#                         os.environ["CASTLE_API_SECRET"])

```

Castle's own sample is in Ruby and uses `Base64.encode64(...).strip`, which is the same standard base64 digest without the trailing newline Ruby adds.

> **Make Castle webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies `X-Castle-Signature` with your API secret, durably queues every policy and list event, and lets you replay any delivery after Castle's six retries have run out.

## Castle webhook limitations and pain points

### The signing key is your API secret

**The Problem:** The secret that verifies webhooks is the same secret that authenticates calls to Castle's API, so every webhook receiver holds a credential that can do much more than check signatures.

**Why It Happens:** Castle has no per-webhook secret. It signs with an account API secret, and only Enterprise plans can choose which one.

**Workarounds:**

* Keep the receiver small and store the secret as you would any API credential.
* On Enterprise plans, create a separate API secret for webhook signing so it can be rotated without touching your API integration.
* Rotate the webhook receiver's copy whenever you rotate the API secret it is based on.

**How Hookdeck Can Help:** With Hookdeck verifying `X-Castle-Signature`, the API secret lives in one place. Your downstream services receive verified events and never need Castle credentials.

### Retries run out

**The Problem:** A long outage on your side means policy matches and list changes are lost.

**Why It Happens:** Castle retries a failed delivery up to six more times with exponential backoff, then gives up. It doesn't publish the intervals or a response timeout.

**Workarounds:**

* Respond with `2xx` straight after verification and process asynchronously.
* For high-value lists, reconcile periodically against Castle's API.

**How Hookdeck Can Help:** Hookdeck accepts and stores each event as it arrives, then retries against your service on a schedule you control, so a long outage on your side doesn't exhaust Castle's retries.

### No timestamp means no replay protection

**The Problem:** A captured request still verifies if it is sent again.

**Why It Happens:** Only the body is signed, and Castle sends no timestamp header. The body's `created_at` is covered by the signature, but Castle defines no tolerance for it.

**Workarounds:**

* Make handlers idempotent so a replay changes nothing.
* If you want a freshness check, compare `created_at` with the current time in your own code, with a window wide enough for Castle's retries.
* Allowlist Castle's three production IPs as an extra layer.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries of the same event, based on the fields you choose.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Castle sent. Verify first, then parse JSON.

### Route on the top-level `type`

The nested `data.event.type` (`$login` in Castle's example) is the user action. The webhook event is the top-level `type`. Acknowledge types you don't handle.

### Check the policy action

`data.policy.action` tells you what the policy decided, such as `allow`, `challenge` or `deny`. A policy built to allow trusted users fires `$policy.matched` too, so check the action before you suspend anyone.

### Deduplicate deliveries

Castle sends no delivery ID header. For policy matches, `data.event.id` identifies the evaluated event; for list events, `data.id` identifies the list item, combined with `type`. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return `2xx` once the event is stored, and let a worker handle suspensions, reviews and notifications. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Avoid redirects

Castle treats `3xx` as a failure. Register the final URL, including the right scheme and host, so nothing in front of your app redirects the request.

## Conclusion

Castle signs each webhook with a base64 HMAC-SHA256 of the raw body in `X-Castle-Signature`, keyed with your API secret. Attach webhooks to the policies and lists you care about, verify against the raw bytes, route on the top-level `type`, and check the policy's action before you act.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every `X-Castle-Signature`, and holds each policy and list event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Castle webhooks reliably in minutes.