Skip to content

Concepts

Webhooks

The events we send, how to verify a signature, what happens when your endpoint is down, and how to rotate a secret.

A webhook tells you the moment work finishes instead of you asking. We POST a small signed JSON body to an HTTPS URL you register, retry it if your server is down, and keep a log of every attempt. Verify the signature before you trust the body — that is the whole of the security model, and it is about fifteen lines.

The events

run.completed
A Run reached a terminal state. All three terminal states use this one type — `completed`, `partial` and `failed` — so you subscribe once and branch on `status`.
tracker.changed
A Tracker cycle wrote a Change Report. Quiet cycles send nothing, so every one of these is worth reading.
webhook.test
The handshake sent when you register an endpoint. Answering it with a 2xx is what activates the endpoint.

There is no subscription list: an active endpoint gets every event for your Customer. Branch on type, and ignore what you do not handle.

Registering an endpoint

Registration is also the handshake. We send webhook.test to the URL immediately and the endpoint becomes active only if your server answers 2xx. Anything else leaves it failed, and a failed endpoint receives nothing until it is deleted and registered again. So deploy your receiver first, then register.

POST /v1/webhooks·Free.

curl -X POST "https://www.shortsintel.com/v1/webhooks" \
  -H "Authorization: Bearer sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/shortsintel"}'

201 · `{ webhook, secret, test_delivery }`.

{
  "webhook": {
    "id": "whe_6d2f10ab44",
    "url": "https://hooks.example.com/shortsintel",
    "status": "active",
    "description": "production receiver",
    "secret_prefix": "whsec_a1b2c3",
    "previous_secret_expires_at": null,
    "created_at": "2026-09-08T09:14:02.418Z",
    "activated_at": "2026-09-08T09:14:03.006Z",
    "last_delivery_at": "2026-09-08T09:14:03.006Z"
  },
  "secret": "whsec_9Qk3Tn7f2xLd0aZmVbYpRc8sHu6WjEg4",
  "test_delivery": {
    "id": "whd_51c8b0f7e3",
    "event_id": "evt_kZ8Qw3n1TfLd0aZmVbYpRc",
    "event_type": "webhook.test",
    "status": "delivered",
    "attempts": 1,
    "response_status": 200,
    "error": null,
    "run_id": null,
    "tracker_id": null,
    "change_report_id": null,
    "replay_of": null,
    "created_at": "2026-09-08T09:14:02.911Z",
    "last_attempt_at": "2026-09-08T09:14:03.006Z",
    "delivered_at": "2026-09-08T09:14:03.006Z",
    "failed_at": null
  }
}

The secret in that response is shown once and never again — there is no endpoint that returns it. Store it before you close the connection. secret_prefix is all you get afterwards, and it is there to tell two secrets apart, not to verify with.

URLs must be https and must not resolve into a private range: no localhost, no 10.x, no 169.254.169.254. The check runs at registration and again immediately before every single attempt, because a hostname that was public yesterday can resolve somewhere else today. Redirects are not followed. You may hold 5 endpoints at once.

The payload is thin

An event carries identifiers and links, not results. That is deliberate: the body is the one part of the API we push at you, so it stays small enough to sign cheaply and narrow enough that it can never disclose more than you were already entitled to read. Fetch what you actually want with your own key, using the links.

{
  "id": "evt_9tR2kPq7wXsL4mVb",
  "type": "tracker.changed",
  "created_at": "2026-09-12T08:00:00.000Z",
  "run_id": "run_c07a41bb92",
  "kind": "tracker_cycle",
  "status": "completed",
  "charge_usd": 0.05,
  "tracker_id": "trk_4b1e90c7aa",
  "change_report_id": "chg_2ac910f4d8",
  "links": {
    "run": "https://www.shortsintel.com/v1/runs/run_c07a41bb92",
    "tracker": "https://www.shortsintel.com/v1/trackers/trk_4b1e90c7aa",
    "changes": "https://www.shortsintel.com/v1/trackers/trk_4b1e90c7aa/changes"
  }
}

