FixControl/Documentation

User Guide

Webhooks

Subscribe an external system to FixControl events. HMAC-signed, retried, and tenant-scoped.

Outbound webhooks push FixControl events into your own systems: a notifier, a status page, an internal dashboard. You pick the events and give a URL; FixControl signs every delivery and retries it a few times when your endpoint does not accept it.

Before you start

  • Role — you need the admin or manager role in the workspace. Other roles cannot open or change webhooks.
  • An endpoint — a URL that accepts POST requests with a JSON body. Use HTTPS in production. The URL must be reachable from the internet: FixControl refuses to deliver to addresses in private network ranges, and it does not follow redirects.

Creating a webhook

  1. Open Settings → Channels. In the Webhooks & API section, open Outbound webhooks (or go straight to /settings/integrations/webhooks).
  2. Click Add webhook.
  3. Fill in:

- Name — for your own bookkeeping, for example the system that receives it. - Endpoint URL — https://.... - Events — all events are ticked when the form opens. Untick the ones you don't need. You must keep at least one: with none selected the form cannot be saved ("Pick at least one event type").

  1. Click Create webhook.
  2. The signing secret (it starts with ws_) is shown once. Copy it into your secret store before you click I've saved it. FixControl cannot show it again.

Afterwards the webhook appears in the list with its name, URL, events (all or a count such as 3/7), the first characters of the secret, and its Enabled state.

Events

Every delivery has an event field. These are all the events that exist today:

EventFired when
issue.createdA new issue is created (intake, manual, or through the API).
issue.updatedAn issue is updated. This fires on every update, including a change to the title or body, not only on status changes.
patch.readyA patch is approved and ready to apply.
patch.approvedAn approved patch was applied; the change landed.
patch.failedA patch was rejected, or changes were requested.
pr.createdA pull or merge request was opened for a patch.
integration.errorOpening a pull or merge request for a patch failed (for example an authentication problem with the Git provider).

Because issue.updated fires on every update, an endpoint subscribed to it can receive several deliveries for one issue in a short time. Subscribe only to what you need.

Payload shape

Every delivery body has the same envelope:

{
  "event": "patch.approved",
  "deliveryId": "whd_lzx3k9q2_8f3k2j1a",
  "timestamp": 1746267262,
  "data": {
    "patchId": "ptch_01HV...",
    "workspaceId": "ws_01HU...",
    "version": 2,
    "status": "applied",
    "previousStatus": "approved",
    "decidedBy": "usr_...",
    "issueKey": "BIZ-29"
  }
}
  • event — the event name.
  • deliveryId — a unique id per delivery, in the form whd_<time>_<random>. Retries of the same delivery keep the same id, so use it to ignore duplicates.
  • timestamp — Unix seconds at the moment this attempt was sent.
  • data — the event's own fields. For issue events that is the issue key (such as BIZ-29), kind, title, priority and status; for patch events the patch id, version, status and issue key; for pr.created the pull request URL, number and branch. The exact fields differ per event, so read the ones you need and ignore anything you don't recognise.

Verifying the signature

Every delivery is signed with HMAC-SHA256 using your webhook's secret. Each request carries these headers:

  • X-FixControl-Signature: t=<unix-seconds>,v1=<hex> — the signature.
  • X-FixControl-Event — the event name.
  • X-FixControl-Delivery — the same value as deliveryId.
  • X-FixControl-Webhook — the id of the webhook this delivery belongs to (not a secret).

The signature is the hex-encoded HMAC-SHA256 of the string <t>.<raw body>: the t value from the header, a dot, then the request body exactly as received.

To verify:

  1. Read the raw request body as bytes. Do not parse and re-serialise the JSON first; that changes the bytes.
  2. Split the header on , and read the t and v1 values.
  3. Reject the request if t is more than 5 minutes away from your current time.
  4. Compute HMAC-SHA256(secret, "<t>.<raw body>") as hex.
  5. Compare it with v1 using a constant-time comparison.

Example (Node.js, Express):

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const SECRET = process.env.FIXCONTROL_WEBHOOK_SECRET; // ws_...

