> ## Documentation Index
> Fetch the complete documentation index at: https://rendobar-docs-compose-ref-tables.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks for job events

> Receive job status events via signed HMAC POST. Setup, payload format, signature verification, 3-attempt retry policy. Available on all plans.

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
__html: JSON.stringify({
  "@context": "https://schema.org",
  "@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.",
  "datePublished": "2026-03-20",
  "dateModified": "2026-05-24",
  "author": { "@type": "Organization", "@id": "https://rendobar.com/#organization" },
  "publisher": { "@type": "Organization", "@id": "https://rendobar.com/#organization" },
  "isPartOf": { "@id": "https://rendobar.com/#website" }
})
}}
/>

Rendobar POSTs JSON to your endpoint when a [job](/concepts/job) changes status. Available on all plans, including Free.

## Set up a webhook

```bash theme={null}
curl -X POST https://api.rendobar.com/webhook-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"]
  }'
```

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.

Manage endpoints (create, update, delete, test) via the API or the dashboard.

## Events

| Event             | When                                                                               |
| ----------------- | ---------------------------------------------------------------------------------- |
| `job.completed`   | Job finished successfully. Carries the unified `output` and `cost`                 |
| `job.failed`      | Job failed. Carries the unified `error` (`code`, `message`, `detail`, `retryable`) |
| `job.running`     | Job started executing on a runner                                                  |
| `batch.completed` | Every job in a batch finished (success or fail)                                    |

## Payload

```
X-Rendobar-Signature: sha256=abc123...
X-Rendobar-Timestamp: 1707436815
X-Rendobar-Event: job.completed
X-Rendobar-Delivery: del_x1y2z3
X-Rendobar-Attempt: 1
```

The webhook carries the same contract as [GET /jobs/{id}](/concepts/job#the-output). A `job.completed` payload includes the unified `output` object. The `data` field has the structured answer for analysis jobs, `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, the File type, and the four output patterns.

```json theme={null}
{
  "event": "job.completed",
  "jobId": "job_a1b2c3d4",
  "output": {
    "data": null,
    "file": {
      "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://api.rendobar.com/dl/job_a1b2c3d4?token=...",
        "path": "output.mp4",
        "type": "video",
        "size": 15234567,
        "meta": { "format": "mp4", "width": 1920, "height": 1080, "durationMs": 127500 }
      }
    ],
    "expiresAt": 1707440415000
  },
  "cost": { "amount": 50000000, "currency": "USD", "formatted": "$0.05" },
  "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.

For `job.failed`, the payload 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`.

```json theme={null}
{
  "event": "job.failed",
  "jobId": "job_a1b2c3d4",
  "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, "completedAt": 1707436810000 }
}
```

## Verify the signature

The signature is HMAC-SHA256 over `{timestamp}.{body}` using your webhook secret. Timestamp-prefixed to prevent replays.

<CodeGroup>
  ```ts SDK theme={null}
  import { verifyWebhookSignature } from "@rendobar/sdk/webhooks";

  // The signature covers `${timestamp}.${rawBody}` (raw string, not parsed JSON).
  const ok = await verifyWebhookSignature(
    `${timestamp}.${rawBody}`,
    signature,                 // X-Rendobar-Signature header
    process.env.WEBHOOK_SECRET,
  );
  if (!ok) throw new Error("Invalid signature");
  ```

  ```javascript Node.js theme={null}
  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 theme={null}
  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)
  ```
</CodeGroup>

<Warning>
  Always use a timing-safe comparison (`timingSafeEqual` / `hmac.compare_digest`). String equality leaks signature bytes through timing.
</Warning>

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

URLs must be HTTPS. Delivery to private/reserved ranges (`10.x`, `172.16-31.x`, `192.168.x`, `127.x`, `::1`) is blocked.

## Retries

If your endpoint doesn't return `2xx` within 10 seconds, Rendobar retries:

| Attempt | Delay |
| ------- | ----- |
| 1st     | 5 s   |
| 2nd     | 25 s  |
| 3rd     | 125 s |

After three failures the delivery is marked failed. Inspect history:

```bash theme={null}
curl https://api.rendobar.com/webhook-endpoints/YOUR_ENDPOINT_ID/deliveries \
  -H "Authorization: Bearer rb_YOUR_KEY"
```

## Best practices

* **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.

## See also

* [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

## 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
* [Changelog](https://rendobar.com/changelog/): webhook payload changes and new events