Three headers come with it: X-ShortsIntel-Signature, X-ShortsIntel-Event-Id and X-ShortsIntel-Event-Type. The event id is stable across retries and replays, so deduplicate on it and you can treat delivery as at-least-once without treating processing as at-least-once.

Verifying a signature

The signature header carries a timestamp and one or more hex HMACs. Each v1 is HMAC-SHA256 over the string "<t>.<body>" — the unix timestamp, a literal dot, then the raw body bytes — keyed with your secret. The timestamp is inside the signed material, which is what stops a captured delivery being replayed at you later.

Raw means raw. The body we sign is the bytes on the wire, which are compact rather than pretty-printed — the block above is the same event, indented for reading. This is what the HMAC actually covers:

{"id":"evt_9tR2kPq7wXsL4mVb","type":"tracker.changed","created_at":"2026-09-12T08:00:00.000Z","run_id":"run_c07a41bb92","kind":"tracker_cycle","status":"completed","charge_usd":0.05,"tracker_id":"trk_4b1e90c7aa","change_report_id":"chg_2ac910f4d8","links":{"run":"https://www.shortsintel.com/v1/runs/run_c07a41bb92","tracker":"https://www.shortsintel.com/v1/trackers/trk_4b1e90c7aa","changes":"https://www.shortsintel.com/v1/trackers/trk_4b1e90c7aa/changes"}}
X-ShortsIntel-Signature: t=1789200000,v1=7c490a38844d774fc0bdcede0f65499631773d116bf65392c65ac2fea53109b8

That header is the real signature of those exact bytes under the secret whsec_EXAMPLE_SECRET_REPLACE_ME. Paste the three of them into either verifier below, with the clock check relaxed since the timestamp is fixed, and it returns true.

Three rules decide whether an implementation is correct. Sign the raw body, not a re-serialised object — most frameworks parse JSON before you see it, and JSON.stringify(req.body) is the classic way to get a signature that never matches. Compare in constant time. And accept any matching v1, not the first one, because during a rotation there are two.

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

const TOLERANCE_SECONDS = 300;

