[ 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 >=1.5.11schema version 3

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 How a failed delivery is surfaced — logged only, or as a banner on the program run. See below.
Timeout (s) delivery_timeout_seconds — how long Cheetah waits for this endpoint to answer one attempt. Default 3, range 1–300.
Retry backoff (s) retry_backoff_seconds — the pause between attempts. Default 1, range 0–30.
Max retries max_retries — retries after the first attempt, so 1 + max_retries attempts in total. Default 3, range 0–10.

The last three sit under Delivery in the dialog and are per-webhook — a slow partner endpoint can get a longer timeout without affecting anyone else's. They came in with cheetah-service v1.5.11; see retries.

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 how a failed delivery to this endpoint is surfaced. A delivery failure never changes the program run — webhook health is a separate axis from the inspection outcome, tracked in the delivery log.

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 so an operator can see the delivery didn't land. The program run still completes and the QC outcome reaches the operator. You want visibility into failures without touching the line.

A delivery failure can't fail a program run#

Earlier releases carried a FAIL_PROGRAM mode that marked the program run FAILED when a ProgramCompleted delivery failed. It was removed in cheetah-service v1.5.1 — coupling the line to a subscriber's availability caused more stoppages than it prevented. A failed delivery now only ever lands in the delivery log (and, under WARN, a banner); the program run reaches its own outcome regardless. If your system is the record-of-truth, don't gate the cell on it — reconcile through the open REST API, where the incremental-sync loop absorbs anything a delivery missed.

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 — the per-attempt timeout is 3 seconds by default, and an endpoint doing a multi-second synchronous write will time out and be retried even though it succeeded. If your receiver genuinely needs longer, raise delivery_timeout_seconds on the webhook rather than hoping; but acknowledging fast and working off the request path is the pattern that scales.

Retries and idempotency#

Cheetah delivers at least once. When an event fires, the delivery gets 1 + max_retries attempts — four by default — inside a dispatch window of about ten seconds, waiting delivery_timeout_seconds (default 3) per attempt and pausing retry_backoff_seconds (default 1) between them. The window is a service-wide cap: raising a webhook's timeout or retry count doesn't extend it, so generous per-webhook settings simply mean fewer attempts fit. If no attempt 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.

Changed in v1.5.11. Timeout, backoff, and retry count moved from service-wide configuration to per-webhook fields. Webhooks that predate the release inherit the previous timeout (3s) and now get one extra attempt (four rather than three) within the same window.

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 · ~10s window 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"],        # the PART's serial
        "inspected_by": event["kiosk_serial_number"],  # the CHEETAH UNIT's (may be None)
        "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"],
                "unit": check["unit"],       # "mm" | "in" | None — never assume mm
                "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#

▸ LAST VERIFIED Wed Sep 02 2026 00:00:00 GMT+0000 (Coordinated Universal Time) ▸▸ /content/developers/event-dispatch/webhooks.md
▸ DOCS UNDER CONSTRUCTION LAST UPDATED 2026-09-20 ▸ CHANGELOG