> 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 get told what happened. Every delivery is a JWT signed with a private key that you verify 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**, and 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`](/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 still referencing it simply 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` rather than running 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 |

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

## 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, so route on `processingId` and read `GET /v1/runs/:id` when you need your own keys back.

## Delivery and retries

* **At-least-once.** The same event can arrive more than once — dedupe on `eventId` before acting.
* **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, and re-read `GET /v1/runs/:id` when you need the authoritative state.

## Debug a delivery

`GET /v1/webhooks/:id/deliveries` is the "did it arrive?" surface — 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` is a retry — the row above shows an endpoint rejecting the POST, the most common integration mistake.

## Signature

Each delivery carries the header:

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

It's 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 and compare it to the JWT's `bodySha256`. This proves the body wasn't altered.

#### Check the claims

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

## 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 won't match — capture the raw body before it's parsed.

## Where to go next

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

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

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

The legacy multipart pipeline and its `callbackUrl`.