Outbound Webhooks
Outbound webhooks push flag and configuration changes to an endpoint you control, the moment they happen, instead of you polling the API on a timer. Toggle a flag, edit a targeting rule, rotate an SDK key. Featureflip sends a signed HTTP request describing what changed, and you can trigger a deploy from it, post it to a chat channel, refresh a cache, or feed your own audit pipeline.
Every delivery is signed, retried on failure, and logged. You can see exactly what was sent, and when.
Creating a subscription
Section titled “Creating a subscription”In the dashboard
Section titled “In the dashboard”- Open Organization Settings.
- In the Webhooks card, click Add webhook.
- Enter a name and the URL to receive events. The URL must be a public
https://address. Featureflip refuses to deliver to a private network or a loopback address. - Choose which event types to receive, and optionally scope the subscription to specific projects and environments. The environment picker follows your project selection, so pick a project first to see its environments.
- Save. The signing secret is shown once, right after creation, so copy it before closing the dialog.
Only organization admins can view or manage webhooks. How many subscriptions an organization can hold depends on its plan: one on Free, three on Team and Pro, and as many as you need on Business and Enterprise.
Via the API
Section titled “Via the API”POST /api/v1/orgs/{org}/webhooks{ "name": "Deploy pipeline", "url": "https://ci.example.com/hooks/featureflip", "provider": "GenericHttp", "eventTypes": ["flag.toggled", "flag.environment_config.updated"], "projectIds": [], "environmentIds": []}Response 201 Created:
{ "id": "0199f3c1-1a2b-7c3d-9e4f-5a6b7c8d9e0f", "secret": "whsec_9k3mF2pQx8vL1nR7tY6wZ4aB0cD5eG...", "warning": "Store this secret securely — it will not be shown again."}As in the dashboard, the secret comes back only in this response, and there’s no way to read it later. If you lose it, rotate it instead (see Rotating signing secrets).
provider must be "GenericHttp". This create call, like other create calls on the public API, accepts an Idempotency-Key header. See Conventions for how it works.
With Terraform
Section titled “With Terraform”The Terraform provider (0.3.0 and later) manages subscriptions as a featureflip_webhook resource, so a new receiver goes through the same pull request as the flags it listens to:
resource "featureflip_webhook" "deploys" { name = "Deploy pipeline" url = "https://ci.example.com/hooks/featureflip" event_types = ["flag.toggled", "flag.environment_config.updated"]}The signing secret lands in the resource’s sensitive secret attribute. Like the API response, it’s the only copy. From then on it lives in Terraform state.
From an AI agent
Section titled “From an AI agent”The MCP server (@featureflip/mcp 0.1.8 and later) has four webhook tools, so Claude Code, Cursor or any other MCP client can do this work for you:
| Tool | What it does |
|---|---|
list_webhooks | Lists your subscriptions and the event types they can filter on |
list_webhook_deliveries | Shows one subscription’s delivery attempts and how each one went |
deliver_webhook | Sends a test event, or redelivers a past delivery |
manage_webhook | Creates, updates or deletes a subscription, or rotates its signing secret |
All four need an Admin token, even the two that only read. When manage_webhook creates a subscription or rotates a secret, the tool result carries the new signing secret, and that’s the only time you’ll see it.
Choosing event types and scope
Section titled “Choosing event types and scope”An empty eventTypes list (or none selected in the dashboard) means every event type. List specific ones to narrow it. The full catalog is available at any time from:
GET /api/v1/orgs/{org}/webhooks/event-typesEvent types are grouped by the entity they describe: flags (flag.created, flag.updated, flag.toggled, flag.archived, flag.restored, flag.deleted, flag.environment_config.updated), targeting rules, variations, segments, environments, projects, SDK keys, API tokens, and the organization itself (each of the latter eight as .created / .updated / .deleted, or organization.updated alone).
Project and environment filters work the same way: leave them empty for everything. There’s one difference worth knowing, though. A flag’s own lifecycle events, like flag.created, flag.updated, flag.archived, and flag.deleted, along with the equivalent events for variations, segments, and projects, aren’t scoped to a single environment, since the entity itself spans every environment in its project. An environment filter can’t narrow those. Only a project filter can. What the environment filter does narrow is the events that genuinely happen in one environment: a flag toggling, or its per-environment configuration changing. Combine a project filter with an environment filter if you want both narrowed together.
Deleting a project or environment removes it from every subscription’s filters. If that leaves a filter empty, the subscription is disabled too, because an empty filter means everything and would otherwise start sending the endpoint events it never asked for. Either way, a subscription that was watching the deleted project or environment gets the project.deleted or environment.deleted event, including one the same deletion disabled. If the deletion disabled it, that event is the last one it receives. Set new filters before re-enabling it.
Payload format
Section titled “Payload format”Every delivery’s body is a single JSON object describing one change. Here’s a flag.toggled event, sent when a flag is turned on or off in an environment:
{ "type": "flag.toggled", "occurredAt": "2026-09-23T14:32:07.412Z", "organizationId": "0199f2aa-1111-7222-8333-444455556666", "projectId": "0199f2aa-2222-7222-8333-444455556666", "environmentId": "0199f2aa-3333-7222-8333-444455556666", "actor": { "userId": "0199f2aa-4444-7222-8333-444455556666", "email": "jane@example.com", "name": "Jane Doe" }, "entity": { "type": "FeatureFlag", "id": "0199f2aa-5555-7222-8333-444455556666" }, "child": { "type": "FlagEnvironmentConfiguration", "id": "0199f2aa-6666-7222-8333-444455556666" }, "changes": [ { "property": "IsEnabled", "from": "false", "to": "true" } ], "description": "Toggled \"new-checkout\" on in Production"}A few fields worth knowing:
entityalways identifies the flag, segment, project, or other top-level object the change belongs to: the thing you’d look up through the API.childis present only when the change happened on something beneath that entity, such as a targeting rule, a variation, or, as here, a flag’s per-environment configuration. It names the specific child object that changed.changeslists each property that moved, with its previous and new value as strings. Property names match the API’s own field casing, so it’sIsEnabled, notisEnabled. They aren’t relabeled for the payload.occurredAtis when the change was made, not when the request was sent. Use it to order events. See Delivery order below.- Creating a flag with its initial variations produces one
flag.createdevent with achildrenarray listing what was created alongside it, rather than a separate event per variation.
Verifying the signature
Section titled “Verifying the signature”Every delivery carries three headers:
webhook-id: 0199f3c2-7a1b-7c2d-9e3f-4a5b6c7d8e9fwebhook-timestamp: 1758640327webhook-signature: v1,4mF2pQx8vL1nR7tY6wZ4aB0cD5eG9k3mF2pQx8vL1n=webhook-id is the event’s own id. It’s stable across every retry of the same event, which makes it the right key for deduplication (see Delivery order).
To verify, HMAC-SHA256 the string {webhook-id}.{webhook-timestamp}.{raw request body} with your signing secret, base64-encode the result, and compare it against the value after v1, in the header. Two details matter:
- The secret is
whsec_-prefixed base64. Strip the prefix, base64-decode it, and that’s your actual HMAC key. Don’t hash the prefixed string itself. - Sign the raw request body, exactly as received. Parsing it as JSON and re-serializing changes key order and whitespace, and the signature won’t match.
If you’ve rotated your secret recently, webhook-signature can carry more than one v1,... entry, space-separated, one per active secret. Accept the delivery if any of them matches.
A signature that matches tells you Featureflip sent the delivery. It doesn’t date it. Anyone who captures one signed request can resend it an hour or a month later, and it will verify every time. So read webhook-timestamp as well, and reject any delivery stamped more than five minutes before or after your server’s clock, the tolerance Standard Webhooks recommends. Featureflip re-signs every attempt with the time it goes out, including retries and redeliveries you trigger by hand. A genuine delivery always lands inside the window. If valid deliveries start failing the check, suspect clock drift and keep the server synced with NTP.
Node.js:
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
function verifyWebhook(secret, webhookId, webhookTimestamp, rawBody, signatureHeader) { // Refuse anything stamped more than five minutes from now, in either direction const timestamp = Number(webhookTimestamp); const now = Math.floor(Date.now() / 1000); if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`; const expected = crypto.createHmac('sha256', key).update(signedContent).digest();
return signatureHeader.split(' ').some((entry) => { const [version, value] = entry.split(','); if (version !== 'v1' || !value) return false; const candidate = Buffer.from(value, 'base64'); return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected); });}Python:
import base64import binasciiimport hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret, webhook_id, webhook_timestamp, raw_body, signature_header): # Refuse anything stamped more than five minutes from now, in either direction try: timestamp = int(webhook_timestamp) except (TypeError, ValueError): return False if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False
key = base64.b64decode(secret.removeprefix("whsec_")) # raw_body is the request body as bytes, exactly as received signed_content = f"{webhook_id}.{webhook_timestamp}.".encode() + raw_body expected = hmac.new(key, signed_content, hashlib.sha256).digest()
for entry in signature_header.split(" "): version, _, value = entry.partition(",") if version != "v1" or not value: continue try: candidate = base64.b64decode(value) except binascii.Error: continue # a malformed entry is a non-match, not an error if hmac.compare_digest(candidate, expected): return True return FalseRead the raw body before your framework parses it as JSON: bytes for the Python example, a string or Buffer for Node. Most frameworks expose this as a raw-body or bytes option on the route.
Retries and timeouts
Section titled “Retries and timeouts”Featureflip attempts each delivery up to 6 times: the initial attempt, then retries after 10 seconds, 1 minute, 5 minutes, 30 minutes, and 2 hours if the previous attempt didn’t succeed. If every attempt fails, the last one lands a little over two and a half hours after the first.
Each attempt has a 10-second timeout. A delivery counts as successful the moment your endpoint responds with any 2xx status code, and the response body itself is never read. Anything else counts as a failed attempt and schedules the next retry: a non-2xx status, a timeout, a connection failure.
If every attempt fails, the delivery is marked failed (shown as DeadLettered in the API). It stays in the delivery log, and you can send it again by hand. See Viewing and redelivering below.
A subscription that fails 20 delivery rounds in a row disables itself automatically. A round is one batch of attempts to your endpoint, not one delivery, so many events failing together against the same unresponsive endpoint count once toward that total, not once each. While a subscription is disabled, deliveries that come due are marked failed rather than sent. Re-enable it from the dashboard, or PUT the subscription with isEnabled: true, then redeliver whatever you still need.
Delivery order and idempotency
Section titled “Delivery order and idempotency”Deliveries go out concurrently, so arrival order isn’t guaranteed. A retried delivery can land well after events that happened later. Treat each delivery as a signal to re-read current state through the API rather than assuming it arrived in order.
If you do apply changes directly, order by the payload’s occurredAt, not by arrival time or by the webhook-timestamp header. That header is re-stamped on every retry attempt, so it doesn’t reflect when the change actually happened. Track the newest occurredAt you’ve applied per changed property, keyed by child.id when present, otherwise entity.id, plus the property name, and skip anything older. This also protects against duplicates: delivery is at-least-once, so the same event can arrive more than once, and webhook-id, stable across retries, is the field to dedupe on.
Viewing and redelivering
Section titled “Viewing and redelivering”Each subscription’s page shows its recent deliveries: status, attempt count, the last response code and error, and when it was created and completed. The same data is available from the API:
GET /api/v1/orgs/{org}/webhooks/{id}/deliveriesStatus is one of Pending (queued or retrying), Succeeded, or DeadLettered.
To resend a specific delivery, say after fixing whatever made your endpoint fail, redeliver it:
POST /api/v1/orgs/{org}/webhooks/{id}/deliveries/{deliveryId}/redeliverThis resets the delivery’s retry schedule from scratch. It’s refused with a 400 if the delivery is still pending, if the subscription is disabled, or if its filters no longer cover the delivery’s event. The deletion event described in Choosing event types and scope is the exception. You can redeliver that one even if the subscription is now disabled or no longer covers it.
To check a new subscription end to end without waiting for a real change, send a synthetic test event:
POST /api/v1/orgs/{org}/webhooks/{id}/testThis queues one flag.toggled event to that subscription alone. It’s asynchronous, so check the delivery log for the outcome, and it’s refused with a 400 on a disabled subscription.
Rotating signing secrets
Section titled “Rotating signing secrets”Rotating adds a new active secret without removing the old one:
POST /api/v1/orgs/{org}/webhooks/{id}/secretsThe response carries the new secret’s value, shown once, the same as at creation. From that point on, every delivery is signed with every active secret, and the webhook-signature header carries one entry per secret. That means your endpoint can start verifying against the new value before you’ve fully switched over: accept the delivery if any signature matches a secret you recognize.
Once your endpoint verifies with the new secret, retire the old one:
DELETE /api/v1/orgs/{org}/webhooks/{id}/secrets/{secretId}Featureflip refuses to retire a subscription’s last active secret. Rotate first, then retire. Never the other way around.
In Terraform, a featureflip_webhook_secret resource adds a secret and destroying it retires one. The Terraform provider page covers how to retire the secret a webhook was created with.
Next steps
Section titled “Next steps”- Feature Flag Audit Log — the same changes, browsable in the dashboard
- Management API Reference — every webhook endpoint, with full request and response shapes
- Authentication — create a token to manage webhooks from the API
- MCP Server — manage webhooks and read the delivery log from an AI agent
- Conventions — pagination, idempotency, and the error format shared by every endpoint
- Creating Feature Flags — the flags whose changes these webhooks report