Integrations

Webhooks

Get a signed HTTPS request whenever a config, its targeting rules, a segment, an environment, or a project changes, or an alert is raised or resolved. What a webhook event holds, how to verify it, every event type with a sample, and how delivery and retries work.

Introduction

A webhook is a URL you give ConfigDirector. Whenever someone changes a config, its targeting rules, a segment, an environment, or a project, or an alert is raised or resolved, ConfigDirector sends that URL an HTTPS POST describing what happened: a webhook event. Use webhooks to post flag changes to a chat channel, start a pipeline when production changes, or open an incident when an alert fires.

A webhook event carries the entity that changed, the way the Admin API returns it, together with the values it had before the change, so your receiver never has to call back to find out what happened. If you have handled Stripe webhooks, the shape will look familiar.

Webhooks belong to the organization, and only owners and admins can see and manage them. They work the same on Free and Pay as you go.

Webhook events come from changes that people make, in the dashboard or through an API token. Changes ConfigDirector makes on its own, such as a config's lifecycle state moving to settled, send none. To hear about those, subscribe to the matching alert, such as Config settled.

What a webhook event looks like

This is a targeting_rules.updated webhook event, sent when a member turned a flag on in Production for everyone except two users:

{
  "id": "01a18500…d66c",
  "data": {
    "object": {
      "id": "01a18500…b84e",
      "rules": [
        {
          "id": "f3538fdb…b8b9",
          "type": "conditional",
          "order": 0,
          "value": false,
          "target": "value",
          "valueId": "3TUVuO…",
          "conditions": [
            {
              "id": "8d6b6e0b…1ccb",
              "kind": "attribute",
              "operator": "is one of",
              "attribute": "identifier",
              "targetType": "text",
              "targetValues": [
                "user-101",
                "user-202"
              ]
            }
          ],
          "percentages": []
        }
      ],
      "environment": {
        "id": "01a184ff…ee77",
        "live": true,
        "name": "Production",
        "slug": "production",
        "color": "red"
      },
      "defaultValue": "true",
      "defaultValueId": "0Br7gC…"
    },
    "previousAttributes": {
      "rules": [],
      "defaultValue": "false",
      "defaultValueId": "3TUVuO…"
    }
  },
  "type": "targeting_rules.updated",
  "actor": {
    "member": {
      "id": "019da45e…ec57",
      "name": "Alice Example",
      "email": "alice@example.com"
    },
    "apiToken": {
      "id": "01a184fc…d78b",
      "name": "ci"
    },
    "sourceClient": "api"
  },
  "config": {
    "id": "01a18500…0339",
    "key": "checkout.one-page"
  },
  "project": {
    "id": "01a184ff…5f07",
    "name": "Storefront Web",
    "slug": "storefront"
  },
  "createdAt": "2026-10-29T00:12:50.165Z",
  "environment": {
    "id": "01a184ff…ee77",
    "live": true,
    "name": "Production",
    "slug": "production"
  },
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}
FieldWhat it holds
idThe webhook event's id. It is the same on every webhook that receives the webhook event and on every resend, and it is also sent as the webhook-id header. Use it to drop duplicates.
typeWhat happened, such as targeting_rules.updated. See Webhook event types.
createdAtWhen the change was made, or when the alert was raised or resolved.
organizationThe organization's id and name.
projectThe project's id, slug, and name. Every change has one.
environmentThe environment's id, slug, name, and whether it is live. Present on targeting rules changes, environment changes, and a segment change to one environment's override.
configThe config's id and key. Present on config and targeting rules changes.
actorWho made the change. member holds the member's id, name, and email; name and email are null if the member's account has been deleted. sourceClient is where the change came from: dashboard, api, terraform, opentofu, mcp, or cli. apiToken holds the token's id and name when the change was made with an API token. Alert and test webhook events have no actor.
alertKindThe alert's kind, on alert webhook events only.
data.objectThe entity after the change, as the Admin API returns it. On a deletion it is the entity as it was just before, and some fields, such as createdAt, updatedAt, and a segment's overrides, can be missing.
data.previousAttributesOn *.updated webhook events only: each property that changed, with its value before the change.

