[ INTEGRATION SURFACE · BETA ] ━━ API, SDKs, and event payloads may change between releases. Read the versioning policy

Webhooks

Stand up an HTTPS endpoint that receives Cheetah events and turns each one into a write against your system.

▸ BETA
▸ APPLIES TO
cheetah service >=0.0.133schema version 2

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 (the X-Cheetah-Event-Id header). The same event can arrive more than once — after a network blip, a retry, or a manual replay. Use run_id as the upsert key in your own store.
  • Don't assume ordering. Deliveries are independent. ProgramCompleted for one part may arrive before ProgramStarted for another. Order by the payload's timestamps and your own keys, not by arrival order.
FIG_004 [ DELIVERY LIFECYCLE ] AT-LEAST-ONCE · DEDUPE ON event_id © CHEETAH HTTP 2xx non-2xx · timeout · error window over retry: ≤3 attempts in ~10s DISPATCH ATTEMPT DELIVERED delivered_at set DISPATCH WINDOW ~10s · retrying FAILED manual replay only
▸ FIG_004 — DELIVERY LIFECYCLE

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#

▸▸ /content/developers/event-dispatch/webhooks.md
▸ DOCS UNDER CONSTRUCTION LAST UPDATED 2026-07-25 ▸ CHANGELOG