function verify(rawBody, header, secret) {
  if (!header) return false;
  let t = null;
  let v1 = null;
  for (const part of header.split(",")) {
    const [k, v] = part.trim().split("=", 2);
    if (k === "t") t = Number(v);
    if (k === "v1") v1 = v;
  }
  if (!Number.isFinite(t) || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody.toString("utf8")}`)
    .digest("hex");
  if (expected.length !== v1.length) return false;
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(v1, "hex"));
}

app.post("/fixcontrol-webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("X-FixControl-Signature"), SECRET)) {
    return res.status(401).end();
  }
  const delivery = JSON.parse(req.body.toString("utf8"));
  // Deduplicate on delivery.deliveryId, then handle delivery.event.
  res.status(204).end();
});

Example (Python):

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or not t.isdigit():
        return False
    if abs(int(time.time()) - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Reject every request that fails verification. Never trust a webhook based on its URL alone; the URL is not a secret.

Retries

FixControl counts a delivery as accepted when your endpoint answers with a 2xx status within 10 seconds. Anything else (another status, a redirect, a timeout, a connection error) is a failed attempt. A delivery gets 3 attempts in total:

attempt 1   immediately
attempt 2   about 1 second later
attempt 3   about 5 seconds later

While attempts remain, the delivery shows Retrying. After the third failed attempt it shows Gave up and is not tried again. There is no replay button: if you missed a delivery, use the Public API to read the current state, or send a test to check your endpoint.

Checking deliveries

Each row in the webhooks list has these actions:

  • Send test — sends a webhook.test delivery to your endpoint, signed like a real one, and opens the delivery log. The data of a test delivery has "kind": "test". A disabled webhook does not send tests; enable it first.
  • View deliveries — opens Recent deliveries for that webhook below the list: event, status (Pending, Retrying, Success, Gave up), response code, number of attempts, the error, the time and the request id (the same value as deliveryId). Use the refresh button to update it.
  • Edit — change the name, URL or events. The secret stays the same.
  • Delete — removes the webhook after a confirmation.

Pausing, deleting and replacing the secret

  • Pause without deleting — click Disable in the Enabled column. A disabled webhook receives no events until you click Enable again.
  • Delete — removes the webhook permanently. Deliveries that were still being retried end as Gave up with the error webhook_deleted. The webhook's delivery log is no longer shown in the app.
  • New secret — a secret cannot be rotated or shown again. If it leaks or gets lost, create a new webhook with the same URL and events, switch your endpoint to the new secret, and then delete the old webhook.

When it does not work

What you seeWhat to do
Create webhook stays greyed outFill in a name and URL and keep at least one event ticked.
"Could not save webhook."Check that the URL is a full http:// or https:// address.
Deliveries show Gave up with http_4xx or http_5xxYour endpoint refused or crashed. Check its logs; a 401 usually means the signature check failed (wrong secret, or the body was parsed before verifying).
Error starts with redirect_blocked_Your endpoint answered with a redirect. Use the final URL directly.
Error starts with url_blocked_The URL resolves to a private or internal address. Use an address that is reachable from the internet.
"Test delivery failed to enqueue."The webhook may be disabled. Enable it and try again.
  • Public API — manage webhooks from your own tools and read issues and patches.
  • Admin webhooks guide — inbound webhooks from Slack, GitHub and other integrations.

FAQ

My endpoint is behind authentication. How do I let FixControl through? FixControl does not send custom authentication headers (Basic, bearer). Verify the HMAC signature instead. If the request has to reach a private network, run a public proxy that verifies the signature and forwards the request inward.

Can I get deliveries for more than one workspace in one webhook? No. A webhook only receives events of the workspace it was created in.

What format is the signature in? Hex-encoded HMAC-SHA256 over <timestamp>.<raw request body>, sent as t=<unix-seconds>,v1=<hex> in one header. This is the same shape Stripe uses, so a Stripe-style verifier ports directly.

Anything unclear or wrong?Let us know →

FixControl is a trade name of FixControl B.V.