Build your receiver to ignore fields it does not know. New fields can appear in any webhook event without notice, while existing fields keep their names and meaning. The order of the keys means nothing either: as the samples on this page show, it is not the order of the table above.

Creating a webhook

  1. Open Webhooks at the bottom of the dashboard's navigation and click New webhook.
  2. Give it a name and the https:// URL that should receive the webhook events.
  3. Add any Custom headers your receiver expects, such as an Authorization header. See URL rules and custom headers.
  4. Narrow it down with the Projects, Environments, and Tags filters, or leave them empty to receive everything.
  5. Choose the Change events, which are all on by default, and tick the alert kinds whose Alert events you want, which are all off by default.
  6. Save. The webhook's page shows its Signing secret: copy it into your receiver's configuration.
  7. Click Send test event. A webhook.test webhook event is sent right away and shows under Deliveries, with your receiver's response.

A webhook event reaches a webhook only when every filter lets it through. An empty filter lets everything through.

FilterLets a webhook event through whenAlso lets through
Projectsit belongs to one of the projectsnothing else: alert webhook events belong to no project, so a webhook filtered to projects receives no alert webhook events
Environmentsit belongs to one of the environmentsevery webhook event that belongs to no environment: config settings, segments (except a change to one environment's override), projects, and alerts
Tagsit is about a config that has one of the tagsevery webhook event that is not about a config: segments, environments, projects, and alerts

Verifying requests

Every request is signed with the webhook's signing secret, following the Standard Webhooks specification. Check the signature before you trust a request: anyone who finds your URL can send it a POST.

Each request carries these headers:

HeaderValue
webhook-idThe webhook event's id.
webhook-timestampWhen this attempt was sent, in seconds since the Unix epoch. Every attempt, including retries and resends, gets a fresh one.
webhook-signaturev1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}. The HMAC key is the signing secret's text after whsec_, decoded from base64. While a rotated secret still signs, the header holds two signatures separated by a space.
content-typeapplication/json
user-agentConfigDirector-Webhooks/1

Your custom headers come with every request too.

Standard Webhooks publishes a verification library for each of these languages. Each language below links to its library; install the one for your receiver:

LanguageInstall
Node.js and TypeScriptnpm install standardwebhooks
Pythonpip install standardwebhooks
Gogo get github.com/standard-webhooks/standard-webhooks/libraries/go
Java and Kotlinimplementation("com.standardwebhooks:standardwebhooks:1.2.0")
C#dotnet add package StandardWebhooks.StandardWebhooks
Rubybundle add standardwebhooks
PHPcomposer require standard-webhooks/standard-webhooks
Rustcargo add standardwebhooks

On NuGet, the package named plain StandardWebhooks is not the Standard Webhooks library; install StandardWebhooks.StandardWebhooks.

Each receiver below reads the signing secret from the CONFIGDIRECTOR_WEBHOOK_SECRET environment variable, answers 400 to a request that fails verification, and 204 once it has the webhook event. Pass the secret exactly as the dashboard shows it, whsec_ included.

import express from "express";
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.CONFIGDIRECTOR_WEBHOOK_SECRET);
const app = express();

app.post("/configdirector-webhooks", express.raw({ type: "application/json" }), (req, res) => {
  let webhookEvent;
  try {
    webhookEvent = webhook.verify(req.body, req.headers);
  } catch {
    return res.sendStatus(400);
  }

  console.log(`Received ${webhookEvent.type} ${webhookEvent.id}`);
  res.sendStatus(204);
});

app.listen(8080);

Kotlin uses the Java library the same way.