/** Pass the RAW request body, exactly as it arrived — not a re-serialised object. */
export function verify(header: string | null, body: string, secret: string) {
  if (!header) return false;

  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=", 2);
    if (!value) continue;
    if (key === "t") timestamp = Number(value);
    else if (key === "v1") signatures.push(value);
  }
  if (timestamp === null || !Number.isInteger(timestamp)) return false;
  if (signatures.length === 0) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = Buffer.from(
    createHmac("sha256", secret).update(`${timestamp}.${body}`, "utf8").digest("hex"),
    "hex",
  );

  // Any v1 matching is a pass: during a secret rotation there are two.
  return signatures.some((candidate) => {
    const got = Buffer.from(candidate, "hex");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(header: str | None, body: bytes, secret: str) -> bool:
    """Pass the RAW request body, exactly as it arrived."""
    if not header:
        return False

    timestamp = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if not value:
            continue
        if key == "t":
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value)

    if timestamp is None or not signatures:
        return False
    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False

    signed = f"{timestamp}.".encode() + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # Any v1 matching is a pass: during a secret rotation there are two.
    return any(hmac.compare_digest(candidate, expected) for candidate in signatures)

Reject anything older than 300 seconds. That is the tolerance we publish; a wider window buys an attacker replay room, a narrower one breaks on ordinary clock drift.

Delivery, and when your endpoint is down

Each attempt is a POST with a 10 second timeout. Only a 2xx acknowledges an event; a 3xx is not followed and anything else is a failure. Answer fast and do your work afterwards — a receiver that processes before it replies will time out and be sent the event again.

A failed attempt is retried: 3 attempts in all, the second about 10 minutes after the first and the third about 50 minutes after that, so the last one lands roughly 1 hour after the event. Every attempt is re-signed with a fresh timestamp, which is why a retry an hour late still passes a freshness check.

After the last attempt the delivery is failed and stays there. Your endpoint is not disabled by failing deliveries — events keep being attempted — but nothing is re-sent automatically. GET /v1/webhooks/{id}/deliveries is the log: what we sent, how many attempts it took, the status your server returned, and the error text if there was one.

200 · `{ items, next_cursor }`.

{
  "items": [
    {
      "id": "whd_51c8b0f81a",
      "event_id": "evt_pR4Vm8s2XqNb7cTfKdYw1E",
      "event_type": "run.completed",
      "status": "failed",
      "attempts": 5,
      "response_status": 503,
      "error": "receiver returned 503",
      "run_id": "run_8f2c1ad04b",
      "tracker_id": null,
      "change_report_id": null,
      "replay_of": null,
      "created_at": "2026-09-08T09:14:02.911Z",
      "last_attempt_at": "2026-09-08T09:14:03.006Z",
      "delivered_at": null,
      "failed_at": "2026-09-08T09:41:10.220Z"
    },
    {
      "id": "whd_51c8b0f7e3",
      "event_id": "evt_kZ8Qw3n1TfLd0aZmVbYpRc",
      "event_type": "webhook.test",
      "status": "delivered",
      "attempts": 1,
      "response_status": 200,
      "error": null,
      "run_id": null,
      "tracker_id": null,
      "change_report_id": null,
      "replay_of": null,
      "created_at": "2026-09-08T09:14:02.911Z",
      "last_attempt_at": "2026-09-08T09:14:03.006Z",
      "delivered_at": "2026-09-08T09:14:03.006Z",
      "failed_at": null
    }
  ],
  "next_cursor": null
}

Replaying a delivery

Once your endpoint is healthy again, replay what it missed. POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay queues a new delivery carrying the same event id and the same body. Free.

202 · `{ delivery }` — queued.

{
  "delivery": {
    "id": "whd_51c8b0f9c0",
    "event_id": "evt_pR4Vm8s2XqNb7cTfKdYw1E",
    "event_type": "run.completed",
    "status": "pending",
    "attempts": 0,
    "response_status": null,
    "error": null,
    "run_id": "run_8f2c1ad04b",
    "tracker_id": null,
    "change_report_id": null,
    "replay_of": "whd_51c8b0f81a",
    "created_at": "2026-09-08T10:02:00.000Z",
    "last_attempt_at": null,
    "delivered_at": null,
    "failed_at": null
  }
}

The event id being stable is the point: a receiver that did eventually process the original recognises the replay as a duplicate and drops it, while the log keeps “we failed on Tuesday, you replayed on Thursday” visible. A delivery still being attempted cannot be replayed — wait for it to settle.

Rotating a secret

POST /v1/webhooks/{id}/rotate mints a new signing secret and shows it once. The old one keeps working for 24 hours, and during that window every delivery is signed with both: one t, two v1 values over the same body.

200 · `{ webhook, secret, overlap_expires_at }` — `secret` shown once.

{
  "webhook": {
    "id": "whe_6d2f10ab44",
    "url": "https://hooks.example.com/shortsintel",
    "status": "active",
    "description": "production receiver",
    "secret_prefix": "whsec_d4e5f6",
    "previous_secret_expires_at": "2026-09-09T09:14:02.418Z",
    "created_at": "2026-09-08T09:14:02.418Z",
    "activated_at": "2026-09-08T09:14:03.006Z",
    "last_delivery_at": "2026-09-08T09:14:03.006Z"
  },
  "secret": "whsec_Lm2Rv8ZaQe5tYb1nXc7pKd3sWu9jHg6F",
  "overlap_expires_at": "2026-09-09T09:14:02.418Z"
}

A verifier written the documented way — does any v1 match? — needs no change at all. Rotate, deploy the new secret at your leisure inside the window, and no event is rejected in between. Rotating again while an overlap is still open is a 409: finish one rotation before starting the next.

DELETE /v1/webhooks/{id} stops delivery and frees a slot. The endpoint is tombstoned rather than erased, so its delivery history stays readable afterwards. Free. Nothing about webhooks is charged — see Runs for what the event is telling you about, and Trackers for what makes a tracker.changed.