> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sigmora.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Get a signed HTTPS request when a job finishes, needs approval, or a post is published, instead of polling.

Webhooks tell your server when work changes state. Sigmora offers two kinds:

| | Per-job callback | Webhook subscription |
| - | - | - |
| Set up with | `callbackUrl` + `callbackSecret` on the request | `POST /v1/webhook-subscriptions` |
| Covers | That one job | Every matching event in the workspace |
| Available on | `POST /v1/video/render`, `POST /v1/video/edit`, `POST /v1/3d/from-text`, `POST /v1/3d/from-image` | Footage edits, media analysis and publishing |
| Delivers | The terminal event | Progress, approval, completion, failure, cancellation and publishing events |

Both are signed the same way. Webhooks complement polling — keep
`GET /v1/jobs/{jobId}` as your recovery path for when your receiver is down.

## Per-job callbacks

Send `callbackUrl` and `callbackSecret` together. The secret is yours: 32 to 512
characters, never echoed back, and used only to sign deliveries to you.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.sigmora.org/v1/video/render \
    -H "Authorization: Bearer $SIGMORA_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: callback-render-0001" \
    -d '{
      "projectId": "prj_9x2a4c",
      "story": "Your document text...",
      "callbackUrl": "https://hooks.example.com/sigmora/events",
      "callbackSecret": "whsec_7cf9f73729a02263c975a3fe92461e07"
    }'
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import os
  import requests

  requests.post(
      "https://api.sigmora.org/v1/video/render",
      headers={
          "Authorization": f"Bearer {os.environ['SIGMORA_API_KEY']}",
          "Idempotency-Key": "callback-render-0001",
      },
      json={
          "projectId": "prj_9x2a4c",
          "story": "Your document text...",
          "callbackUrl": "https://hooks.example.com/sigmora/events",
          "callbackSecret": os.environ["SIGMORA_WEBHOOK_SECRET"],
      },
      timeout=60,
  )
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  await fetch("https://api.sigmora.org/v1/video/render", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SIGMORA_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": "callback-render-0001",
    },
    body: JSON.stringify({
      projectId: "prj_9x2a4c",
      story: "Your document text...",
      callbackUrl: "https://hooks.example.com/sigmora/events",
      callbackSecret: process.env.SIGMORA_WEBHOOK_SECRET,
    }),
  });
  ```
</CodeGroup>

The URL must use HTTPS and resolve only to public addresses. Localhost, private
networks and redirects are rejected.

When the job reaches a terminal state, Sigmora posts the event:

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "schemaVersion": "job-event.v1",
  "eventId": "evt_5c1e9a",
  "event": "render.completed",
  "jobId": "job_4k1b7d",
  "status": "done",
  "error": null,
  "workspaceId": "ws_91c2",
  "artifacts": {
    "videoUrl": "https://...",
    "downloadUrls": { "video": "https://..." }
  }
}
```

`status` is `done`, `cancelled`, `failed` or `qa_failed`. Only `done` carries
artifacts; for the other outcomes `artifacts` is `null`. Poll the job for the
details, and never publish media from a `qa_failed` job.

## Webhook subscriptions

A subscription delivers matching events for the whole workspace to one URL.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  curl -X POST https://api.sigmora.org/v1/webhook-subscriptions \
    -H "Authorization: Bearer $SIGMORA_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: hooks-main-001" \
    -d '{
      "url": "https://hooks.example.com/sigmora/events",
      "events": ["job.awaiting_approval", "job.completed", "job.failed", "publish.completed"],
      "secret": "whsec_7cf9f73729a02263c975a3fe92461e07"
    }'
  ```

  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  subscription = requests.post(
      "https://api.sigmora.org/v1/webhook-subscriptions",
      headers={
          "Authorization": f"Bearer {os.environ['SIGMORA_API_KEY']}",
          "Idempotency-Key": "hooks-main-001",
      },
      json={
          "url": "https://hooks.example.com/sigmora/events",
          "events": ["job.awaiting_approval", "job.completed", "job.failed", "publish.completed"],
          "secret": os.environ["SIGMORA_WEBHOOK_SECRET"],
      },
      timeout=30,
  ).json()["subscription"]
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  const { subscription } = await fetch("https://api.sigmora.org/v1/webhook-subscriptions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.SIGMORA_API_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": "hooks-main-001",
    },
    body: JSON.stringify({
      url: "https://hooks.example.com/sigmora/events",
      events: ["job.awaiting_approval", "job.completed", "job.failed", "publish.completed"],
      secret: process.env.SIGMORA_WEBHOOK_SECRET,
    }),
  }).then((r) => r.json());
  ```
