# 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:

```json
{
  "_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

1. Open **Organization settings** from the gear icon, click **Integrations**, find **Webhooks** and click **Add integration**.
2. Give the webhook a name and enter your URL.
3. Tick **Sign this webhook**. Keep the generated secret, or supply your own, and store it server-side as `LAUNCHDARKLY_WEBHOOK_SECRET`.
4. 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:

```json
[
  {
    "effect": "allow",
    "actions": ["createApprovalRequest"],
    "resources": ["proj/*:env/production:flag/*"]
  }
]

```

1. 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](/docs/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.

```javascript
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:

```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](/event-gateway) verifies `X-LD-Signature` with 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](/docs/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 over `accesses` and handle each `action`.
* Use `title` or `description` for human-readable messages instead of building your own from the actions.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/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 `date` with 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle LaunchDarkly webhooks reliably in minutes.