# Act on the result with a webhook

Point your key at an endpoint, verify each signed POST, and handle every finished turn exactly once, even when a delivery is retried.

Holding an event stream open works for a script you watch. A service that starts many runs wants
to be told when each one finishes. In this lesson you give your org key a webhook, receive a
`run.completed` event, check its signature and act on it once, however many times it arrives.

You need the key from [Your first run from code](/docs/learn/first-api-run) and a public `https`
endpoint you control.

_Figure: webhook delivery, on the page at /docs/learn/act-on-the-result._
## Point the key at your endpoint

### 1. Set the webhook URL

The person who created the key sets its webhook from their own signed-in session, not with the key
itself. [`updateApiKeyWebhook`](/docs/api/update-api-key-webhook) takes the key's `id`:

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

The URL must be public `https`.

### 2. Store the signing secret

The response carries `webhook_secret`, starting `whsec_`, once. Store it beside `RUSH_API_KEY`:

```bash
export RUSH_WEBHOOK_SECRET=whsec_...
```

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

## What arrives

### 3. Start a run and wait

Every run the key starts now posts to your URL within about 30 seconds of a turn ending. A resumable
run, which every key run is, sends one event per turn. The body:

```json
{
  "type": "run.completed",
  "timestamp": "2026-10-06T12:00:00.000Z",
  "data": {
    "execution_id": "3f9c2a71d04b8e65",
    "org_id": "acme",
    "status": "completed",
    "result": "Opened https://github.com/acme/web/pull/412 and all checks pass.",
    "total_cost_usd": 0.42,
    "session_url": "https://api.prix.dev/o/acme/sessions/3f9c2a71d04b8e65",
    "events_url": "https://api.prix.dev/o/acme/sessions/3f9c2a71d04b8e65/events"
  }
}
```

`type` is one of three values:

| `type` | When |
| --- | --- |
| `run.completed` | The turn ended `completed` or `needs_review`. Check `data.status` to tell them apart |
| `run.failed` | The turn could not finish |
| `run.cancelled` | Someone cancelled the run |

`data.result`, the agent's final answer, is included up to 16 KiB and left out past that. Read
`session_url` with your key for the full record either way: the answer, `pr_url`, `artifacts` and
usage. The [`runFinished`](/docs/api/run-finished) reference has the full schema.

Three headers come with every request: `webhook-id`, `webhook-timestamp` and `webhook-signature`.

## Verify the signature

Requests are signed per [Standard Webhooks](https://www.standardwebhooks.com/), so any of its
libraries verifies them, for example `new Webhook(secret).verify(body, headers)` from the
`standardwebhooks` package. Without one, the check is short.

### 4. Check it against the raw body

`webhook-signature` is `v1,` followed by 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 bytes, before
any JSON parsing, and reject a timestamp more than five minutes off.

TypeScript:

```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;
  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);
  });
}
```

Python:

```python
import base64, hashlib, hmac, time

def verify(secret: str, headers: dict[str, str], body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
    for sig in headers.get("webhook-signature", "").split(" "):
        version, _, value = sig.partition(",")
        if version == "v1" and hmac.compare_digest(base64.b64decode(value), expected):
            return True
    return False
```

The header can hold more than one signature, separated by spaces; accept the request when any `v1`
signature matches.

## Handle each turn once

### 5. Answer fast, work later

Answer any 2xx within 5 seconds. Anything else, including a timeout, counts as a failed delivery.
Record the event and return; do the slow work (posting a comment, merging, paging someone) after.

### 6. Drop duplicates by webhook-id

A failed delivery is retried 3 more times, 2, 4 and 6 minutes after the first try, each with the
same `webhook-id`. Store the ids you have handled and skip one you have seen:

```ts
if (!verify(process.env.RUSH_WEBHOOK_SECRET!, req.headers, rawBody)) return new Response(null, { status: 401 });
const id = req.headers.get("webhook-id")!;
if (await seen.has(id)) return new Response(null, { status: 200 });
await queue.push(JSON.parse(rawBody));
await seen.add(id);
return new Response(null, { status: 200 });
```

Each finished turn has its own `webhook-id`, so the second turn of a session is a new event, not a
duplicate.

### 7. Catch up after a drop

After the third retry the event is dropped. If your endpoint was down longer than that, list recent
runs with [`listSessions`](/docs/api/list-sessions) or read each one from `session_url`. Revoking the
key or removing its webhook drops any retry still pending, and a run that finishes after its key
expired sends no webhook, so give a key that starts long runs room past their end.

## Next

[Webhooks](/docs/concepts/webhooks) covers delivery in more depth, and
[Results and proof](/docs/concepts/results-and-proof) explains what `needs_review` means for a run.