A few rules keep verification working:

  • Verify the body exactly as it arrived. The signature covers the raw bytes, so a body your framework has parsed and serialized again no longer matches. Each receiver above reads the raw body for that reason.
  • Keep your server's clock right. The libraries refuse a request whose webhook-timestamp is more than five minutes away from your clock, so a request someone recorded cannot be replayed later.
  • Drop duplicates by webhook-id. The same webhook event can arrive more than once, for example when your receiver handled a request but answered too late, or when someone clicks Resend. Keep the ids you have handled for 30 days, the time a delivery stays resendable, and skip any you have seen.
  • Answer fast. Any 2xx within 10 seconds counts as delivered. Do slow work after answering, for example from a queue.

Rotating the signing secret

Click Rotate secret on the webhook's page if the secret may have leaked. The new secret starts signing right away, and the previous one keeps signing every request too for the next 24 hours, so update your receiver at any point in that window without a failed verification. The page shows when the previous secret stops. Rotating again within those 24 hours ends the oldest secret at once.

Webhook event types

TypeSent whendata.objectdata.previousAttributes can hold
config.createdA config is created. Its first targeting rules send nothing of their own.The config as the Admin API lists it, without targeting rules
config.updatedA config's settings change, or it gets a new keySamedescription, role, lifetime, type, typeOptions, variations, client, server, tags, key
config.archivedA config is archivedThe config as it was archived
config.restoredAn archived config is restoredThe config as restored
targeting_rules.updatedThe targeting rules of one environment changeThat environment's targeting rules: id, environment, rules, defaultValue, defaultValueIdrules, defaultValue, defaultValueId
segment.created, segment.updated, segment.deletedA segment is created, changed, or deletedThe segment as the Admin API returns it, without usageskey, name, kind, groups, or overrides when one environment's override changed
environment.created, environment.updated, environment.deletedAn environment is created, changed, or deletedThe environment as the Admin API returns itname, slug, color, live, deletionProtection
project.created, project.updated, project.deletedA project is created, changed, or deletedThe project as the Admin API returns it, without its environmentsname, slug, storeCodeSnippets, deletionProtection
alert.created, alert.resolvedAn alert of a kind the webhook subscribes to is raised or resolvedThe alert: id, kind, priority, facts, detectedAt, createdAt, resolvedAt, and url, its page in the dashboard
webhook.testSomeone sends a test webhook eventNone: data.sample holds a sample targeting_rules.updated webhook event

A change sends a webhook event for what the person did, not one for every entity it touched. Deleting a project sends project.deleted only, with nothing for its environments, configs, or segments, and deleting an environment sends environment.deleted only. Creating a project sends project.created and an environment.created for each of the two environments it starts with.

The samples below were sent by ConfigDirector, with ids shortened and names changed.

Configs

A change of key is a config.updated whose previousAttributes holds the old key, while the envelope's config.key and data.object.key are the new one. Changing the key and other settings in one request sends two config.updated: one for the key and one for the other settings. Deleting a tag sends a config.updated for every config that had it, with the previous tags.

{
  "id": "01a18500…2b93",
  "data": {
    "object": {
      "id": "01a18500…0339",
      "key": "checkout.new-flow",
      "role": "flag",
      "tags": [
        {
          "id": "01a18500…436d",
          "name": "checkout"
        }
      ],
      "type": "boolean",
      "state": "new",
      "client": true,
      "server": true,
      "lifetime": "temporary",
      "createdAt": "2026-10-29T00:12:27.344Z",
      "projectId": "01a184ff…5f07",
      "updatedAt": "2026-10-29T00:12:27.344Z",
      "variations": [],
      "description": "The redesigned checkout flow",
      "deprecatedKeys": []
    }
  },
  "type": "config.created",
  "actor": {
    "member": {
      "id": "019da45e…ec57",
      "name": "Alice Example",
      "email": "alice@example.com"
    },
    "apiToken": {
      "id": "01a184fc…d78b",
      "name": "ci"
    },
    "sourceClient": "api"
  },
  "config": {
    "id": "01a18500…0339",
    "key": "checkout.new-flow"
  },
  "project": {
    "id": "01a184ff…5f07",
    "name": "Storefront Web",
    "slug": "storefront"
  },
  "createdAt": "2026-10-29T00:12:27.344Z",
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}

