Horizon

Test LaunchDarkly webhooks locally

Receive LaunchDarkly webhook events on localhost with a Horizon tunnel, and verify the X-LD-Signature header.

Receive LaunchDarkly flag change events on your laptop while you build, with a URL LaunchDarkly can reach.

Horizon has no LaunchDarkly integration. LaunchDarkly sends webhooks to a public URL, and Horizon provides that URL.

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 Next.js app that uses the App Router and runs on port 3000
  • A LaunchDarkly account where you can add integrations, and a flag you can change

Start your app

LaunchDarkly signs a webhook only when you define a secret. It then sends an X-LD-Signature header. The header holds an HMAC (a keyed hash) of the webhook payload, using SHA-256 and your secret as the key, as a hex digest.

LaunchDarkly publishes no code sample and no SDK helper for this. The handler below computes the digest over the raw body and compares it with crypto.timingSafeEqual.

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

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

  const body = await request.text();
  const received = request.headers.get("x-ld-signature") ?? "";
  const expected = createHmac("sha256", secret).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 payload = JSON.parse(body);
  console.log(`Received LaunchDarkly webhook dated ${payload.date}`);

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

Pick a secret and store it in an environment variable. Use a random, high-entropy string. LaunchDarkly can also generate one for you. In that case, copy it from LaunchDarkly instead.

.env.local
LAUNCHDARKLY_WEBHOOK_SECRET=replace-with-a-long-random-string

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 LaunchDarkly webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-ld-app
Output
HORIZON: Tunnel connected
  URL          https://my-ld-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-ld-app

Your public URL is https://my-ld-app.hrzn.run. Keep this terminal open.

Add the webhook in LaunchDarkly

  1. In LaunchDarkly, select the gear icon, then Organization settings.
  2. Select Integrations and search for "Webhooks".
  3. Select Add integration.
  4. Optionally, enter a Name.
  5. Set URL to https://my-ld-app.hrzn.run/api/webhooks/launchdarkly.
  6. Select Sign this webhook and enter the same secret as LAUNCHDARKLY_WEBHOOK_SECRET.
  7. Optionally, add a policy to filter which events LaunchDarkly sends. By default it sends all flag changes from production.
  8. Accept the Terms and Conditions, then select Save settings.

Trigger an event

LaunchDarkly documents no button that sends a test event or resends a delivery. Change a flag in the production environment instead, for example turn a flag on or off. LaunchDarkly sends the change to your URL.

Check it works

In the terminal that runs hrzn, you see one line for the delivery:

Output
  POST    200  /api/webhooks/launchdarkly

Your app terminal prints a line such as:

Output
Received LaunchDarkly webhook dated 1760000000000

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that LAUNCHDARKLY_WEBHOOK_SECRET is identical to the secret you entered in LaunchDarkly, with no extra spaces or newline.
  • Compute the HMAC 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.

The event never arrives

LaunchDarkly sends all flag changes from production by default. A policy can narrow that. Check the policy on the webhook, and change a flag that it covers.

Events arrive out of order, or twice

LaunchDarkly can deliver webhooks out of order. It retries once after a non-2xx response, and doesn't guarantee delivery. Use the date field in the payload to order events.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-ld-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 URL ends with /api/webhooks/launchdarkly.

Next steps

On this page