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.

View .md

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.

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.

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.