> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.cloudraker.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.cloudraker.com/_mcp/server.

# Webhooks

Instead of polling a run, point CloudRaker at an endpoint and receive events. Every delivery is a JWT signed with a private key. You verify it against a **public** JWKS. There is no shared secret to store or rotate.

## Two ways to subscribe

**Per run** — every capability call takes a `webhook` union:

```json
{ "file": { "url": "…" }, "schema": { }, "webhook": { "url": "https://example.com/hooks/cloudraker" } }
{ "file": { "url": "…" }, "schema": { }, "webhook": { "id": "whe_01KYD7GKAP5FZE84XTX455FFZB" } }
```

`{url}` is ad-hoc. The URL is used for this run only. `{id}` references a **saved endpoint**. The run stores the reference, not a URL snapshot. Re-pointing or pausing that endpoint applies to runs that are already in flight.

The legacy [`/process`](/paperwork/developers/process-api) API's `callbackUrl` behaves like the `{url}` form and is unchanged.

## Saved endpoints

```bash
curl -X POST https://api.cloudraker.com/v1/webhooks \
  -H "Authorization: Bearer $CLOUDRAKER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/cloudraker",
    "events": ["run.completed", "run.failed", "processing.completed"]
  }'
```

```json
{
  "object": "webhook_endpoint",
  "id": "whe_01KYD7GKAP5FZE84XTX455FFZB",
  "url": "https://example.com/hooks/cloudraker",
  "events": ["run.completed", "run.failed", "processing.completed"],
  "disabled": false,
  "createdAt": "2026-07-25T18:09:40.310Z"
}
```

Omit `events` to receive every type. The URL must be `https`.

| Route                             | What it does                                                         |
| --------------------------------- | -------------------------------------------------------------------- |
| `POST /v1/webhooks`               | Create an endpoint. Returns a `whe_` id.                             |
| `GET /v1/webhooks`                | List your endpoints, newest first.                                   |
| `GET /v1/webhooks/:id`            | Read one.                                                            |
| `PATCH /v1/webhooks/:id`          | Re-point (`url`), re-filter (`events`), or pause (`disabled: true`). |
| `DELETE /v1/webhooks/:id`         | Remove it. Runs that still reference it stop delivering. `204`.      |
| `GET /v1/webhooks/:id/deliveries` | The 50 most recent attempts.                                         |

A run that references a **disabled** endpoint fails at create with `422 webhook_endpoint_disabled`. It does not run silently undelivered. Re-enable the endpoint, or send `webhook: {url}` for that call.

## Event types

| Type                           | Fires when                                                                    |
| ------------------------------ | ----------------------------------------------------------------------------- |
| `processing.started`           | The run begins                                                                |
| `file.processed`               | A file finishes parsing                                                       |
| `file.failed`                  | A file fails to parse                                                         |
| `run.completed`                | A capability step completes                                                   |
| `run.failed`                   | A capability step fails                                                       |
| `processing.completed`         | The whole run is done                                                         |
| `processing.expired`           | The run hit its TTL and was purged                                            |
| `agent_run.waiting`            | An [agent run](/paperwork/developers/agent-runs) is blocked on a person       |
| `agent_run.approval_requested` | An agent run asks for a sign-off                                              |
| `agent_run.task_ready`         | A human step of an agent run is open                                          |
| `agent_run.completed`          | An agent run finished                                                         |
| `agent_run.failed`             | An agent run ended without a result. Also fires for `cancelled` and `expired` |
| `space.expired`                | A [space](/paperwork/developers/spaces) passed its `expiresAt` and was closed |

A single-capability run emits both `run.completed` (the step) and `processing.completed` (the run). A [pipeline](/paperwork/capabilities/pipeline) emits one `run.*` per step and one `processing.completed` at the end.

### `space.expired`

Space expiry is lazy. The first call that addresses a space past its `expiresAt` closes it, deletes its files, and answers `404`. That moment is when this event fires — **once per space**, not at the deadline itself, and never on an explicit `DELETE`. Advance warnings before the deadline come later, with the sweeper.

`processingId` carries the space id, so every published verifier keeps working:

