Horizon

Test Signal Sciences webhooks locally

Receive Fastly Next-Gen WAF (Signal Sciences) webhook notifications on localhost with a Horizon tunnel, and verify X-SigSci-Signature.

Receive Fastly Next-Gen WAF notifications on your laptop while you build, with a URL the WAF can reach. Next-Gen WAF is the product formerly sold as Signal Sciences. Fastly calls the integration "generic webhooks".

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Fastly account with Next-Gen WAF and a workspace
  • A Next.js App Router app

Start your app

Every notification carries an X-SigSci-Signature header. It holds the HMAC-SHA256 (a keyed hash) hex digest of the JSON payload body. The key is the signing key generated when you create the webhook. Fastly doesn't ship a Node helper, so the handler computes the digest with node:crypto and compares in constant time.

app/api/webhooks/signalsciences/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const signingKey = process.env.SIGSCI_SIGNING_KEY;
  if (!signingKey) {
    return new Response("Missing SIGSCI_SIGNING_KEY", { status: 500 });
  }

  const body = await request.text();
  const received = request.headers.get("x-sigsci-signature") ?? "";
  const expected = createHmac("sha256", signingKey).update(body).digest("hex");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const notification = JSON.parse(body);
  console.log(`Received Next-Gen WAF notification: ${notification.type}`);

  return new Response("ok", { status: 200 });
}

Notifications are JSON with created, type, payload, link and workspaceId fields.

Store the signing key in an environment variable. You copy it from Fastly in a later step.

.env.local
SIGSCI_SIGNING_KEY=replace-with-your-signing-key

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

Add the webhook in Fastly

  1. Log in to the Fastly control panel and go to Security, Next-Gen WAF, Workspaces.
  2. Select the gear next to the workspace.
  3. Select Alerts, then Add alert.
  4. From the Integration type menu, select Webhook.
  5. In the Webhook URL field, enter https://my-app.hrzn.run/api/webhooks/signalsciences.
  6. In the Select activities menu, leave Flagged IPs checked or pick the activities you want.
  7. Select Add workspace alert.

If you use the Next-Gen WAF control panel instead, add a Generic Webhook site integration from Site Integrations.

Copy the signing key

Open the alert again. Select Reveal to see the full value of the signing key, and copy it into SIGSCI_SIGNING_KEY. Restart npm run dev.

The Rotate button creates a new key. You don't need to select Update workspace alert to save a rotated key. Update SIGSCI_SIGNING_KEY after you rotate.

Trigger a notification

Fastly documents no test button and no resend for generic webhooks. A notification fires when the WAF records one of the activities you selected, for example a flagged IP.

To check your handler without waiting, send a signed request yourself. This uses the openssl command from Fastly's guide, and the request goes through the tunnel like Fastly's would.

BODY='{"type":"flag","payload":{},"workspaceId":"test"}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SIGSCI_SIGNING_KEY" | awk '{print $NF}')
curl -X POST https://my-app.hrzn.run/api/webhooks/signalsciences \
  -H "Content-Type: application/json" \
  -H "X-SigSci-Signature: $SIGNATURE" \
  -d "$BODY"

Check it works

Your Horizon terminal prints one line for each notification:

Output
  POST    200  /api/webhooks/signalsciences

Your app terminal prints Received Next-Gen WAF notification: and the type. The curl command above prints ok.

Troubleshooting

The signature doesn't match

  • Check that SIGSCI_SIGNING_KEY is the current signing key of this alert. Rotating creates a new one.
  • Compute the digest over the raw body. 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 notification arrives

Fastly sends one only when an activity you selected happens. Check the activities on the alert. 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/signalsciences.

Next steps

On this page