Learn
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 and a public https
endpoint you control.
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 takes the key's id:
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:
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:
{
"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 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, 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:
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:
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:
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 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 covers delivery in more depth, and
Results and proof explains what needs_review means for a run.