```json
{
  "eventId": "b1c0a175-8f2e-4a4b-9d0f-77a3e9c15b6a",
  "type": "space.expired",
  "processingId": "2f6d1c48-9b7a-4b2c-9a3e-1d5f8c0b7e21",
  "occurredAt": "2026-08-22T09:14:31.220Z",
  "data": {
    "spaceId": "2f6d1c48-9b7a-4b2c-9a3e-1d5f8c0b7e21",
    "name": "Acme onboarding",
    "expiresAt": "2026-08-22T09:12:00.114Z"
  }
}
```

## Envelope

Each request body is JSON:

```json
{
  "eventId": "8357b4b4-9b6f-4126-9b67-23c804b610d4",
  "type": "run.completed",
  "processingId": "exr_01KYD7GRF4JW6XNEDYGAY1S5N5",
  "occurredAt": "2026-07-25T18:10:23.960Z",
  "data": { }
}
```

`processingId` is the run id you got at create. Those five fields are the whole envelope. Request `metadata` is **not** carried on deliveries. It lives on the run body. Route on `processingId` and read `GET /v1/runs/:id` when you need your own keys back.

## Agent runs

[Agent runs](/paperwork/developers/agent-runs) deliver on the same wire contract: same envelope, same `x-rk1-signature` JWT, same JWKS. `processingId` carries the `agr_` id, so **every verifier below keeps working unchanged**. Only `data` differs. Unlike a capability run, an agent run's `metadata` *is* carried on `data.metadata` when you set one.

Every `agent_run.*` payload starts with the run itself:

```jsonc
{
  "eventId": "5b1c0a17-8f2e-4a4b-9d0f-77a3e9c15b6a",
  "type": "agent_run.approval_requested",
  "processingId": "agr_01KYD1J8QW2RN4T6VXZ0ABCDEF",
  "occurredAt": "2026-07-29T09:41:06.114Z",
  "data": {
    "object": "agent_run",
    "id": "agr_01KYD1J8QW2RN4T6VXZ0ABCDEF",
    "status": "waiting",
    "metadata": { "caseId": "42" },
    "approval": {
      "id": "7c1e9f42-3b0d-4a5e-8f61-2d9c4b7ae013",
      "kind": "before",
      "action": "Sign document",
      "requestedAt": "2026-07-29T09:41:05.902Z"
    }
  }
}
```

What each type adds to `data`:

| Type                           | Extra fields                                                                                                                               |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `agent_run.waiting`            | `waiting: {approvals, tasks, summary}` — the counts and a one-line reason.                                                                 |
| `agent_run.approval_requested` | `approval: {id, kind, action, requestedAt}`.                                                                                               |
| `agent_run.task_ready`         | `task: {id, title, executor: "human", status: "ready"}`.                                                                                   |
| `agent_run.completed`          | `progress`, plus `result`, `output`, and `incomplete` when present.                                                                        |
| `agent_run.failed`             | `error: {code, message}`. Also carries a `cancelled` or `expired` run (`run_cancelled`, `run_expired`). **`data.status` tells the truth.** |

`approval_requested` is deliberately minimal. The proposed `params` are auth-gated on the run itself. Re-read `GET /v1/agent-runs/:id` and take `approvals[]` from there before you decide.

`task_ready` is the one event that is a to-do list. Look up the step in the run's `tasks[]`, do the work, then `POST /v1/agent-runs/:id/tasks/:taskId/complete`.

**Delivery rides the request that observed the change**, with a retry ladder compressed into it (about 0.5, 1, 2, and 4 seconds). An endpoint that is down longer than that gets the event the next time the run is observed, not on a slow background ladder. An integration that also long-polls `GET /v1/agent-runs/:id` therefore never misses a transition. Re-read the run whenever you need the authoritative state.

## Delivery and retries

* **At-least-once.** The same event can arrive more than once. Dedupe on `eventId` before you act.
* **Up to 5 attempts** per event, with exponential backoff (about 10 s, 20 s, 40 s, 80 s, capped at 5 minutes).
* Any non-2xx response counts as a failure and is retried. After the last attempt the event is dropped.
* Ordering is not guaranteed. Treat each event as a fact about a run. Re-read `GET /v1/runs/:id` when you need the authoritative state.

## Debug a delivery

`GET /v1/webhooks/:id/deliveries` shows whether an event arrived. It returns one row per attempt, newest first:

