Webhooks

Flagify webhooks deliver a signed HTTP POST to a URL of your choice every time a flag or targeting rule changes. Use them to notify Slack on releases, mirror flag state into a feature-tracking spreadsheet, kick off CI when a kill-switch flips, or anything else your stack needs.

Webhooks are scoped to a single environment within a project — each environment keeps its own list of subscribers. This means you can ship a “Slack #prod-flags” hook bound to production without it firing on staging toggles, and run a noisier “all events” hook in development without polluting your production channels. Create them from the dashboard (use the environment selector in the header to pick a target, then Configuration → Webhooks) or with the CLI.

How it works

  1. You register a webhook with a URL, a name, and an event filter.
  2. Flagify generates a signing secret and shows it to you once.
  3. When a matching event fires (e.g. a teammate toggles a flag), Flagify POSTs a JSON payload to your URL with an HMAC-SHA256 signature header.
  4. Failed deliveries are retried up to 3 times with exponential backoff (0s, 5s, 30s). Every attempt — succeeded or failed — is recorded in the delivery log.

Supported events

The v1 event vocabulary is limited to flag and targeting changes. More resource types (api keys, environments, members) will land in a follow-up.

EventScopeFires on
flag.createdproject-wideNew flag created (creates a flag_environment row in every env)
flag.updatedproject-wideFlag metadata or default value changed
flag.archivedproject-wideFlag soft-deleted from the project (the only delete the API performs today)
flag.clonedproject-wideFlag duplicated to a new key with all per-environment config copied
flag.toggledenvironmentenabled flips in a specific environment
flag.variants_setenvironmentA/B variant list replaced for a flag in one environment
flag.promotedenvironmentFlag config copied from a source environment to a target environment
targeting.rules_setenvironmentTargeting rules replaced for a flag in one environment

Project-wide events reach every webhook in the project, regardless of which environment each hook is bound to. Environment-scoped events reach only the webhooks bound to the environment where the change happened.

A webhook with an empty events list (or All events in the dashboard) receives every supported event.

Payload

Every delivery is a JSON object with this shape. The example below is the actual body sent for a flag.toggled event — copy it verbatim into your test fixtures.

{
  "id": "01KQ7WTME8M4T7E98174CF4TVR",
  "event": "flag.toggled",
  "createdAt": "2026-04-27T16:37:12.776331Z",
  "data": {
    "resource": {
      "type": "flag_environment",
      "id": "01KP6ENV6FWDAE1XT48J4N80HP"
    },
    "actor": {
      "userId": "01KP5PWVRWY3TPJZRCD493A9V3",
      "email": "[email protected]",
      "name": ""
    },
    "workspaceId": "01KP5PX7EMY8BMMK71NYGPXFZ7",
    "projectId": "01KP5PX7G4QDK8HT4QWXGG8KH5",
    "environmentId": "01KP5PX7G78TS6SYD6DSNEH825",
    "metadata": {
      "flag_key": "oauth-login-enabled",
      "environment_id": "01KP5PX7G78TS6SYD6DSNEH825",
      "environment_key": "production",
      "field": "status",
      "old_value": "inactive",
      "new_value": "active"
    }
  }
}

Top-level envelope

FieldTypeNotes
idstring (ULID)Unique per delivery — use it as your idempotency key.
eventstringEvent name, e.g. flag.toggled. Mirrors the X-Flagify-Event header.
createdAtstring (RFC 3339, UTC)Server time at fan-out, not at receiver.
data.resource.typestringThe kind of object the event acted on: flag, flag_environment, etc.
data.resource.idstring (ULID)Primary key of that object.
data.actorobject{ userId, email, name }. name may be empty when the actor never set one.
data.workspaceId / projectIdstring (ULID)Scope of the event.
data.environmentIdstring (ULID)Environment the event originated from. For project-wide events (flag.created, flag.updated, flag.archived, flag.cloned) we surface the webhook’s own environmentId, so the field is never empty.
data.metadataobjectPer-event details — see below.

metadata shapes per event

metadata keys are snake_case and vary by event. Treat any unfamiliar key as forward-compatible — receivers should ignore unknown fields rather than reject the payload.

Eventresource.typemetadata keys
flag.createdflagkey, name, type
flag.updatedflagkey, name (only fields that changed are guaranteed)
flag.archivedflagkey
flag.clonedflagkey, name, source_key
flag.toggledflag_environmentflag_key, environment_id, environment_key, field, old_value, new_value
flag.variants_setflag_environmentflag_key, environment_id, environment_key, variant_count
flag.promotedflag_environmentflag_key, source_environment_key, target_environment_key, environment_id
targeting.rules_setflag_environmentflag_key, environment_id, rule_count

Headers

Every request includes:

