Webhooks

Receive signed events when runs progress, and verify them with public-key JWTs.

View as Markdown

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:

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, 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 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 still referencing it simply 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 rather than running 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

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.

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

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

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

4

Check the claims

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

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

Where to go next