Targeting rules

The sample at the top of this page is a targeting_rules.updated. Its data.object is shaped like one entry of targets in GET /projects/{projectId}/configs/{configKey}: each rule's value has the config's type, while defaultValue, the default targeting rule value, is a string.

Segments

A change to one environment's override of a segment is a segment.updated that names that environment and holds the previous overrides.

{
  "id": "01a18500…8fd5",
  "data": {
    "object": {
      "id": "01a18500…aca7",
      "key": "beta-testers",
      "kind": "rule-based",
      "name": "Beta testers",
      "groups": [
        [
          {
            "id": "93f47530…8c54",
            "kind": "attribute",
            "trait": "/plan",
            "operator": "equals",
            "attribute": "traits",
            "targetType": "text",
            "targetValues": [
              "beta"
            ]
          }
        ]
      ],
      "createdAt": "2026-10-29T00:13:16.529Z",
      "overrides": {},
      "projectId": "01a184ff…5f07",
      "updatedAt": "2026-10-29T00:13:16.529Z"
    }
  },
  "type": "segment.created",
  "actor": {
    "member": {
      "id": "019da45e…ec57",
      "name": "Alice Example",
      "email": "alice@example.com"
    },
    "apiToken": {
      "id": "01a184fc…d78b",
      "name": "ci"
    },
    "sourceClient": "api"
  },
  "project": {
    "id": "01a184ff…5f07",
    "name": "Storefront Web",
    "slug": "storefront"
  },
  "createdAt": "2026-10-29T00:13:16.529Z",
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}

Environments

{
  "id": "01a18500…b284",
  "data": {
    "object": {
      "id": "01a18500…4e5e",
      "live": false,
      "name": "Staging",
      "slug": "staging",
      "color": "yellow",
      "deletionProtection": false,
      "createdAt": "2026-10-29T00:12:13.940Z",
      "projectId": "01a184ff…5f07",
      "updatedAt": "2026-10-29T00:12:13.940Z"
    }
  },
  "type": "environment.created",
  "actor": {
    "member": {
      "id": "019da45e…ec57",
      "name": "Alice Example",
      "email": "alice@example.com"
    },
    "apiToken": {
      "id": "01a184fc…d78b",
      "name": "ci"
    },
    "sourceClient": "api"
  },
  "project": {
    "id": "01a184ff…5f07",
    "name": "Storefront Web",
    "slug": "storefront"
  },
  "createdAt": "2026-10-29T00:12:13.940Z",
  "environment": {
    "id": "01a18500…4e5e",
    "live": false,
    "name": "Staging",
    "slug": "staging"
  },
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}

Projects

{
  "id": "01a184ff…258b",
  "data": {
    "object": {
      "id": "01a184ff…5f07",
      "name": "Storefront",
      "slug": "storefront",
      "createdAt": "2026-10-29T00:11:51.108Z",
      "updatedAt": "2026-10-29T00:11:51.108Z",
      "organizationId": "01a01632…6040",
      "storeCodeSnippets": true,
      "deletionProtection": false
    }
  },
  "type": "project.created",
  "actor": {
    "member": {
      "id": "019da45e…ec57",
      "name": "Alice Example",
      "email": "alice@example.com"
    },
    "apiToken": {
      "id": "01a184fc…d78b",
      "name": "ci"
    },
    "sourceClient": "api"
  },
  "project": {
    "id": "01a184ff…5f07",
    "name": "Storefront",
    "slug": "storefront"
  },
  "createdAt": "2026-10-29T00:11:51.108Z",
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}