HeaderExampleNotes
Content-Typeapplication/jsonAlways JSON.
User-AgentFlagify-Webhook/1.0Stable across all dispatches.
X-Flagify-Eventflag.toggledConvenient for routing without parsing the body.
X-Flagify-Webhook-Id01J9...The webhook config that produced this delivery.
X-Flagify-Timestamp1745520000Unix seconds — also embedded in the signature.
X-Flagify-Signaturet=1745520000,v1=ab12...HMAC-SHA256 of <timestamp>.<body> keyed on your secret.

Verifying the signature

The reference implementation lives in our Go API; here are equivalent snippets for the languages you’re most likely to receive in.

Node.js
const crypto = require('crypto')

function verifyFlagifySignature(req, body, secret, toleranceSec = 300) {
  const header = req.headers['x-flagify-signature'] || ''
  const match = header.match(/t=(\d+),v1=([0-9a-f]+)/)
  if (!match) return false

  const [, ts, sig] = match
  // Reject old timestamps to defeat replay attacks.
  const age = Math.abs(Date.now() / 1000 - Number(ts))
  if (age > toleranceSec) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${ts}.${body}`)
    .digest('hex')

  return crypto.timingSafeEqual(
    Buffer.from(sig, 'hex'),
    Buffer.from(expected, 'hex')
  )
}
Python
import hmac
import hashlib
import re
import time

def verify_flagify_signature(headers, body: bytes, secret: str, tolerance: int = 300) -> bool:
    header = headers.get("X-Flagify-Signature", "")
    m = re.match(r"t=(\d+),v1=([0-9a-f]+)", header)
    if not m:
        return False

    ts, sig = m.groups()
    if abs(time.time() - int(ts)) > tolerance:
        return False

    payload = f"{ts}.".encode() + body
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, expected)
Go
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "regexp"
    "strconv"
    "time"
)

var sigRegex = regexp.MustCompile(`t=(\d+),v1=([0-9a-f]+)`)

func VerifyFlagifySignature(header, secret string, body []byte, tolerance time.Duration) error {
    parts := sigRegex.FindStringSubmatch(header)
    if len(parts) != 3 {
        return fmt.Errorf("malformed signature header")
    }
    ts, _ := strconv.ParseInt(parts[1], 10, 64)
    if delta := time.Since(time.Unix(ts, 0)); delta < -tolerance || delta > tolerance {
        return fmt.Errorf("timestamp outside tolerance")
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(parts[1]))
    mac.Write([]byte("."))
    mac.Write(body)
    expected := mac.Sum(nil)

    got, err := hex.DecodeString(parts[2])
    if err != nil {
        return err
    }
    if !hmac.Equal(expected, got) {
        return fmt.Errorf("signature mismatch")
    }
    return nil
}

Reliability

  • Retries: a delivery that returns a non-2xx response (or fails to connect) is retried at 5s and 30s. After three failed attempts the delivery is marked failed in the log; the webhook itself stays active and keeps receiving new events.
  • Auto-disable: a future enhancement will pause webhooks that fail consecutively for a long window. Currently you can pause a webhook manually from the dashboard or CLI.
  • Replay: every delivery in the dashboard list has a Replay button. CLI users can repost the original payload via the API. Each replay creates a fresh row in the delivery log so the history stays append-only.
  • Ordering: deliveries are not guaranteed to arrive in the order events were emitted. Each delivery includes a createdAt timestamp the receiver can use to reorder if necessary.

Managing webhooks

Dashboard

Configuration → Webhooks. The page shows total / active / paused / auto-disabled counts at a glance. Click a row to expand recent deliveries inline; View all opens the dedicated detail screen with paginated history.

CLI

flagify webhooks create \
  --environment production \
  --name "Slack #releases" \
  --url https://hooks.slack.com/services/T00/B00/xxx \
  --events flag.created,flag.toggled,flag.archived

flagify webhooks list                    # aggregate across environments
flagify webhooks list -e production      # filter to one environment
flagify webhooks deliveries wh_01...
flagify webhooks delete wh_01... --yes

--environment accepts the environment slug (development, staging, production) or its ULID. Set it once with flagify config set environment production to drop the flag from every command.

The signing secret is printed exactly once at create time. Save it on the receiver as an environment variable (e.g. FLAGIFY_WEBHOOK_SECRET) — Flagify can not retrieve it later.

Best practices

  • Verify the signature on every request. Reject anything that fails verification with 401 Unauthorized.
  • Reject old timestamps. A 5-minute tolerance window stops replay attacks where someone captures an old payload and re-sends it.
  • Respond fast. Acknowledge with a 2xx as quickly as possible (< 2s); push the actual work to a background queue. Slow receivers waste retry budget.
  • Be idempotent. A delivery may arrive twice (e.g. our retry succeeded but our 200 ack was lost). Use the delivery id field as an idempotency key on your side.
  • Filter by event. Subscribe only to the events you handle — every match adds load to both Flagify and your receiver.