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.
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.
LAUNCHDARKLY_WEBHOOK_SECRET=replace-with-a-long-random-stringStart 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 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-appHORIZON: Tunnel connected
URL https://my-ld-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-ld-appYour public URL is https://my-ld-app.hrzn.run. Keep this terminal open.
Add the webhook in LaunchDarkly
- In LaunchDarkly, select the gear icon, then Organization settings.
- Select Integrations and search for "Webhooks".
- Select Add integration.
- Optionally, enter a Name.
- Set URL to
https://my-ld-app.hrzn.run/api/webhooks/launchdarkly. - Select Sign this webhook and enter the same secret as
LAUNCHDARKLY_WEBHOOK_SECRET. - Optionally, add a policy to filter which events LaunchDarkly sends. By default it sends all flag changes from production.
- Accept the Terms and Conditions, then select Save settings.
Without a secret, LaunchDarkly sends no X-LD-Signature header and the handler above rejects every request.
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:
POST 200 /api/webhooks/launchdarklyYour app terminal prints a line such as:
Received LaunchDarkly webhook dated 1760000000000If the line shows [401], see Troubleshooting.
Troubleshooting
The signature doesn't match
- Check that
LAUNCHDARKLY_WEBHOOK_SECRETis 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.parseandJSON.stringifyfirst, because that can change the bytes. - Restart
npm run devafter 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 forReconnected. - Check that URL ends with
/api/webhooks/launchdarkly.
Next steps
- Read LaunchDarkly's webhooks documentation.