Alerts

An alert webhook event carries no sentence about the alert. Write your own from kind and facts, which holds the alert's details and differs by kind; Alerts describes every kind. Alerts are raised by checks that run every 30 minutes or every hour, depending on the kind, so alert.created can arrive that long after the problem starts. Many alerts resolve the moment you fix their cause, for example when you create a config that was not found, and alert.resolved follows within seconds.

alert.created and alert.resolved for the same alert have different ids, because they are different webhook events. Match them by data.object.id, the alert's id.

{
  "id": "01a18512…dac1",
  "data": {
    "object": {
      "id": "01a18512…dac1",
      "url": "https://app.configdirector.com/alerts/01a18512…dac1?organization=01a01632…6040",
      "kind": "config-not-found",
      "facts": {
        "sdks": [
          {
            "name": "js-server-sdk",
            "count": 5,
            "version": "1.12.0"
          }
        ],
        "sdkType": "server",
        "sdkKeyId": "01a01632…372d",
        "configKey": "webhook-docs.missing-flag",
        "projectId": "01a01632…179b",
        "eventCount": 5,
        "lastSeenAt": "2026-10-29T00:11:23.322Z",
        "sdkKeyName": "sk_test_4e66••••66eb",
        "projectName": "Acme App",
        "quietWindow": {
          "hours": 24
        },
        "environmentId": "01a01632…7ba2",
        "countingWindow": {
          "hours": 24
        },
        "environmentName": "Test"
      },
      "priority": "high",
      "createdAt": "2026-10-29T00:30:46.735Z",
      "detectedAt": "2026-10-29T00:30:46.735Z",
      "resolvedAt": null
    }
  },
  "type": "alert.created",
  "alertKind": "config-not-found",
  "createdAt": "2026-10-29T00:30:46.735Z",
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}

Test

{
  "id": "0e168f29…66c3",
  "data": {
    "sample": {
      "id": "019a2d5e…6a7b",
      "data": {
        "object": {
          "id": "019a2d5e…9e06",
          "rules": [
            {
              "id": "019a2d5e…9e07",
              "type": "conditional",
              "order": 0,
              "value": "false",
              "target": "value",
              "valueId": "0c5nBf…",
              "conditions": [
                {
                  "id": "019a2d5e…9e08",
                  "kind": "attribute",
                  "operator": "is one of",
                  "attribute": "identifier",
                  "targetType": "text",
                  "targetValues": [
                    "user-101",
                    "user-202"
                  ]
                }
              ],
              "percentages": []
            }
          ],
          "environment": {
            "id": "019a2d5e…9e02",
            "live": true,
            "name": "Production",
            "slug": "production",
            "color": "red"
          },
          "defaultValue": "true",
          "defaultValueId": "4hR8sK…"
        },
        "previousAttributes": {
          "defaultValue": "false"
        }
      },
      "type": "targeting_rules.updated",
      "actor": {
        "member": {
          "id": "019a2d5e…9e04",
          "name": "Alice Example",
          "email": "alice@example.com"
        },
        "apiToken": {
          "id": "019a2d5e…9e05",
          "name": "ci"
        },
        "sourceClient": "terraform"
      },
      "config": {
        "id": "019a2d5e…9e03",
        "key": "checkout.new-flow"
      },
      "project": {
        "id": "019a2d5e…9e01",
        "name": "Storefront",
        "slug": "storefront"
      },
      "createdAt": "2026-10-27T14:03:12.481Z",
      "environment": {
        "id": "019a2d5e…9e02",
        "live": true,
        "name": "Production",
        "slug": "production"
      },
      "organization": {
        "id": "01a01632…6040",
        "name": "Acme"
      }
    }
  },
  "type": "webhook.test",
  "createdAt": "2026-10-29T00:11:34.020Z",
  "organization": {
    "id": "01a01632…6040",
    "name": "Acme"
  }
}

