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.
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.
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
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.