Test Castle webhooks locally
Receive Castle webhook events on localhost with a Horizon tunnel, and verify the X-Castle-Signature header with the Castle Node SDK.
Receive Castle webhooks on your laptop while you build, with a URL Castle can reach.
Before you begin
- Node.js 20 or later, which the Castle SDK requires
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A Castle account and your Castle API secret
- A Next.js App Router app
Start your app
Install the official Castle SDK.
npm install @castleio/sdkCastle signs every webhook with the X-Castle-Signature header. The value is a base64 HMAC (a keyed hash) of the raw request body, with SHA-256 and your API secret as the key. The SDK's verifyWebhookSignature checks it and throws a WebhookVerificationError when it doesn't match. Pass it the raw body, so read it with request.text() before you parse it.
import { Castle, WebhookVerificationError } from "@castleio/sdk";
export async function POST(request: Request) {
const apiSecret = process.env.CASTLE_API_SECRET;
if (!apiSecret) {
return new Response("Missing CASTLE_API_SECRET", { status: 500 });
}
const castle = new Castle({ apiSecret });
const body = await request.text();
try {
castle.verifyWebhookSignature(
body,
request.headers.get("x-castle-signature") ?? undefined,
);
} catch (error) {
if (error instanceof WebhookVerificationError) {
return new Response("Invalid signature", { status: 400 });
}
throw error;
}
const event = JSON.parse(body);
console.log("Received Castle webhook:", event);
return new Response("ok", { status: 200 });
}Store the API secret in an environment variable.
CASTLE_API_SECRET=replace-with-your-api-secretStart the app on port 3000.
npm run devStart a tunnel
Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your Castle webhook would point at a dead URL after a restart. Reserve it first on the Subdomains page. Reserved subdomains are a paid feature, see Pricing.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open. Castle checks the certificate of an HTTPS endpoint, and every Horizon tunnel serves HTTPS.
Add the webhook in Castle
Castle sends a webhook when a policy or a list matches. You set it up on the page of that policy or list.
- Open the Castle dashboard and go to the Policy page or the Lists page.
- Open the Integrations tab.
- Select Add Integration and choose a webhook.
- Enter
https://my-app.hrzn.run/api/webhooks/castleas the URL. - Save it.
Trigger an event
Castle documents no test button for webhooks. To get a delivery, cause the condition you attached the webhook to. Castle's own testing tip is a policy that fires when it sees an email domain such as example.com, so send a request to Castle that matches your policy.
Castle documents no resend tool. Cause the condition again for a new delivery.
Check it works
When the condition matches, your Horizon terminal prints one line:
POST 200 /api/webhooks/castleYour app terminal prints Received Castle webhook: followed by the JSON payload. If the line shows [400], see Troubleshooting.
Troubleshooting
The handler returns 400
- Check that
CASTLE_API_SECRETis the API secret of the Castle application that sends the webhook. - Verify the raw body from
request.text(). Don't runJSON.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter you edit.env.local.
No webhook arrives
Castle sends a webhook only when the condition of your policy or list matches. Check the condition first. Then check the Horizon request log to see whether a request reached the tunnel.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
- Check that the Horizon terminal is still running. If its last line is
Connection lost. Reconnecting…, wait forReconnected. - Check that the webhook URL ends with
/api/webhooks/castle.
Next steps
- Read Castle's webhooks documentation.
- Read the Castle Node SDK for the other calls.