Delivery

A webhook event is normally delivered within a few seconds of the change. During maintenance on our side it can take longer, up to 30 minutes, while webhook events wait to be sent.

Each delivery is one webhook event sent to one webhook. It succeeds when your receiver answers with any 2xx status within 10 seconds. Anything else is a failed attempt: another status, a redirect (redirects are not followed), no answer within 10 seconds, a connection error, or a URL whose host now resolves to a private address. After a failed attempt the delivery is tried again after about 1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours, each up to 10% later, so six attempts over about ten and a half hours. If the last one fails too, the delivery is failed.

  • At least once. A webhook event can arrive more than once. Drop duplicates by webhook-id.
  • In no particular order. A retried delivery can arrive after newer ones, and a failing delivery never holds back the ones after it. Order webhook events by createdAt when order matters.

The webhook's page lists its Deliveries from the last 30 days, newest first, with Failures only to show just the failed ones. Each delivery shows its webhook event type, its status, the number of attempts, and the latest attempt's result, and expands to the webhook event that was sent and the first 1 KB of your receiver's response or the connection error. A delivery is pending while it waits for an attempt, succeeded, failed, or abandoned when the webhook was disabled before it could succeed.

Resend sends a delivery's webhook event again right away, with the same webhook-id and body, and a fresh timestamp and signature. If that attempt fails, the delivery is marked failed and is not tried again on its own, even if it was still waiting for a retry. A disabled webhook cannot resend or send test webhook events until it is enabled again.

When a webhook is disabled

An owner or admin can disable a webhook from its page. ConfigDirector also disables a webhook on its own when deliveries to it were attempted on each of the last three days and none of them succeeded. Then it:

  • abandons the deliveries still waiting for an attempt,
  • marks the webhook Disabled in the list, saying it was disabled after repeated failures,
  • raises the Webhook disabled after repeated failures alert, which is Critical, goes to owners and admins, and is emailed right away unless they turned that off in their email preferences.

While a webhook is disabled, nothing is queued for it, and enabling it again does not send what happened in between. Fix the receiver, then click Enable on the webhook's page; the alert resolves when the webhook is enabled again or deleted.

URL rules and custom headers

  • The URL must start with https:// and be at most 2,048 characters long.
  • Its host must resolve to a public address. A host that cannot be found, or that resolves to a private, loopback, or link-local address, is refused when you save, and the address is checked again before every attempt. To try webhooks against a receiver on your own machine, expose it through a tunnel that gives it a public https:// URL.
  • Requests do not come from a fixed set of IP addresses, so rely on the signature, not the sender's address.

A webhook can send up to 5 custom headers with every request, for example an Authorization header your gateway checks. Each name can be used once and follows the HTTP rules for header names. A value is at most 1,024 characters of printable Latin-1 text with no line breaks. The headers ConfigDirector sets itself (content-type, content-length, host, user-agent, webhook-id, webhook-signature, and webhook-timestamp) cannot be custom headers. Header values are stored encrypted and never shown again; when you edit a webhook, a value you leave blank keeps the saved one.

Limits

LimitValue
Webhooks per organization10
Custom headers per webhook5, each value up to 1,024 characters
Response time10 seconds
Delivery history30 days, after which deliveries are deleted and can no longer be resent
Webhook event size256 KB

A change webhook event larger than 256 KB is sent with data replaced by { "truncated": true, "url": "..." }, where url opens the config, segment, or project in the dashboard; read the full entity from the Admin API. Alert webhook events are always sent whole.

Managing webhooks with the Admin API

Everything on the Webhooks page is also available through the Admin API: listing, creating, updating, and deleting webhooks, rotating the secret, sending a test webhook event, listing deliveries, and resending one. The API token needs the webhooks:manage permission, which only owners and admins can grant.

To declare webhooks as code, use the configdirector_webhook resource of the Terraform provider, which also works with OpenTofu.