```json
{
  "object": "list",
  "data": [
    {
      "object": "webhook_delivery",
      "endpointId": "whe_01KYD7GKAP5FZE84XTX455FFZB",
      "processingId": "exr_01KYD7GRF4JW6XNEDYGAY1S5N5",
      "eventId": "8357b4b4-9b6f-4126-9b67-23c804b610d4",
      "type": "run.completed",
      "attempt": 2,
      "responseStatus": 401,
      "ok": false,
      "at": "2026-07-25T18:10:33.927Z"
    }
  ]
}
```

`responseStatus` is what your endpoint answered. Two rows with the same `eventId` and rising `attempt` show a retry. The row above shows an endpoint that rejects the POST. This is the most common integration mistake.

## Signature

Each delivery carries the header:

```
x-rk1-signature: <compact ES256 JWS (a JWT)>
```

It is a JWT signed per attempt with a private P-256 key. Its claims:

```jsonc
{
  "iss": "rakerone-process",
  "processingId": "…",
  "eventId": "…",
  "type": "run.completed",
  "bodySha256": "<base64url SHA-256 of the raw request body>",
  "iat": 1721476800,
  "exp": 1721477100   // iat + 300 (5 minutes)
}
```

The JWT header carries a `kid` (for example `rk1-wh-prod-2026-07`) and `alg: ES256`. Retries get a fresh `iat`/`exp`, so the replay window is always 5 minutes from the latest attempt.

## Verify a delivery

#### Fetch the public JWKS

`GET /v1/webhooks/jwks.json` — unauthenticated, cacheable for an hour. It returns the public P-256 keys:

```bash
curl https://api.cloudraker.com/v1/webhooks/jwks.json
```

```json
{ "keys": [ { "kty": "EC", "crv": "P-256", "x": "…", "y": "…", "use": "sig", "alg": "ES256", "kid": "rk1-wh-prod-2026-07" } ] }
```

`GET /process/jwks.json` serves the same keys and keeps working. `/v1/webhooks/jwks.json` is the stable address.

#### Verify the JWT

Verify the `x-rk1-signature` JWT against those keys with any standard JWT library. Match on `kid`, algorithm `ES256`.

#### Bind the signature to the body

Recompute the base64url SHA-256 of the **raw** request body. Compare it to the JWT's `bodySha256`. This proves the body was not altered.

#### Check the claims

Confirm `iss` is `rakerone-process` and `exp` has not passed. Then dedupe on `eventId` before you act.

## Sample verifier

```ts title="TypeScript (jose)"
import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(
  new URL("https://api.cloudraker.com/v1/webhooks/jwks.json"),
);

// rawBody: the exact bytes you received (Buffer/Uint8Array), not a re-serialized object
export async function verifyWebhook(rawBody: Buffer, signature: string) {
  const { payload } = await jwtVerify(signature, JWKS, {
    issuer: "rakerone-process",
    algorithms: ["ES256"],
  });

  const digest = createHash("sha256").update(rawBody).digest("base64url");
  if (digest !== payload.bodySha256) throw new Error("body digest mismatch");

  return payload; // { eventId, type, processingId, ... } — dedupe on eventId
}
```

```python title="Python (PyJWT)"
import base64, hashlib
import jwt
from jwt import PyJWKClient

JWKS = PyJWKClient("https://api.cloudraker.com/v1/webhooks/jwks.json")

def verify_webhook(raw_body: bytes, signature: str) -> dict:
    signing_key = JWKS.get_signing_key_from_jwt(signature).key
    claims = jwt.decode(
        signature,
        signing_key,
        algorithms=["ES256"],
        issuer="rakerone-process",
    )

    digest = base64.urlsafe_b64encode(
        hashlib.sha256(raw_body).digest()
    ).rstrip(b"=").decode()
    if digest != claims["bodySha256"]:
        raise ValueError("body digest mismatch")

    return claims  # dedupe on claims["eventId"]
```

Verify against the **raw** request bytes. If your web framework parses and re-serializes the JSON body, the digest will not match. Capture the raw body before it is parsed.

## Where to go next

#### [Runs](/developers/runs)

What the events describe: statuses, outputs, TTL, and `keep`.

#### [Agent runs](/developers/agent-runs)

What the `agent_run.*` events describe: approvals, human steps, and how to answer them.

#### [Process API](/developers/process-api)

The legacy multipart pipeline and its `callbackUrl`.