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
POSTrequests 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
- Open Settings → Channels. In the Webhooks & API section, open Outbound webhooks (or go straight to /settings/integrations/webhooks).
- Click Add webhook.
- 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").
- Click Create webhook.
- 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:
| Event | Fired when |
|---|---|
issue.created | A new issue is created (intake, manual, or through the API). |
issue.updated | An issue is updated. This fires on every update, including a change to the title or body, not only on status changes. |
patch.ready | A patch is approved and ready to apply. |
patch.approved | An approved patch was applied; the change landed. |
patch.failed | A patch was rejected, or changes were requested. |
pr.created | A pull or merge request was opened for a patch. |
integration.error | Opening 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 formwhd_<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 asBIZ-29), kind, title, priority and status; for patch events the patch id, version, status and issue key; forpr.createdthe 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 asdeliveryId.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:
- Read the raw request body as bytes. Do not parse and re-serialise the JSON first; that changes the bytes.
- Split the header on
,and read thetandv1values. - Reject the request if
tis more than 5 minutes away from your current time. - Compute
HMAC-SHA256(secret, "<t>.<raw body>")as hex. - Compare it with
v1using 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 laterWhile 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.testdelivery to your endpoint, signed like a real one, and opens the delivery log. Thedataof 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 see | What to do |
|---|---|
| Create webhook stays greyed out | Fill 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_5xx | Your 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. |
Related
- 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.