Guide to LaunchDarkly Webhooks: Features and Best Practices
LaunchDarkly webhooks tell your systems when something changes in your LaunchDarkly account: a flag is turned on or off, targeting rules change, a segment is updated, an approval is requested, or a member is invited. If you mirror flag changes into issue trackers, chat channels, audit stores or deployment tooling, webhooks let those systems hear about each change as it happens.
This guide covers how LaunchDarkly webhooks are created, signed and filtered, the change history payload you receive, the delivery limits, and the best practices for running a LaunchDarkly receiver in production.
What are LaunchDarkly webhooks?
A LaunchDarkly webhook is an organisation-level integration. When something in your account changes, LaunchDarkly sends an HTTP POST to the webhook's URL with a copy of the change history entry for that change. "The webhook payload format is identical to the entry format for the change history", the record you see in LaunchDarkly's change history (formerly called the audit log).
By default a webhook receives "all flag change events in the production environment". A policy, written in the same syntax as LaunchDarkly's custom roles, changes that scope: only some actions, other environments, or other resource types such as segments, projects and members.
Signing is optional. If you tick Sign this webhook (or set sign to true in the API), every request carries an X-LD-Signature header with a hex HMAC-SHA256 of the payload. If you don't, requests carry no signature at all.
LaunchDarkly webhook features
| Feature | Details |
|---|---|
| Configuration | Organization settings > Integrations > Webhooks, or POST /api/v2/webhooks |
| Default scope | All flag changes in the production environment |
| Filtering | A policy of effect, actions and resources statements |
| Signature header | X-LD-Signature, only when signing is enabled |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | The webhook payload |
| Secret | Generated by LaunchDarkly, or supplied by you, when you create or edit the webhook |
| Replay window | None. The payload's date is not checked by LaunchDarkly |
| Retries | One retry on a non-2xx response |
| Ordering | Not guaranteed. Reorder by date |
| Delivery guarantee | None. LaunchDarkly says delivery "is not guaranteed" |
| On/off switch | Webhooks can be turned off without deleting them |
LaunchDarkly publishes no source IP list for webhooks.
Common events
There is no event-type field. What happened is in accesses[].action, and what it happened to is in kind and accesses[].resource. The actions are the same names you use in custom role and webhook policies, so the full list is LaunchDarkly's role actions reference. Common flag actions include:
| Action | What changed |
|---|---|
createFlag, deleteFlag | A flag was created or deleted |
updateOn | A flag's targeting was toggled on or off |
updateRules | Custom targeting rules changed |
updateFallthrough | The default rule changed |
updateTargets | Individual context targets changed |
updateOffVariation | The variation served when the flag is off changed |
updateGlobalArchived | The flag was archived or restored |
createApprovalRequest | Someone requested approval for a change |
Segments, environments, projects and members have their own actions, such as createSegment, updateIncluded, updateName and createMember. One entry can list several actions if a single change did several things, so read every element of accesses.
Here is LaunchDarkly's example payload, for an environment being renamed:
{
"_links": {
"canonical": { "href": "/api/v2/projects/alexis/environments/test", "type": "application/json" },
"parent": { "href": "/api/v2/auditlog", "type": "application/json" },
"self": { "href": "/api/v2/auditlog/57c0a8e29969090743529965", "type": "application/json" },
"site": { "href": "/settings#/projects", "type": "text/html" }
},
"_id": "57c0a8e29969090743529965",
"date": 1472243938774,
"accesses": [
{ "action": "updateName", "resource": "proj/alexis:env/test" }
],
"kind": "environment",
"name": "Testing",
"description": "- Changed the name from ~~Test~~ to *Testing*",
"member": {
"_id": "548f6741c1efad40031b18ae",
"email": "ariel@acme.com",
"firstName": "Ariel",
"lastName": "Flores"
},
"titleVerb": "changed the name of",
"title": "[Ariel Flores](mailto:ariel@acme.com) changed the name of [Testing](https://app.launchdarkly.com/settings#/projects)",
"target": {
"name": "Testing",
"resources": ["proj/alexis:env/test"]
}
}
The member and target _links are trimmed. _id identifies the change history entry, date is milliseconds since the epoch, and title and description are ready-made Markdown summaries you can post to chat as they are.
Setting up LaunchDarkly webhooks
- Open Organization settings from the gear icon, click Integrations, find Webhooks and click Add integration.
- Give the webhook a name and enter your URL.
- Tick Sign this webhook. Keep the generated secret, or supply your own, and store it server-side as
LAUNCHDARKLY_.WEBHOOK_ SECRET - Add a policy if the default (all production flag changes) isn't what you want. For example, to receive only approval requests on production flags:
[
{
"effect": "allow",
"actions": ["createApprovalRequest"],
"resources": ["proj/*:env/production:flag/*"]
}
]
- Accept the integration terms and click Save settings.
With the REST API, POST /api/v2/webhooks requires url, sign and on. If sign is true and you omit secret, LaunchDarkly generates one for you.
For local development, the Hookdeck CLI (hookdeck listen 3000 launchdarkly --path /webhooks/launchdarkly) gives you a public HTTPS URL that forwards to your local server, with no account required. Use it as the webhook URL and toggle a flag in production to trigger a delivery.
Securing LaunchDarkly webhooks
Read X-LD-Signature, compute an HMAC-SHA256 of the raw request body with your secret as the key, encode it as hex and compare in constant time. If your webhook isn't signed there is nothing to check, so sign every webhook you rely on and make your handler refuse to run without a secret.
const crypto = require('crypto');
const express = require('express');
function verifyLaunchDarklySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/launchdarkly', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyLaunchDarklySignature(req.body, req.get('x-ld-signature'), process.env.LAUNCHDARKLY_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const entry = JSON.parse(req.body.toString('utf8'));
// Route on entry.kind and each entry.accesses[].action, then answer.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
def verify_launchdarkly_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_launchdarkly_signature(raw_body, request.headers.get("x-ld-signature"),
# os.environ["LAUNCHDARKLY_WEBHOOK_SECRET"])
To check your verifier, use the secret launchdarkly-test-secret and this exact body:
{"_id":"57c0a8e29969090743529965","date":1472243938774,"kind":"environment","accesses":[{"action":"updateName","resource":"proj/alexis:env/test"}]}
The signature should be:
3c7774900317eb9dc2f9a4d99f7136aa577d85688e90d2965fee7f1077e53a18
The payload's date is inside the signature, so your receiver can reject entries older than a window you choose. LaunchDarkly doesn't define one, and a legitimately retried delivery keeps its original date.
Make LaunchDarkly webhooks production-ready. Hookdeck Event Gateway verifies
X-LD-Signaturewith your webhook secret, accepts every delivery the first time, and durably queues each change history entry before your service sees it.
LaunchDarkly webhook limitations and pain points
One retry, then the change is lost
The Problem: A flag change made during a deploy never reaches your audit store.
Why It Happens: "If LaunchDarkly receives a non-2xx response to a webhook POST, it attempts to retry the delivery once." After that the delivery is dropped, and LaunchDarkly says integrations "should be tolerant of delivery failures".
Workarounds:
- Keep the handler to verify, store and answer, so it rarely fails.
- Reconcile periodically against the change history (audit log) API for anything you must not miss.
How Hookdeck Can Help: A Hookdeck source answers LaunchDarkly's first attempt with a 2xx and stores the entry. Your service gets it with retries on your own schedule, so one retry upstream is no longer your only chance.
Unsigned by default
The Problem: Requests arrive with no X-LD-Signature, and anyone who knows the URL could post fake changes.
Why It Happens: Signing is an opt-in checkbox, and in the API sign can be false. Webhooks created without it are unsigned.
Workarounds:
- Edit each webhook, tick Sign this webhook and store the secret.
- Reject requests without a valid signature once all your webhooks are signed.
How Hookdeck Can Help: Hookdeck's LaunchDarkly source verifies X-LD-Signature when you give it the secret. If you haven't set a secret in LaunchDarkly yet, leave verification off on the source until you do.
No event type, and several actions per entry
The Problem: Your router looks for an event name and finds none, or handles only the first action and misses the rest.
Why It Happens: The payload is a change history entry. The change is described by kind and the accesses array, which can hold more than one action.
Workarounds:
- Route on
kind, then loop overaccessesand handle eachaction. - Use
titleordescriptionfor human-readable messages instead of building your own from the actions.
How Hookdeck Can Help: Hookdeck filters can match on kind or on values inside accesses, so you can send flag changes to one destination and member changes to another.
Out of order delivery
The Problem: A "turned off" change is applied before the "turned on" change that preceded it, and your mirror of flag state is wrong.
Why It Happens: LaunchDarkly says "webhooks may not be delivered in chronological order" and recommends using date to reorder them.
Workarounds:
- Store
datewith each change and ignore entries older than the latest you have applied for that resource. - For current flag state, read the flag from the API rather than replaying actions.
How Hookdeck Can Help: Hookdeck logs every delivery with its body, so you can see the order changes arrived in and replay them once you have fixed your ordering logic.
Best practices
Verify against the raw body
Mount express.raw() on the route, read await request.body() in FastAPI, or use Buffer.from(await request.arrayBuffer()) in a Next.js route handler. Parse only after verifying.
Scope the webhook with a policy
The default covers every production flag change. If you only need approvals, or only one project, say so in the policy. It cuts the number of requests and the amount of filtering in your code.
Deduplicate on _id
Each change history entry has an _id. Store it before processing and skip entries you have seen. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
With only one retry, a slow handler is an expensive one. Verify, store and answer, then do the rest in a worker. See why to process webhooks asynchronously.
Turn webhooks off rather than deleting them
When you need to pause deliveries, use the On/Off switch on the Integrations page. The webhook keeps its URL, secret and policy.
Conclusion
LaunchDarkly webhooks deliver change history entries, signed with a hex HMAC-SHA256 in X-LD-Signature only when you enable signing. Sign every webhook, verify against the raw body, scope it with a policy, route on kind and every accesses[].action, reorder by date, and keep the handler fast, because a failed delivery is retried only once.
Hookdeck Event Gateway verifies each signature, accepts every delivery on the first attempt and holds each entry until your service has processed it, so flag changes aren't lost to a single failed request.
Get started with Hookdeck for free and handle LaunchDarkly webhooks reliably in minutes.