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

_Figure: webhook delivery, on the page at /docs/concepts/webhooks._
## 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}`](/docs/api/update-api-key-webhook):

```bash
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](/docs/api/run-finished).

```json
{
  "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

_Figure: signed delivery, on the page at /docs/concepts/webhooks._
Requests are signed per [Standard Webhooks](https://www.standardwebhooks.com/), 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:

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