Webhooks

Receive signed events when runs progress. Verify them with public-key JWTs.

View as Markdown

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:

1{ "file": { "url": "" }, "schema": { }, "webhook": { "url": "https://example.com/hooks/cloudraker" } }
2{ "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 API’s callbackUrl behaves like the {url} form and is unchanged.

Saved endpoints

$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"]
> }'
1{
2 "object": "webhook_endpoint",
3 "id": "whe_01KYD7GKAP5FZE84XTX455FFZB",
4 "url": "https://example.com/hooks/cloudraker",
5 "events": ["run.completed", "run.failed", "processing.completed"],
6 "disabled": false,
7 "createdAt": "2026-07-25T18:09:40.310Z"
8}

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

RouteWhat it does
POST /v1/webhooksCreate an endpoint. Returns a whe_ id.
GET /v1/webhooksList your endpoints, newest first.
GET /v1/webhooks/:idRead one.
PATCH /v1/webhooks/:idRe-point (url), re-filter (events), or pause (disabled: true).
DELETE /v1/webhooks/:idRemove it. Runs that still reference it stop delivering. 204.
GET /v1/webhooks/:id/deliveriesThe 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

TypeFires when
processing.startedThe run begins
file.processedA file finishes parsing
file.failedA file fails to parse
run.completedA capability step completes
run.failedA capability step fails
processing.completedThe whole run is done
processing.expiredThe run hit its TTL and was purged
agent_run.waitingAn agent run is blocked on a person
agent_run.approval_requestedAn agent run asks for a sign-off
agent_run.task_readyA human step of an agent run is open
agent_run.completedAn agent run finished
agent_run.failedAn agent run ended without a result. Also fires for cancelled and expired
space.expiredA space passed its expiresAt and was closed

A single-capability run emits both run.completed (the step) and processing.completed (the run). A 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:

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

Envelope

Each request body is JSON:

1{
2 "eventId": "8357b4b4-9b6f-4126-9b67-23c804b610d4",
3 "type": "run.completed",
4 "processingId": "exr_01KYD7GRF4JW6XNEDYGAY1S5N5",
5 "occurredAt": "2026-07-25T18:10:23.960Z",
6 "data": { }
7}

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 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:

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

What each type adds to data:

TypeExtra fields
agent_run.waitingwaiting: {approvals, tasks, summary} — the counts and a one-line reason.
agent_run.approval_requestedapproval: {id, kind, action, requestedAt}.
agent_run.task_readytask: {id, title, executor: "human", status: "ready"}.
agent_run.completedprogress, plus result, output, and incomplete when present.
agent_run.failederror: {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:

1{
2 "object": "list",
3 "data": [
4 {
5 "object": "webhook_delivery",
6 "endpointId": "whe_01KYD7GKAP5FZE84XTX455FFZB",
7 "processingId": "exr_01KYD7GRF4JW6XNEDYGAY1S5N5",
8 "eventId": "8357b4b4-9b6f-4126-9b67-23c804b610d4",
9 "type": "run.completed",
10 "attempt": 2,
11 "responseStatus": 401,
12 "ok": false,
13 "at": "2026-07-25T18:10:33.927Z"
14 }
15 ]
16}

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:

1{
2 "iss": "rakerone-process",
3 "processingId": "",
4 "eventId": "",
5 "type": "run.completed",
6 "bodySha256": "<base64url SHA-256 of the raw request body>",
7 "iat": 1721476800,
8 "exp": 1721477100 // iat + 300 (5 minutes)
9}

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

1

Fetch the public JWKS

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

$curl https://api.cloudraker.com/v1/webhooks/jwks.json
1{ "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.

2

Verify the JWT

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

3

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.

4

Check the claims

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

Sample verifier

1import { createHash } from "node:crypto";
2import { createRemoteJWKSet, jwtVerify } from "jose";
3
4const JWKS = createRemoteJWKSet(
5 new URL("https://api.cloudraker.com/v1/webhooks/jwks.json"),
6);
7
8// rawBody: the exact bytes you received (Buffer/Uint8Array), not a re-serialized object
9export async function verifyWebhook(rawBody: Buffer, signature: string) {
10 const { payload } = await jwtVerify(signature, JWKS, {
11 issuer: "rakerone-process",
12 algorithms: ["ES256"],
13 });
14
15 const digest = createHash("sha256").update(rawBody).digest("base64url");
16 if (digest !== payload.bodySha256) throw new Error("body digest mismatch");
17
18 return payload; // { eventId, type, processingId, ... } — dedupe on eventId
19}

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