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:
{
"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
- In the Castle Dashboard, open the Policy or List you want notifications for.
- Go to the Integrations tab and click Add Integration. You can also start from a policy or list entry directly.
- Enter your endpoint URL and save.
- 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-Signaturewith 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
2xxstraight 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_atwith 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.