Skip to content

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.

  1. Open Organization Settings.
  2. In the Webhooks card, click Add webhook.
  3. 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.
  4. 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.
  5. 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.

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.

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.

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:

ToolWhat it does
list_webhooksLists your subscriptions and the event types they can filter on
list_webhook_deliveriesShows one subscription’s delivery attempts and how each one went
deliver_webhookSends a test event, or redelivers a past delivery
manage_webhookCreates, 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.

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-types

Event 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.

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:

  • entity always identifies the flag, segment, project, or other top-level object the change belongs to: the thing you’d look up through the API. child is 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.
  • changes lists each property that moved, with its previous and new value as strings. Property names match the API’s own field casing, so it’s IsEnabled, not isEnabled. They aren’t relabeled for the payload.
  • occurredAt is 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.created event with a children array listing what was created alongside it, rather than a separate event per variation.

Every delivery carries three headers:

webhook-id: 0199f3c2-7a1b-7c2d-9e3f-4a5b6c7d8e9f
webhook-timestamp: 1758640327
webhook-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 base64
import binascii
import hashlib
import hmac
import 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 False

Read 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.

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.

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.

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}/deliveries

Status 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}/redeliver

This 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}/test

This 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 adds a new active secret without removing the old one:

POST /api/v1/orgs/{org}/webhooks/{id}/secrets

The 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.