diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml
index 9b21941..bb4a58d 100644
--- a/.github/workflows/validate-frontmatter.yml
+++ b/.github/workflows/validate-frontmatter.yml
@@ -13,6 +13,7 @@ on:
- "scripts/validate-frontmatter.mjs"
- "scripts/validate-nav-and-redirects.mjs"
- "scripts/check-plan-limits.mjs"
+ - "scripts/check-webhooks-guide.mjs"
- "package.json"
pull_request:
paths:
@@ -22,6 +23,7 @@ on:
- "scripts/validate-frontmatter.mjs"
- "scripts/validate-nav-and-redirects.mjs"
- "scripts/check-plan-limits.mjs"
+ - "scripts/check-webhooks-guide.mjs"
- "package.json"
jobs:
@@ -39,3 +41,5 @@ jobs:
run: node scripts/validate-nav-and-redirects.mjs
- name: Check plan-limit tables match the source of truth
run: node scripts/check-plan-limits.mjs
+ - name: Check webhooks guide matches the webhook contract
+ run: node scripts/check-webhooks-guide.mjs
diff --git a/docs.json b/docs.json
index 17e7fa2..7343f66 100644
--- a/docs.json
+++ b/docs.json
@@ -34,7 +34,8 @@
"group": "Concepts",
"pages": [
"concepts/job",
- "guides/webhooks"
+ "guides/webhooks",
+ "guides/callbacks"
]
},
{
diff --git a/guides/callbacks.mdx b/guides/callbacks.mdx
new file mode 100644
index 0000000..4b36d69
--- /dev/null
+++ b/guides/callbacks.mdx
@@ -0,0 +1,177 @@
+---
+title: "Per-job completion callbacks"
+description: "Add a completion callback to a single job, POST the result to a per-job URL, and resume a Cloudflare Workflow without polling."
+sidebarTitle: "Callbacks"
+icon: "reply"
+keywords: ["callbacks", "job callback", "per-job callback", "cloudflare workflows", "waitForEvent", "sendEvent", "verifyCallback", "callback signature"]
+canonical: "https://rendobar.com/docs/guides/callbacks"
+---
+
+
+
+A callback attaches a completion hook to one [job](/concepts/job). You pass `callback.url` when you create the job, and the moment that job reaches a terminal state Rendobar POSTs the result there. The body is byte-identical to the [webhook](/guides/webhooks) envelope, so the same receiver code handles both. The correlation lives in the URL you chose, so the receiver stays stateless and never polls `GET /jobs/{id}`.
+
+## Attach a callback
+
+Add a `callback` object to `client.jobs.create(...)`. The only required field is `url`.
+
+```ts
+import { createClient } from "@rendobar/sdk";
+
+const client = createClient({ apiKey: process.env.RENDOBAR_API_KEY });
+
+await client.jobs.create({
+ type: "ffmpeg",
+ params: { command: "ffmpeg -i input.mp4 -t 1 output.mp4" },
+ callback: { url: "https://your-app.example.com/rb/job_a1b2c3d4" },
+});
+```
+
+| Field | Type | Purpose |
+|---|---|---|
+| `callback.url` | string, HTTPS | Where Rendobar POSTs the job envelope on any terminal state (`complete`, `failed`, `cancelled`). SSRF-validated, HTTPS only. |
+| `callback.headers` | object, optional | Extra request headers on the POST, for example `{ Authorization: "Bearer ..." }`, so the callback can hit an authenticated target directly. |
+| `callback.verify` | boolean, optional | Sign the POST with `X-Rendobar-Signature`. Default `false`. |
+| `callback.events` | string[], optional | Opt into extra non-terminal events. The only value today is `"job.started"`. |
+
+
+Terminal events (`complete`, `failed`, `cancelled`) always fire and cannot be filtered out, so a step waiting on a callback can never hang. `callback.events` only adds non-terminal pings on top. The one available today is `"job.started"`, a progress signal sent when the job begins running.
+
+
+Custom headers let the POST reach an authenticated endpoint with no receiver of your own. Framing headers and any `X-Rendobar-*` header are reserved. If you set one, the platform's value wins.
+
+
+Rendobar never returns `callback.url` or `callback.headers` in any API response. Both are secrets. `GET /jobs/{id}` reports only `callback.status`.
+
+
+## Resume a Cloudflare Workflow
+
+Callbacks were built for this pattern. A Cloudflare Workflow dispatches a job and suspends until it finishes, with no polling in between. There are two shapes.
+
+### Shape A: receiver Worker with sendEvent
+
+The Workflow's own `fetch` handler is the receiver. The callback URL carries `event.instanceId`, the receiver route turns the POST into `instance.sendEvent(...)`, and that resumes the suspended `waitForEvent`. This exact suspend and resume loop was verified end to end.
+
+```ts
+import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from "cloudflare:workers";
+import { createClient } from "@rendobar/sdk";
+
+export class RenderJob extends WorkflowEntrypoint {
+ async run(event: WorkflowEvent<{ source: string }>, step: WorkflowStep) {
+ await step.do("submit rendobar job", async () => {
+ const client = createClient({ apiKey: this.env.RENDOBAR_API_KEY });
+ await client.jobs.create({
+ type: "ffmpeg",
+ params: { command: `ffmpeg -i ${event.payload.source} -t 1 output.mp4` },
+ // The instance id in the path is how the callback finds this exact run.
+ callback: { url: `https://your-worker.example.com/rb/${event.instanceId}` },
+ });
+ });
+
+ // Suspend the whole workflow until Rendobar calls back. No polling.
+ const done = await step.waitForEvent("await rendobar callback", {
+ type: "rb-job",
+ timeout: "10 minutes",
+ });
+
+ return { url: done.payload?.data?.output?.file?.url };
+ }
+}
+
+export default {
+ async fetch(req: Request, env: Env): Promise {
+ const url = new URL(req.url);
+ const m = url.pathname.match(/^\/rb\/(.+)$/);
+ if (req.method === "POST" && m) {
+ const instance = await env.RENDER_JOB.get(m[1]);
+ await instance.sendEvent({ type: "rb-job", payload: await req.json() });
+ return new Response("ok");
+ }
+ return new Response("not found", { status: 404 });
+ },
+};
+```
+
+### Shape B: no receiver Worker
+
+Point `callback.url` straight at Cloudflare's Workflows "send event" REST endpoint for the instance, and put your Cloudflare API token in `callback.headers`. Rendobar POSTs the job result directly to Cloudflare, so there is no shim to deploy. This is the zero-infrastructure option. The caller authenticates the callback itself through the header, so signing is usually unnecessary here.
+
+```ts
+await client.jobs.create({
+ type: "ffmpeg",
+ params: { command: `ffmpeg -i ${source} -t 1 output.mp4` },
+ callback: {
+ url: `https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/workflows/render-job/instances/${instanceId}/events/rb-job`,
+ headers: { Authorization: `Bearer ${CF_API_TOKEN}` },
+ },
+});
+```
+
+The waiting Workflow is the same as Shape A. It calls `waitForEvent({ type: "rb-job" })` and resumes when Cloudflare delivers the event.
+
+## Secure it
+
+Callbacks are unsigned by default because the URL itself is the capability. Set `callback.verify: true` to sign the POST, then check it on your receiver. The signature is the same HMAC-SHA256 scheme as webhooks, over `${timestamp}.${body}`, and the SDK ships the same verifier.
+
+```ts
+import { verifyCallback } from "@rendobar/sdk/webhooks";
+
+// Raw body (a string, not parsed JSON) plus the request headers.
+const ok = await verifyCallback(rawBody, req.headers, process.env.CALLBACK_SECRET);
+if (!ok) throw new Error("invalid signature");
+```
+
+`verifyCallback` is the same function as `verifyWebhook`. Fetch the organization callback signing secret once and store it in your secret manager.
+
+```ts
+// GET /orgs/current/callback-secret -> { data: { secret: "cbsec_..." } }
+const { data } = await client.orgs.getCallbackSecret();
+```
+
+Rotate the secret with `POST /orgs/current/callback-secret/rotate`. Reach for signing when the callback lands on an endpoint you own and you want to prove the POST came from Rendobar. For Shape B, where you already authenticate to Cloudflare through `callback.headers`, signing usually adds nothing.
+
+## Watch delivery
+
+Once a job carries a callback, `GET /jobs/{id}` reports its delivery state under `callback.status`.
+
+| Status | Meaning |
+|---|---|
+| `pending` | The job is not terminal yet, or the callback is queued for delivery |
+| `delivered` | The receiver returned `2xx` |
+| `failed` | Delivery exhausted its retries and moved to the dead-letter path |
+
+The dashboard job detail view shows the same status. Delivery is at-least-once, with retries and a dead-letter path, so the same event can arrive more than once. Deduplicate on the `X-Rendobar-Delivery` header, a `whd_...` id that is unique per delivery. Every POST carries these headers.
+
+```
+X-Rendobar-Event: job.completed
+X-Rendobar-Delivery: whd_x1y2z3
+X-Rendobar-Timestamp: 1707436815
+X-Rendobar-Attempt: 1
+```
+
+## How it compares to org webhooks
+
+[Webhooks](/guides/webhooks) and callbacks carry the identical envelope and verify with the same code. The difference is scope. A webhook is one static URL registered in the dashboard that receives events for every job in the organization. A callback is a per-job URL, set at create time, that receives events for exactly one job. Webhooks fit a central event pipeline. Callbacks fit an orchestrator that dispatches a job and waits on that one job, such as the Cloudflare Workflow above.
+
+## Related
+
+- [Webhooks for job events](/guides/webhooks): the shared envelope, signature scheme, and retry behavior
+- [Job output](/concepts/job#the-output): the `output` and `error` shape a callback carries
+- [Job lifecycle](/concepts/job): what each terminal state means
+- [Changelog](https://rendobar.com/changelog/): callback and webhook payload changes
diff --git a/guides/webhooks.mdx b/guides/webhooks.mdx
index 9141659..d5c85e5 100644
--- a/guides/webhooks.mdx
+++ b/guides/webhooks.mdx
@@ -27,6 +27,10 @@ canonical: "https://rendobar.com/docs/guides/webhooks"
Webhooks push events to your server the moment a [job](/concepts/job) changes status or your balance runs low. No polling. Rendobar POSTs a signed JSON payload, your server verifies it and reacts.
+
+Want a hook scoped to a single job instead of every job in the organization? See [per-job callbacks](/guides/callbacks).
+
+
## Create an endpoint
Create and manage endpoints on the [**Webhooks page**](https://app.rendobar.com/webhooks) in the dashboard.
diff --git a/package.json b/package.json
index 8d8af0e..7be33f8 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"scripts": {
"validate:frontmatter": "node scripts/validate-frontmatter.mjs",
"validate:nav": "node scripts/validate-nav-and-redirects.mjs",
- "validate:plan-limits": "node scripts/check-plan-limits.mjs"
+ "validate:plan-limits": "node scripts/check-plan-limits.mjs",
+ "validate:webhooks": "node scripts/check-webhooks-guide.mjs"
}
}
diff --git a/scripts/check-webhooks-guide.mjs b/scripts/check-webhooks-guide.mjs
new file mode 100644
index 0000000..502e49a
--- /dev/null
+++ b/scripts/check-webhooks-guide.mjs
@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+/**
+ * Webhooks guide consistency guard.
+ *
+ * Asserts the facts in guides/webhooks.mdx still match the canonical webhook
+ * contract defined in the monorepo. Source of truth:
+ * - packages/shared/src/constants/webhook-events.ts (event catalog, retry
+ * backoff, MAX_ENDPOINTS_PER_ORG)
+ * - packages/shared/src/types/webhook.ts (envelope fields)
+ * - apps/api/src/routes/webhook-endpoints.ts (whd_ delivery id,
+ * header names) + lib/webhooks.ts (signed string {timestamp}.{body})
+ * - packages/sdk/src/lib/webhook-verify.ts (verifyWebhook helper)
+ *
+ * The docs repo does not vendor the monorepo, so the contract is embedded below.
+ * When the contract changes in the monorepo, update EXPECTED here and the guide
+ * in the same change. Exit 0 = consistent, exit 1 = drift.
+ */
+
+import { readFileSync } from "fs";
+import { join, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const ROOT = join(__dirname, "..");
+const FILE = "guides/webhooks.mdx";
+
+// ── Canonical contract (mirror of the monorepo; see header) ──────────────────
+const EVENTS = [
+ "job.created", "job.started", "job.completed",
+ "job.failed", "job.cancelled", "balance.low", "balance.depleted",
+];
+const HEADERS = [
+ "X-Rendobar-Signature", "X-Rendobar-Timestamp",
+ "X-Rendobar-Event", "X-Rendobar-Delivery", "X-Rendobar-Attempt",
+];
+const ENVELOPE_FIELDS = ["version", "event", "deliveryId", "timestamp", "orgId", "data"];
+const RETRY_DELAYS_SEC = [10, 20, 40, 80, 160];
+const SDK_METHODS = ["rotateSecret", "retryDelivery"]; // methods the guide shows in code
+const MAX_ENDPOINTS = 10;
+const DELIVERY_ID_PREFIX = "whd_";
+const STALE_DELIVERY_ID_PREFIX = "del_"; // the bug we fixed; it must never return
+const SIGNED_STRING = "{timestamp}.{body}";
+const SDK_WEBHOOKS_ENTRY = "@rendobar/sdk/webhooks";
+const EM_DASH = "—";
+const EN_DASH = "–";
+
+const md = readFileSync(join(ROOT, FILE), "utf8");
+// Prose only: strip fenced blocks and inline code so code samples are not
+// flagged by the punctuation check.
+const prose = md.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
+
+const checks = [];
+const check = (name, ok, detail = "") => checks.push({ name, ok, detail });
+
+// 1. Every catalog event is documented
+for (const e of EVENTS) check(`event ${e} documented`, md.includes(`\`${e}\``));
+
+// 2. No event outside the catalog is documented (catches renamed/removed events)
+const mentioned = [...md.matchAll(/`((?:job|balance)\.[a-z.]+)`/g)].map((m) => m[1]);
+const unknown = [...new Set(mentioned)].filter((e) => !EVENTS.includes(e));
+check("no unknown events documented", unknown.length === 0, unknown.join(", "));
+
+// 3. All delivery headers present
+for (const h of HEADERS) check(`header ${h}`, md.includes(h));
+
+// 4. Delivery id uses whd_, and the old del_ is gone
+check(`delivery id prefix ${DELIVERY_ID_PREFIX}`, md.includes(DELIVERY_ID_PREFIX));
+check(`stale ${STALE_DELIVERY_ID_PREFIX} id removed`, !md.includes(STALE_DELIVERY_ID_PREFIX));
+
+// 5. Envelope fields documented
+for (const f of ENVELOPE_FIELDS) check(`envelope field ${f}`, md.includes(`\`${f}\``));
+
+// 6. Retry backoff numbers present
+for (const d of RETRY_DELAYS_SEC) check(`retry delay ${d}s`, new RegExp(`\\b${d} s\\b`).test(md));
+
+// 7. Verification leads with the SDK one-liner
+check(`imports ${SDK_WEBHOOKS_ENTRY}`, md.includes(SDK_WEBHOOKS_ENTRY));
+check("uses verifyWebhook()", md.includes("verifyWebhook("));
+check("documents signed string", md.includes(SIGNED_STRING));
+
+// 8. SDK management methods referenced + the dashboard test action
+for (const m of SDK_METHODS) check(`sdk client.webhooks.${m}()`, md.includes(`${m}(`));
+check("mentions the Send Test action", /send test/i.test(md));
+
+// 9. Limits + dashboard link
+check(`max ${MAX_ENDPOINTS} endpoints noted`, new RegExp(`up to ${MAX_ENDPOINTS}\\b`).test(md));
+check("links to the webhooks dashboard", md.includes("app.rendobar.com/webhooks"));
+
+// 10. Prose punctuation (writing-style rule)
+check("no em-dash in prose", !prose.includes(EM_DASH));
+check("no en-dash in prose", !prose.includes(EN_DASH));
+
+// ── Report ───────────────────────────────────────────────────────────────
+const failed = checks.filter((c) => !c.ok);
+for (const c of checks) {
+ console.log(`${c.ok ? "✓" : "✗"} ${c.name}${!c.ok && c.detail ? ` (${c.detail})` : ""}`);
+}
+console.log(`\n${checks.length - failed.length}/${checks.length} checks passed.`);
+if (failed.length) {
+ console.error(`✗ webhooks guide drifted from the contract. Fix ${FILE}, or update EXPECTED in this script if the contract itself changed.`);
+ process.exitCode = 1;
+} else {
+ console.log("✓ webhooks guide matches the webhook contract.");
+}