diff --git a/guides/webhooks.mdx b/guides/webhooks.mdx
index 36df25b..9141659 100644
--- a/guides/webhooks.mdx
+++ b/guides/webhooks.mdx
@@ -1,9 +1,9 @@
---
title: "Webhooks for job events"
-description: "Receive job and account events via signed HMAC POST. Setup, payload format, signature verification, automatic retries with backoff."
+description: "Receive job and account events as signed HMAC POSTs. Create an endpoint in the dashboard, verify with one SDK call, and handle retries."
sidebarTitle: "Webhooks"
icon: "bell"
-keywords: ["webhooks", "hmac signature", "job status webhook", "rendobar webhook setup", "signed webhook"]
+keywords: ["webhooks", "hmac signature", "job status webhook", "rendobar webhook setup", "signed webhook", "verifyWebhook", "webhook sdk"]
canonical: "https://rendobar.com/docs/guides/webhooks"
---
@@ -15,9 +15,9 @@ canonical: "https://rendobar.com/docs/guides/webhooks"
"@type": "TechArticle",
"@id": "https://rendobar.com/docs/guides/webhooks/#article",
"headline": "Webhooks for job events",
- "description": "Receive job status events via signed HMAC POST. Setup, payload format, signature verification, retry policy.",
+ "description": "Receive job status events via signed HMAC POST. Create an endpoint in the dashboard, verify the signature, and handle retries.",
"datePublished": "2026-03-20",
- "dateModified": "2026-07-14",
+ "dateModified": "2026-07-25",
"author": { "@type": "Organization", "@id": "https://rendobar.com/#organization" },
"publisher": { "@type": "Organization", "@id": "https://rendobar.com/#organization" },
"isPartOf": { "@id": "https://rendobar.com/#website" }
@@ -25,28 +25,23 @@ canonical: "https://rendobar.com/docs/guides/webhooks"
}}
/>
-Rendobar POSTs JSON to your endpoint when a [job](/concepts/job) changes status.
+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.
-## Set up a webhook
+## Create an endpoint
-```bash
-curl -X POST https://api.rendobar.com/webhooks/endpoints \
- -H "Authorization: Bearer rb_YOUR_KEY" \
- -H "Content-Type: application/json" \
- -d '{
- "name": "Production webhook",
- "url": "https://your-server.com/webhooks/rendobar",
- "subscribedEvents": ["job.completed", "job.failed"]
- }'
-```
+Create and manage endpoints on the [**Webhooks page**](https://app.rendobar.com/webhooks) in the dashboard.
-Required: `name` (1–50 chars), `url` (must be HTTPS), `subscribedEvents` (≥ 1). The response includes a signing secret (`whsec_...`). Store it. You'll verify every payload against it.
+1. Open [**Webhooks**](https://app.rendobar.com/webhooks) and click **New Endpoint**.
+2. Name it, paste your **HTTPS** URL, and check the events you want. New endpoints start with `job.completed`, `job.failed`, and `job.cancelled` selected.
+3. Click **Create Endpoint**, then copy the signing secret (`whsec_...`). It is shown once, so store it in your secret manager.
-Manage endpoints (create, update, delete, test) via the API or the dashboard.
+Each endpoint gets a card where you edit events, rotate the secret, send a test delivery, and re-send anything that failed. An organization can have up to 10.
-## Events
+
+Hit **Send Test** on the card to fire a sample event and confirm your receiver returns `2xx` before real jobs start flowing.
+
-Subscribe to any of these when you create an endpoint.
+## Events
| Event | When |
|---|---|
@@ -58,28 +53,102 @@ Subscribe to any of these when you create an endpoint.
| `balance.low` | Credit balance crossed the low threshold |
| `balance.depleted` | Credit balance reached zero |
+## Verify every delivery
+
+Rendobar signs each payload. Check the signature before you trust the body. The SDK does it in one call.
+
+```ts
+import { verifyWebhook } from "@rendobar/sdk/webhooks";
+
+// Raw body (a string, not parsed JSON) plus the request headers.
+const ok = await verifyWebhook(rawBody, req.headers, process.env.WEBHOOK_SECRET);
+if (!ok) throw new Error("invalid signature");
+```
+
+`verifyWebhook` rebuilds the signed string, checks the HMAC, rejects stale deliveries so a captured request cannot be replayed, and accepts either secret during a [rotation](#secret-rotation). It has no dependencies and runs on Node, Deno, Bun, Cloudflare Workers, and the browser.
+
+A full receiver on Express. The one rule: verify the raw bytes, before any JSON parser touches them.
+
+```ts
+import express from "express";
+import { verifyWebhook } from "@rendobar/sdk/webhooks";
+
+const app = express();
+
+app.post(
+ "/webhooks/rendobar",
+ express.raw({ type: "application/json" }),
+ async (req, res) => {
+ const raw = req.body.toString("utf8");
+
+ if (!(await verifyWebhook(raw, req.headers, process.env.WEBHOOK_SECRET))) {
+ return res.status(401).send("invalid signature");
+ }
+
+ res.status(200).send("ok"); // ack fast, then work off the request path
+
+ const { event, data } = JSON.parse(raw);
+ if (event === "job.completed") console.log(data.jobId, data.output.file?.url);
+ },
+);
+
+app.listen(3000);
+```
+
+Not on Node? The signature is HMAC-SHA256 over `{timestamp}.{body}`, hex-encoded, in `X-Rendobar-Signature` as `sha256=`.
+
+
+
+```javascript Node.js
+import { createHmac, timingSafeEqual } from "crypto";
+
+function verify(body, signature, timestamp, secret) {
+ const message = `${timestamp}.${body}`;
+ const expected = createHmac("sha256", secret).update(message).digest("hex");
+ const received = signature.replace("sha256=", "");
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));
+}
+```
+
+```python Python
+import hmac, hashlib
+
+def verify_webhook(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
+ message = f"{timestamp}.{body.decode()}"
+ expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
+ return hmac.compare_digest(expected, signature.replace("sha256=", ""))
+```
+
+
+
+
+Compare with a timing-safe function (`timingSafeEqual`, `hmac.compare_digest`). Plain string equality leaks the signature one byte at a time.
+
+
## Payload
+Every delivery carries these headers.
+
```
X-Rendobar-Signature: sha256=abc123...
X-Rendobar-Timestamp: 1707436815
X-Rendobar-Event: job.completed
-X-Rendobar-Delivery: del_x1y2z3
+X-Rendobar-Delivery: whd_x1y2z3
X-Rendobar-Attempt: 1
```
-Every delivery is a versioned envelope. The envelope fields identify the event and delivery. The event-specific payload lives under `data`. For job events, `data` carries the same contract as [GET /jobs/{id}](/concepts/job#the-output).
+The body is a versioned envelope. The envelope fields identify the event and delivery. The event payload lives under `data`, which for job events matches [GET /jobs/{id}](/concepts/job#the-output).
-| Envelope field | Meaning |
+| Field | Meaning |
|---|---|
| `version` | Envelope schema version (currently `"1"`) |
-| `event` | The event name (see the table above) |
+| `event` | The event name |
| `deliveryId` | Unique per delivery. Use it to deduplicate |
| `timestamp` | When the envelope was built (unix ms) |
| `orgId` | Your organization ID |
| `data` | Event-specific payload |
-A `job.completed` delivery. `data.output` is the unified job output. `file.url` is a signed, time-limited URL, and `files` lists every produced file. Read [Job output](/concepts/job#the-output) for the full shape and the four output patterns.
+A `job.completed` delivery. `output.file.url` is a signed, time-limited URL, and `output.files` lists every produced file.
```json
{
@@ -95,127 +164,44 @@ A `job.completed` delivery. `data.output` is the unified job output. `file.url`
"output": {
"data": null,
"file": {
- "url": "https://api.rendobar.com/dl/job_a1b2c3d4?token=...",
+ "url": "https://r2.rendobar.com/...",
"path": "output.mp4",
"type": "video",
"size": 15234567,
"meta": { "format": "mp4", "width": 1920, "height": 1080, "durationMs": 127500 }
},
- "files": [
- {
- "url": "https://api.rendobar.com/dl/job_a1b2c3d4?token=...",
- "path": "output.mp4",
- "type": "video",
- "size": 15234567,
- "meta": { "format": "mp4", "width": 1920, "height": 1080, "durationMs": 127500 }
- }
- ],
+ "files": [{ "url": "https://r2.rendobar.com/...", "path": "output.mp4", "type": "video", "size": 15234567 }],
"expiresAt": 1707440415000
},
"cost": { "amount": 50000000, "currency": "USD", "formatted": "$0.05" },
- "timing": {
- "createdAt": 1707436800000,
- "startedAt": 1707436805000,
- "completedAt": 1707436815000
- }
+ "timing": { "createdAt": 1707436800000, "startedAt": 1707436805000, "completedAt": 1707436815000 }
}
}
```
-The `output` shape is identical for every job type. A data-only job (such as `extract.metadata`) carries its answer in `output.data` with `file` null and `files` empty. A stream job carries the manifest as `output.file` and every segment in `output.files`. See [Job output](/concepts/job#the-output) for each pattern.
+The `output` shape is identical for every job type. A data-only job (such as `ffprobe`) puts its answer in `output.data` with `file` null. See [Job output](/concepts/job#the-output) for each pattern.
-For `job.failed`, `data` carries `error` instead of `output`. The error matches the [GET /jobs](/concepts/job#the-output) error shape: `code`, `message`, `detail` (the process stderr tail, or null), and `retryable`.
+For `job.failed`, `data` carries `error` instead of `output`.
```json
-{
- "version": "1",
- "event": "job.failed",
- "deliveryId": "whd_a4b5c6",
- "timestamp": 1707436810000,
- "orgId": "org_abc123",
- "data": {
- "jobId": "job_a1b2c3d4",
- "jobType": "ffmpeg",
- "status": "failed",
- "error": {
- "code": "RUNNER_ERROR",
- "message": "FFmpeg process exited with code 1",
- "detail": "Conversion failed: Invalid data found when processing input",
- "retryable": false
- },
- "timing": { "createdAt": 1707436800000, "startedAt": 1707436805000, "failedAt": 1707436810000 }
+"data": {
+ "jobId": "job_a1b2c3d4",
+ "jobType": "ffmpeg",
+ "status": "failed",
+ "error": {
+ "code": "RUNNER_ERROR",
+ "message": "FFmpeg process exited with code 1",
+ "detail": "Invalid data found when processing input",
+ "retryable": false
}
}
```
-## Verify the signature
-
-The signature is HMAC-SHA256 over `{timestamp}.{body}` using your webhook secret. Timestamp-prefixed to prevent replays.
-
-
-
-```ts SDK
-import { verifyWebhook } from "@rendobar/sdk/webhooks";
-
-// Pass the raw body (not parsed JSON) and the request headers. verifyWebhook
-// reads the signature and timestamp, rebuilds the signed string, checks the
-// timestamp is recent (replay protection), and handles secret rotation.
-const ok = await verifyWebhook(rawBody, req.headers, process.env.WEBHOOK_SECRET);
-if (!ok) throw new Error("Invalid signature");
-```
-
-```javascript Node.js
-import { createHmac, timingSafeEqual } from "crypto";
-
-function verifyWebhook(body, signature, timestamp, secret) {
- const message = `${timestamp}.${body}`;
- const expected = createHmac("sha256", secret).update(message, "utf8").digest("hex");
- const received = signature.replace("sha256=", "");
- return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(received, "hex"));
-}
-
-app.post("/webhooks/rendobar", (req, res) => {
- const ok = verifyWebhook(
- req.rawBody, // raw string, not parsed JSON
- req.headers["x-rendobar-signature"],
- req.headers["x-rendobar-timestamp"],
- process.env.WEBHOOK_SECRET
- );
- if (!ok) return res.status(401).send("Invalid signature");
- // process req.body...
- res.status(200).send("OK");
-});
-```
-
-```python Python
-import hmac, hashlib
-
-def verify_webhook(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
- message = f"{timestamp}.{body.decode()}"
- expected = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
- received = signature.replace("sha256=", "")
- return hmac.compare_digest(expected, received)
-```
-
-
-
-
-Always use a timing-safe comparison (`timingSafeEqual` / `hmac.compare_digest`). String equality leaks signature bytes through timing.
-
-
-## Secret rotation
-
-Rotation has a 24-hour window. During it, Rendobar sends both `X-Rendobar-Signature` (new secret) and `X-Rendobar-Signature-Previous` (old). Verify against either.
-
-## SSRF protection
+## Delivery and retries
-URLs must be HTTPS. Delivery to private/reserved ranges (`10.x`, `172.16-31.x`, `192.168.x`, `127.x`, `::1`) is blocked.
+If your endpoint does not return `2xx` within 10 seconds, Rendobar retries up to 5 times, doubling the wait each attempt.
-## Retries
-
-If your endpoint doesn't return `2xx` within 10 seconds, Rendobar retries up to 5 times with exponential backoff. The delay doubles each attempt and caps at 5 minutes.
-
-| Retry | Delay before it |
+| Retry | Wait |
|---|---|
| 1st | 10 s |
| 2nd | 20 s |
@@ -223,33 +209,31 @@ If your endpoint doesn't return `2xx` within 10 seconds, Rendobar retries up to
| 4th | 80 s |
| 5th | 160 s |
-After the retries are exhausted the delivery is marked `failed`. A delivery whose status is `failed` or `cancelled` can be re-sent from the dashboard or the API. Inspect history:
+After that the delivery is marked `failed`. Re-send a `failed` or `cancelled` delivery from the card, or from code:
-```bash
-curl "https://api.rendobar.com/webhooks/deliveries?endpointId=YOUR_ENDPOINT_ID" \
- -H "Authorization: Bearer rb_YOUR_KEY"
+```ts
+await client.webhooks.retryDelivery(deliveryId);
```
-An endpoint that fails 10 deliveries in a row is disabled automatically. Update or re-enable it to reset the counter.
+An endpoint that fails 10 deliveries in a row is disabled automatically and flagged in the dashboard. Update or re-enable it to reset the counter.
-## Best practices
+## Secret rotation
+
+Rotate from the card, or with `client.webhooks.rotateSecret(endpointId)`. For 24 hours Rendobar signs with both secrets, sending `X-Rendobar-Signature` (new) and `X-Rendobar-Signature-Previous` (old), so `verifyWebhook` keeps passing while you roll the new one out. After the window the old secret stops signing. An endpoint can rotate once per 24 hours.
-- **Return 200 fast.** Process asynchronously. Long handlers trigger retries → duplicate deliveries.
-- **Deduplicate** on `X-Rendobar-Delivery` or `jobId`. Same event can arrive more than once.
-- **Verify every signature** before reading the body.
+
+Endpoint URLs must be HTTPS. Delivery to private and reserved ranges (`10.x`, `172.16-31.x`, `192.168.x`, `127.x`, `::1`) is blocked to prevent SSRF, so point webhooks at a publicly reachable host.
+
-## See also
+## Best practices
-- [Job output](/concepts/job#the-output): the canonical `output` and `error` shape this payload carries
-- [Job lifecycle](/concepts/job)
-- [FFmpeg](/jobs/ffmpeg)
-- [Error codes](/support/errors)
-- [MCP](/mcp-server): alternative push channel for AI agents
+- **Return 200 fast.** Acknowledge, then process asynchronously. A slow handler trips the 10-second timeout and earns a duplicate delivery.
+- **Deduplicate** on `X-Rendobar-Delivery` (or `data.jobId`). The same event can arrive twice after a retry.
+- **Verify every signature** before you read the body.
## Related
-- [Job lifecycle](/concepts/job): what each status means before `job.completed` fires
-- [Error codes](/support/errors): codes you'll see inside `job.failed` payloads
-- [FFmpeg](/jobs/ffmpeg): the job type that drives most webhook traffic
-- [MCP overview](/mcp-server): alternative push channel for AI agent clients
+- [Job output](/concepts/job#the-output): the `output` and `error` shape this payload carries
+- [Job lifecycle](/concepts/job): what each status means
+- [Error codes](/support/errors): the codes inside `job.failed` payloads
- [Changelog](https://rendobar.com/changelog/): webhook payload changes and new events