Horizon

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/sdk

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

app/api/webhooks/castle/route.ts
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.

.env.local
CASTLE_API_SECRET=replace-with-your-api-secret

Start the app on port 3000.

npm run dev

Start 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-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

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

  1. Open the Castle dashboard and go to the Policy page or the Lists page.
  2. Open the Integrations tab.
  3. Select Add Integration and choose a webhook.
  4. Enter https://my-app.hrzn.run/api/webhooks/castle as the URL.
  5. 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:

Output
  POST    200  /api/webhooks/castle

Your 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_SECRET is the API secret of the Castle application that sends the webhook.
  • Verify the raw body from request.text(). Don't run JSON.parse and JSON.stringify first, because that can change the bytes.
  • Restart npm run dev after 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 for Reconnected.
  • Check that the webhook URL ends with /api/webhooks/castle.

Next steps

On this page