Caps

Webhook integrations

The outbound webhook cap action - signed cap.fired, cap.restored and cap.dry_run events you can relay to feature flags, other clouds or runbook automation.

Not every way to stop spend lives in Cloudflare. The Call your webhook action lets a cap flip a feature flag, scale down a service on another cloud, open an incident or run a runbook. SignalFlare POSTs a signed event to your HTTPS endpoint; your endpoint makes the change.

Warning. SignalFlare cannot see or undo what your endpoint changes. It only records whether the endpoint answered with a 2xx status. Your endpoint is responsible for reversing its change when it receives cap.restored.

The action needs no Cloudflare write token. Like every action it is born as a dry run, and arming it follows the same plan rules as other caps.

Events

EventSent when
cap.dry_runYou press Test, or a dry-run action reaches 100%
cap.firedAn armed action reaches 100%, or you run a fire drill (drill: true)
cap.restoredYou press Restore, or a fire drill auto-restores

Payload

{
  "event": "cap.fired",
  "estimate": true,
  "note": "Early warning, not your invoice: spend is estimated from sampled Cloudflare analytics.",
  "trigger": "breach",
  "drill": false,
  "action": { "id": "8f0c…", "name": "Turn off AI search", "type": "outbound_webhook" },
  "budget": {
    "id": "2b91…",
    "name": "Monthly cap",
    "budgetCents": 5000,
    "spendCents": 5012.4,
    "projectedCents": 9100
  },
  "account": { "id": "c41d…", "name": "production", "accountTag": "023e…", "environment": "production" },
  "sentAt": 1760550120
}

Money is in cents and may carry fractional cents. spendCents and projectedCents are estimates and can be null before the first reading of the month. trigger is breach, manual_test, drill or restore.

Headers:

  • Content-Type: application/json
  • X-SignalFlare-Event: the event name
  • X-SignalFlare-Signature: hex HMAC-SHA256 of the raw request body with your signing secret, the same scheme as webhook alert channels

Delivery rules

  • HTTPS only, to a public hostname. Private, loopback and link-local IP addresses and hostnames such as localhost, *.internal and *.local are refused.
  • No redirects. A 3xx answer counts as a failure.
  • 10 second timeout.
  • The response status and the first 1,000 characters of the body are stored with the execution, so you can see them in the execution log.
  • A failed cap.fired from a breach is retried on the next polls, up to three attempts, like any other cap.

The signing secret is encrypted at rest and never shown again after you save the action.

Verify the signature

async function verify(request: Request, secret: string): Promise<string | null> {
  const body = await request.text();
  const key = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );
  const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(body));
  const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
  const given = request.headers.get('X-SignalFlare-Signature') ?? '';
  if (given.length !== expected.length) return null;
  let diff = 0;
  for (let i = 0; i < given.length; i++) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i);
  return diff === 0 ? body : null;
}

Compute the HMAC over the raw body before parsing it, and compare in constant time.

Example: relay to a feature-flag service

Most feature-flag services (LaunchDarkly, Flagsmith, Unleash, Statsig and others) expose an HTTP API to turn a flag on or off. Their endpoints and auth differ, so this relay Worker keeps the vendor call in one function you fill in from your provider’s API reference.

interface Env {
  SIGNALFLARE_SECRET: string;
  FLAG_API_URL: string;
  FLAG_API_TOKEN: string;
}

async function setFlag(env: Env, enabled: boolean): Promise<Response> {
  return fetch(env.FLAG_API_URL, {
    method: 'PATCH',
    headers: { Authorization: `Bearer ${env.FLAG_API_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ enabled }),
  });
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });
    const body = await verify(request, env.SIGNALFLARE_SECRET);
    if (body === null) return new Response('bad signature', { status: 401 });

    const event = JSON.parse(body) as { event: string; drill: boolean };
    if (event.event === 'cap.dry_run') return new Response('dry run ok');

    const res = await setFlag(env, event.event === 'cap.restored');
    return new Response(res.ok ? 'flag updated' : 'flag update failed', { status: res.ok ? 200 : 502 });
  },
};

Here the flag guards an expensive feature, such as AI search: cap.fired turns it off and cap.restored turns it back on. Answering 502 when the vendor call fails makes the cap show as failed, so you are alerted that spend is not capped.

Other ideas

  • Another cloud. Scale a container service or a GPU pool to zero through its API, and back up on cap.restored.
  • DNS elsewhere. If your DNS is not on Cloudflare, switch a record at your provider.
  • Runbooks and incidents. Start a runbook in your automation tool, or open an incident with the payload as context.

Check drill if your endpoint should behave differently during a fire drill, for example by skipping the incident page.

Something unclear or missing? hello@signalflare.dev