Concepts

Webhooks

Give an API key an endpoint and every run it starts posts a signed event there when a turn ends, so you never hold a stream or poll.

View .md

Holding an event stream open for every run does not scale to many runs, and polling wastes requests. A webhook turns it around: Rush calls your server when a turn ends, with the status and the answer, signed so you can tell the call came from Rush.

A webhook is set on an API key. Every run that key starts reports to it.

04The body says what happened

Rushturn endedPOST https://your-server.example/rushwebhook-idstable across retrieswebhook-timestampunix secondswebhook-signaturev1,<HMAC-SHA256>{ "type": "run.completed", "timestamp": "2026-10-06T15:09:03.000Z", "data": { "execution_id": "3f9c2a71d04b8e65", "org_id": "0b6f3c52-8d1e-4a7b-9c55-2f1e8d7a6b90", "status": "completed", "result": "Pinned the clock in checkout.spec.ts and opened… "total_cost_usd": 1.84, "session_url": "https://api.prix.dev/o/0b6f3c52-8d1e-4a7b-… "events_url": "https://api.prix.dev/o/0b6f3c52-8d1e-4a7b-9…
Scene. The delivery Rush posts when a turn ends: three Standard Webhooks headers and the payload, from the spec's example.

Set one

The key's creator sets the endpoint from a signed-in session, not with the key itself, using PATCH /o/{org}/api-keys/{key}:

rush http PATCH /o/$ORG/api-keys/$KEY_ID -d '{"webhook_url": "https://example.com/rush"}'

The URL must be public https. The response carries webhook_secret, a whsec_… value, once. Store it beside RUSH_API_KEY.

  • Changing the URL keeps the secret.
  • {"rotate_secret": true} returns a new secret and retires the old one at once.
  • {"webhook_url": null} removes both.

The event

Within about 30 seconds of a turn ending, Rush POSTs JSON to your URL. A resumable run sends one event per turn. See the event's reference.

{
  "type": "run.completed",
  "timestamp": "2026-10-06T15:09:03.000Z",
  "data": {
    "execution_id": "3f9c2a71d04b8e65",
    "org_id": "0b6f3c52-8d1e-4a7b-9c55-2f1e8d7a6b90",
    "status": "completed",
    "result": "Pinned the clock in checkout.spec.ts and opened PR 412.",
    "total_cost_usd": 1.84,
    "session_url": "https://api.prix.dev/o/0b6f3c52-…/sessions/3f9c2a71d04b8e65",
    "events_url": "https://api.prix.dev/o/0b6f3c52-…/sessions/3f9c2a71d04b8e65/events"
  }
}

type is one of:

type data.status
run.completed completed or needs_review
run.failed failed
run.cancelled cancelled

Check data.status, not only type: a needs_review turn arrives as run.completed. result is included up to 16 KiB and left out past that; read session_url with your key for the full answer, the pull request and the artifacts.

Verify the signature

05No answer? Retried at 2, 4 and 6 minutes

Rushturn endedwebhook-idwebhook-timestampwebhook-signature: v1,…{"type":"run.completed",…}Your serverHMAC-SHA256(id.timestamp.body, key after whsec_)= v1,… ✓200 within 5 sno 2xx →+2 min+4 min+6 minsame webhook-id: drop duplicates
Scene. Rush signs each delivery per Standard Webhooks. Answer a 2xx within 5 seconds; otherwise it is retried with the same webhook-id.

Requests are signed per Standard Webhooks, so its libraries verify them (new Webhook(secret).verify(body, headers) in the standardwebhooks package). Three headers carry the signature:

Header Value
webhook-id Stable across retries: webhook:{execution_id}:{completed_at ms}.
webhook-timestamp Unix seconds of this attempt.
webhook-signature v1, and a base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}.

The HMAC key is the base64-decoded bytes after whsec_. Verify against the raw body, before parsing it:

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

function verify(secret: string, headers: Headers, body: string): boolean {
  const id = headers.get("webhook-id")!, ts = headers.get("webhook-timestamp")!;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // stale or replayed
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest();
  return (headers.get("webhook-signature") ?? "").split(" ").some((sig) => {
    const got = Buffer.from(sig.split(",")[1] ?? "", "base64");
    return sig.startsWith("v1,") && got.length === expected.length && timingSafeEqual(got, expected);
  });
}

Retries

Answer any 2xx within 5 seconds. Anything else, or no answer, is retried 3 more times: 2, 4 and 6 minutes after the first attempt, with the same webhook-id. Use that id to drop duplicates. After the third retry the event is dropped; read the run from session_url to catch up.

Revoking the key or removing its webhook drops any retry still pending. A run that finishes after its key expired sends no webhook, so give a key that starts long runs room past their end.