Gareth Wilson Gareth Wilson

Guide to Castle Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe Integrations tab of a Policy or List in the Castle Dashboard
Subscription modelPer policy or per list
Event fieldtype in the body, for example $policy.matched
Signature headerX-Castle-Signature
SchemeHMAC-SHA256, base64 digest
Signed contentThe raw request body, nothing else
SecretYour Castle API secret. Enterprise plans can choose which API secret signs webhooks
Replay windowNone. No timestamp is sent or signed
Success responseAny 2xx. 3xx counts as a failure
RetriesUp to six additional attempts with exponential backoff
Source IPsProduction 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

typeFires when
$policy.matchedThe conditions of a policy matched
$list_item.createdA new item was added to a List. New items are active by default and match against incoming events
$list_item.archivedAn item was archived and is no longer in the list, which is equivalent to removing it
$list_item.unarchivedA 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:

{
  "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.

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:

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 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.

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.

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 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 for free and handle Castle webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.