</CodeGroup>

| Event | Sent when |
| - | - |
| `job.progressed` | A job moves forward. |
| `job.awaiting_approval` | A footage edit is waiting for a person to approve its cuts. |
| `job.completed` | A job finished successfully. |
| `artifact.ready` | A finished video job's files are ready. |
| `job.failed` | A job failed or was blocked by a release check. |
| `job.cancelled` | A job was cancelled. |
| `publish.completed` | A queued post was published. |
| `publish.failed` | A queued post failed or was cancelled. |

Subscriptions cover footage edits, media analysis and publishing. For renders
from `POST /v1/video/render` and 3D jobs, use a per-job callback or poll.

Each delivery has this shape; read the full job from `data.resourceUrl`:

```json theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
{
  "schemaVersion": "automation-event.v1",
  "eventId": "awe_3f2c9d1e7a",
  "event": "job.completed",
  "occurredAt": "2026-10-06T09:14:03.000Z",
  "workspaceId": "ws_91c2",
  "data": {
    "jobId": "job_7e3c2f",
    "projectId": "prj_9x2a4c",
    "status": "done",
    "phase": null,
    "sequence": 12,
    "resourceUrl": "/v1/jobs/job_7e3c2f"
  }
}
```

List, update, disable or delete subscriptions with `GET`, `PATCH` and `DELETE`
on `/v1/webhook-subscriptions/{subscriptionId}`. The secret is stored encrypted
and never returned.

## Verify signatures

Every delivery carries these headers:

| Header | Meaning |
| - | - |
| `X-Sigmora-Signature` | `sha256=` followed by the lowercase hex HMAC-SHA256 digest |
| `X-Sigmora-Timestamp` | ISO 8601 timestamp included in the signed message |
| `X-Sigmora-Event` | The event name, such as `render.completed` or `job.completed` |
| `X-Sigmora-Event-Id` | A stable event id, also in the body |
| `X-Sigmora-Job-Id` | Per-job callbacks: the job id |
| `X-Sigmora-Subscription-Id` | Subscriptions: the subscription that matched |

Compute HMAC-SHA256 with your secret over the exact string
`<X-Sigmora-Timestamp>.<raw request body>` and compare it to
`X-Sigmora-Signature` in constant time. Verify the **raw bytes** you received —
parsing and re-serializing the JSON changes them.

<CodeGroup>
  ```python Python theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import hashlib
  import hmac

  def is_valid(raw_body: bytes, timestamp: str, presented: str, secret: str) -> bool:
      digest = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(f"sha256={digest}", presented or "")
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function isValid(rawBody: Buffer, timestamp: string, presented: string, secret: string) {
    const expected = `sha256=${createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex")}`;
    const a = Buffer.from(expected);
    const b = Buffer.from(presented ?? "");
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```
</CodeGroup>

## Handle deliveries

* **Respond fast.** Return any `2xx` as soon as the signature checks out, then do
  the work in your own queue.
* **Expect retries.** Network errors, `408`, `429` and `5xx` responses are retried
  with increasing backoff. Other `4xx` responses are treated as a permanent
  rejection.
* **Deduplicate by event id.** Deliveries are at least once, and an event can be
  replayed during recovery. Store `X-Sigmora-Event-Id` and skip ids you have
  processed; do not rely on the timestamp being recent.
* **Keep polling as a fallback.** If a job stays in flight longer than expected,
  read it with `GET /v1/jobs/{jobId}`.
* **Protect the secret.** Never log it, and act on a delivery only after its
  signature verifies.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.