Webhooks
Stand up an HTTPS endpoint that receives Cheetah events and turns each one into a write against your system.
A webhook is an HTTPS endpoint you run that Cheetah POSTs events to. This page covers the three things you need to get right: registering the endpoint, responding correctly, and handling retries without double-processing a part.
New to event dispatch? Start there; this page assumes you're ready to write the receiver.
Register an endpoint#
Webhooks are registered in cheetah-ui. Open Webhooks and choose New webhook:
| Field | What it does |
|---|---|
| Enabled | A disabled webhook fires on no event. Toggle it off to pause without deleting. |
| Name | A label that shows up in the delivery log — e.g. Partner MES. |
| URL | Your endpoint. Both http:// and https:// are accepted; use HTTPS for any cross-network partner. |
| Subscribed events | One or more of ProgramStarted, ProgramCompleted, ProgramFailed, ProgramCancelled. At least one is required. |
| On-failure mode | What a failed delivery does to the program run. See below. |
Subscribe only to the events you act on. A webhook that just records inspection results needs ProgramCompleted and nothing else.
Choose a failure mode#
on_failure_mode decides what happens to a program run when a delivery to this endpoint fails.
| Mode | Effect when delivery fails | Use when |
|---|---|---|
IGNORE |
Best effort. The failure lives only in the delivery log. The program run is unaffected. | The subscriber is a nice-to-have — a notifier, a dashboard. |
WARN (default) |
A banner appears on the program run detail. The program run still completes and the QC outcome reaches the operator. | You want visibility into failures without blocking the line. |
FAIL_PROGRAM |
ProgramCompleted only. The program run is marked FAILED and the QC outcome is suppressed from the operator. |
The subscriber is your record-of-truth — MES integration, compliance attestation. |
FAIL_PROGRAM couples your endpoint to the line#
In FAIL_PROGRAM mode a failed delivery becomes a failed program run — the operator does not see a pass/fail outcome until your endpoint accepts the event. No further event is emitted for the flip: the run ends FAILED with the delivery failure recorded in its error_message, so reconcile via the open REST API. That delivery is evaluated on the program run's completion path, so your endpoint must be highly available and respond within the 3-second per-attempt timeout. Reach for this mode only when a missing inspection record is genuinely worse than a halted cell. For everything else, WARN plus the delivery log and manual replay below is the right posture.
The request Cheetah sends#
Each delivery is an HTTP POST with a JSON body (the event payload) and these headers:
| Header | Example | Meaning |
|---|---|---|
X-Cheetah-Event |
ProgramCompleted |
The event name. Matches event_name in the body. |
X-Cheetah-Event-Id |
8f3a1c20-… |
The delivery's unique ID. Equals event_id in the body. Deduplicate on this. |
X-Cheetah-Timestamp |
1714291200000 |
When Cheetah sent the request, in epoch milliseconds. |
X-Cheetah-Signature |
sha256=3f1a… |
Only when the webhook has a secret: HMAC-SHA256 over {timestamp}.{body}. Verify it to authenticate the sender. |
Verify the sender#
When a webhook is registered with a secret, Cheetah signs every delivery so your receiver can prove the request came from the cell — and not from anyone who learned your endpoint's URL. The signature is sha256= plus a hex HMAC-SHA256 over {timestamp}.{body}, keyed with the webhook's secret.
Verify by recomputing the HMAC over the raw request body — before any JSON parsing, since re-serialized JSON won't byte-match what was signed — and comparing in constant time. The timestamp is inside the signed message, so a replayed request can't be re-dated; reject deliveries older than your tolerance:
import hashlib
import hmac
import time
from fastapi import FastAPI, HTTPException, Request, Response
SECRET = "your-webhook-secret" # the secret you registered the webhook with
TOLERANCE_MS = 5 * 60 * 1000
app = FastAPI()
@app.post("/cheetah/events")
async def receive(request: Request) -> Response:
body = await request.body()
timestamp = request.headers.get("x-cheetah-timestamp", "")
signature = request.headers.get("x-cheetah-signature", "")
expected = "sha256=" + hmac.new(
SECRET.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="bad signature")
if abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
raise HTTPException(status_code=401, detail="stale timestamp")
enqueue_for_processing(await request.json())
return Response(status_code=200)
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const SECRET = "your-webhook-secret"; // the secret you registered the webhook with
const TOLERANCE_MS = 5 * 60 * 1000;
const app = express();
app.post("/cheetah/events", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.header("x-cheetah-timestamp") ?? "";
const signature = req.header("x-cheetah-signature") ?? "";
const expected =
"sha256=" +
createHmac("sha256", SECRET).update(`${timestamp}.`).update(req.body).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end();
}
if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) {
return res.status(401).end();
}
enqueueForProcessing(JSON.parse(req.body.toString()));
res.status(200).end();
});
app.listen(8080);
A webhook registered without a secret sends no signature header; for anything beyond a local experiment, set one. Use an https:// URL for any receiver beyond the local segment — TLS covers the transport.
Receive the event#
Parse the body, hand the event off for processing, and return a 2xx quickly. Keep the slow work — the MES write, downstream calls — off the request path so you can acknowledge fast.
from fastapi import FastAPI, Request, Response
app = FastAPI()
@app.post("/cheetah/events")
async def receive(request: Request) -> Response:
event = await request.json()
enqueue_for_processing(event) # do the slow work off the request path
return Response(status_code=200) # ack fast
import express from "express";
const app = express();
app.post("/cheetah/events", express.json(), (req, res) => {
const event = req.body;
enqueueForProcessing(event); // do the slow work off the request path
res.status(200).end(); // ack fast
});
app.listen(8080);
Respond correctly#
Cheetah treats any 2xx as success and everything else — a 4xx, a 5xx, a timeout, or a connection error — as a failure to be retried.
Return the 2xx as soon as you've persisted the event — Cheetah's per-attempt timeout is 3 seconds, and an endpoint doing a multi-second synchronous write will time out and be retried even though it succeeded.
Retries and idempotency#
Cheetah delivers at least once. When an event fires, the delivery gets up to three attempts inside a dispatch window of about ten seconds (3-second per-attempt timeout). If none of them gets a 2xx, the delivery is marked failed and is not retried automatically — recover it with a manual replay from the delivery log. Each attempt carries the same event_id and an incrementing delivery_attempt.
Two consequences you must design for:
- Make the endpoint idempotent. Deduplicate on
event_id(theX-Cheetah-Event-Idheader). The same event can arrive more than once — after a network blip, a retry, or a manual replay. Userun_idas the upsert key in your own store. - Don't assume ordering. Deliveries are independent.
ProgramCompletedfor one part may arrive beforeProgramStartedfor another. Order by the payload's timestamps and your own keys, not by arrival order.
Inspect and replay deliveries#
Every delivery is recorded with its status — pending, delivered, or failed — along with the attempt count, the last response code, and the last error. From a webhook's delivery log in cheetah-ui you can open any delivery to see the request body and each attempt's response, and you can manually replay one that failed.
A manual replay re-sends the existing delivery immediately; it reuses the same event_id, so an idempotent endpoint that dedupes on event_id handles a replay safely.
End to end: write a result into your MES#
Putting it together — a handler for ProgramCompleted that maps the payload onto an MES inspection record, keyed on run_id so retries and replays collapse to one row:
def handle_program_completed(event: dict) -> None:
data = event["data"]
record = {
"serial_number": data["serial_number"],
"program_id": data["program_id"],
"run_id": data["run_id"], # use as the idempotency key in your MES
"result": "PASS" if data["passed"] else "FAIL",
"inspected_at": data["completed_at"],
"measurements": [
{
"name": check["name"],
"value": check["value"],
"limit": check["threshold"],
"status": check["status"], # "pass" | "fail"
}
for check in data["qc_checks"]
],
}
mes.upsert_inspection(record, key=record["run_id"])
Every field used here is documented in the event reference.
Manage endpoints programmatically#
Most teams register webhooks in cheetah-ui. A REST surface exists for scripting it — create, list, update, and delete webhooks, send a test event, and read delivery history — under /webhooks and /webhook-deliveries. See the API reference.
Next#
- Event reference — every field of every event payload.
- Program lifecycle events — what each event means in the life of a program run.
- Versioning policy — how
schema_versionevolves and what we guarantee during beta.