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

# Webhooks

> Get a signed POST when an async video or image job ends, instead of polling for it.

Video generation and async image generation run in the background. Instead of
polling `status_url` until the job ends, you can have Opper send a signed
`POST` to your endpoint when it does. The event carries the same object the
status poll returns, so one handler covers both.

> [!NOTE]
>
> Webhooks are for async jobs only: [`POST /v3/videos`](https://docs.opper.ai/build/multimodal/video),
> and [`POST /v3/images`](https://docs.opper.ai/build/multimodal/images#heavy-jobs-async) with
> `"async": true`. A synchronous image request that sets `callback_url` is
> refused with a `400` (set `async: true`).

## How it works

1. **Point a job at your endpoint**: pass `callback_url` on the request, or save a default webhook URL for the project.
2. **Opper runs the job** and writes its final status, as it always does.
3. **Opper POSTs a signed event** (`video.completed`, `image.failed`, and so on) to your URL, and retries it if your endpoint does not answer `2xx`.
4. **Your endpoint verifies the signature**, answers `2xx` within 10 seconds, then downloads the result from `data.url`.

## Choose where events go

There are two ways to get a webhook. A `callback_url` on the request always wins.

### `callback_url` on the request

Add `callback_url` to the request body. That job, and only that job, reports there.

> [tab] Video
>
> ```bash theme={null}
> curl -sX POST https://api.opper.ai/v3/videos \
>   -H "Authorization: Bearer $OPPER_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d '{
>     "model": "xai/grok-imagine-video",
>     "prompt": "a paper boat drifting down a stream",
>     "callback_url": "https://example.com/webhooks/opper"
>   }'
> # → 202 { "id": "gen_...", "status_url": "https://api.opper.ai/v3/artifacts/gen_.../status" }
> ```

> [tab] Image (async)
>
> ```bash theme={null}
> curl -sX POST https://api.opper.ai/v3/images \
>   -H "Authorization: Bearer $OPPER_API_KEY" \
>   -H "Content-Type: application/json" \
>   -d '{
>     "model": "openai/gpt-image-2.5-sunburst",
>     "prompt": "an oil painting of a lighthouse in a winter storm",
>     "quality": "max",
>     "async": true,
>     "callback_url": "https://example.com/webhooks/opper"
>   }'
> ```

### A default URL for the project

Save a default webhook URL once, and every async job in the project that sets
no `callback_url` reports there. A project has one webhook.

* **In the platform**: open **Settings > Webhooks** in the [Opper platform](https://platform.opper.ai), choose **Add webhook**, pick the project and enter the endpoint URL.
* **Over the API**: `PATCH /v3/webhooks/settings` with a key of the project. The key's owner must be an organization admin or owner, otherwise the call is a `403`.

```bash theme={null}
# Set the default URL
curl -sX PATCH https://api.opper.ai/v3/webhooks/settings \
  -H "Authorization: Bearer $OPPER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "default_url": "https://example.com/webhooks/opper" }'
# → { "meta": {}, "data": { "default_url": "https://example.com/webhooks/opper", "default_url_updated_at": "..." } }

# Read it (any key of the project)
curl -s https://api.opper.ai/v3/webhooks/settings \
  -H "Authorization: Bearer $OPPER_API_KEY"

# Clear it
curl -sX PATCH https://api.opper.ai/v3/webhooks/settings \
  -H "Authorization: Bearer $OPPER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "default_url": null }'
```

> [!WARNING]
>
> A saved default takes effect right away: from then on, every async video and
> image job in the project that sets no `callback_url` sends its webhook there.
> Have your endpoint ready (and [send a test event](#send-a-test-event)) before you save it.

### URL rules

Opper checks the URL when you submit the job or save the default, and again
before every delivery attempt. A URL that breaks a rule is a `400` that names the rule.

* `https` only, on port 443 (or no port).
* No credentials (`user:password@`) and no `#fragment`.
* At most 2048 characters.
* The host is a public IP address, or a public name that resolves only to
  public addresses. Localhost, private, link-local, CGNAT and cloud metadata
  addresses are refused, and so are `.internal` and `.local` names.

## The event

Opper sends the event after the job's final status is written, so a `GET` of
`status_url` when you receive it shows the same state.

```json video.completed theme={null}
{
  "type": "video.completed",
  "id": "gen_...",
  "created_at": "2026-10-30T12:00:00Z",
  "status_url": "https://api.opper.ai/v3/artifacts/gen_.../status",
  "data": {
    "status": "completed",
    "id": "gen_...",
    "url": "https://...presigned...",
    "expires_at": "2026-10-30T13:00:01Z",
    "mime_type": "video/mp4",
    "usage": { "cost": 0.4, "seconds": 8 }
  }
}
```

| Field | Meaning |
| - | - |
| `type` | The event type, see the table below. |
| `id` | The generation id, the same `id` the submit call returned. |
| `created_at` | When the event was made. |
| `status_url` | The job's status URL, for a fresh download link or the delivery state. |
| `data` | The same object `GET {status_url}` returns: `status`, `url` and `expires_at`, `mime_type`, `usage`, `file_id` (when the job set `"store": true`), or `error` and `error_type` for a failed job. |

The event carries no prompt and no media bytes. `data.url` is a presigned link
that expires after an hour (`expires_at`). If you download later, `GET`
`status_url` for a fresh one.

### Event types

| Type | Sent when |
| - | - |
| `video.completed` | A video job finished and its result is ready to download. |
| `video.failed` | A video job failed. `data.error` and `data.error_type` say why. |
| `image.completed` | An async image job finished. |
| `image.failed` | An async image job failed. |
| `webhook.test` | You sent a [test event](#send-a-test-event). It has no `status_url`, and `data` holds only a `message`. |

A job that stops before it ends (for example, the worker running it went away)
is marked failed with `error_type` `interrupted`, and sends its `video.failed`
or `image.failed` event then.

## Verify the signature

Every delivery is signed the [Standard Webhooks](https://www.standardwebhooks.com)
way, so any Standard Webhooks library verifies it. Each request carries three headers:

| Header | Value |
| - | - |
| `webhook-id` | `<generation id>:<event type>`, for example `gen_abc:video.completed`. The same on every retry, so dedupe on it. |
| `webhook-timestamp` | The attempt's time, in Unix seconds. |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` over `<webhook-id>.<webhook-timestamp>.<body>`. During a [rotation](#rotate-the-secret) it holds two signatures separated by a space. |

The HMAC key is the base64-decoded part of the secret after `whsec_`. The
libraries handle this, refuse timestamps more than 5 minutes off, and accept a
request when any of its signatures match.

> [tab] TypeScript
>
> ```typescript theme={null}
> // npm install standardwebhooks express
> import express from "express";
> import { Webhook } from "standardwebhooks";
>
> const wh = new Webhook(process.env.OPPER_WEBHOOK_SECRET!); // whsec_...
> const app = express();
>
> // Verify the raw body: parsing it first changes the bytes that were signed.
> app.post("/webhooks/opper", express.raw({ type: "application/json" }), (req, res) => {
>   let event: any;
>   try {
>     event = wh.verify(req.body, req.headers as Record<string, string>);
>   } catch {
>     return res.status(400).end();
>   }
>   // event.type: video.completed, video.failed, image.completed, image.failed, webhook.test
>   // Answer 2xx within 10 seconds, then do the work.
>   res.status(204).end();
> });
>
> app.listen(3000);
> ```

> [tab] Python
>
> ```python theme={null}
> # pip install standardwebhooks flask
> import os
> from flask import Flask, request
> from standardwebhooks.webhooks import Webhook
>
> wh = Webhook(os.environ["OPPER_WEBHOOK_SECRET"])  # whsec_...
> app = Flask(__name__)
>
> @app.post("/webhooks/opper")
> def opper_webhook():
>     try:
>         # Verify the raw body, before any JSON parsing.
>         event = wh.verify(request.get_data(), dict(request.headers))
>     except Exception:
>         return "", 400
>     # event["type"]: video.completed, video.failed, image.completed, image.failed, webhook.test
>     # Answer 2xx within 10 seconds, then do the work.
>     return "", 204
> ```

> [!TIP]
>
> Answer first, work later. Queue the event (or hand it to a background task)
> and return `2xx` straight away; downloading a video inside the handler can
> outrun the 10 second limit and trigger a retry.

## The signing secret

Each project has one signing secret (`whsec_...`) that signs all its webhooks.
Keep it server-side: whoever holds it can sign a request your endpoint accepts.

* **In the platform**: **Settings > Webhooks**, open the project's webhook, then **Reveal** or **Copy** the secret.
* **Over the API**: `GET /v3/webhooks/secret` with a key whose owner is an organization admin or owner. The secret is made on first use.

```bash theme={null}
curl -s https://api.opper.ai/v3/webhooks/secret \
  -H "Authorization: Bearer $OPPER_API_KEY"
# → { "meta": {}, "data": { "secret": "whsec_...", "created_at": "..." } }
```

### Rotate the secret

Rotate from the webhook's page in **Settings > Webhooks**, or with
`POST /v3/webhooks/secret/rotate` (the key's owner must be an organization
admin or owner, otherwise `403`).

```bash theme={null}
curl -sX POST https://api.opper.ai/v3/webhooks/secret/rotate \
  -H "Authorization: Bearer $OPPER_API_KEY"
# → { "meta": {}, "data": { "secret": "whsec_...new", "created_at": "...", "previous_valid_until": "..." } }
```

For 24 hours after a rotation (until `previous_valid_until`), every webhook
carries two signatures, one made with the new secret and one with the
previous. Deploy the new secret to your receiver within that window and no
delivery is dropped. Rotating again within the 24 hours retires the previous
secret at once.

## Delivery and retries

Answer with a `2xx` status within **10 seconds**. Redirects are not followed.

| Your endpoint | What Opper does |
| - | - |
| `2xx` | Delivered. |
| Times out, network error, DNS lookup that gets no answer | Retries. |
| `408`, `429` or `5xx` | Retries. |
| `3xx` or any other `4xx` | Stops: the delivery has failed. |

A webhook gets up to **8 attempts** in all, waiting about 1 minute, 5 minutes,
30 minutes, 1 hour, 3 hours, 6 hours and 12 hours between them (about 23 hours
in total). Every attempt has a new `webhook-timestamp` and signature, and the
same `webhook-id`, so make your handler idempotent on `webhook-id`: you may get
an event more than once.

### Check a delivery

The status poll reports the delivery under `webhook` once the job has ended:

```bash theme={null}
curl -s https://api.opper.ai/v3/artifacts/gen_XXX/status \
  -H "Authorization: Bearer $OPPER_API_KEY"
```

```json theme={null}
{
  "status": "completed",
  "id": "gen_...",
  "url": "https://...presigned...",
  "webhook": {
    "status": "pending",
    "event": "video.completed",
    "attempts": 2,
    "last_status_code": 503,
    "last_error": "receiver answered 503",
    "last_attempt_at": "2026-10-30T12:01:05Z",
    "next_attempt_at": "2026-10-30T12:06:05Z"
  }
}
```

`webhook.status` is `pending` (still trying), `delivered` or `failed`.
`last_status_code` is absent when the last attempt got no answer. The
**Settings > Webhooks** list in the platform shows each webhook's delivered,
failed and retrying counts for the last 7 days.

> [!NOTE]
>
> The status poll keeps working whether or not the webhook arrives, so polling
> `status_url` remains a fallback for a job whose webhook failed.

## Send a test event

Check that your endpoint is reachable and verifies signatures before you rely
on it. A test sends one signed `webhook.test` event now, with no job and no
retries.

* **In the platform**: **Send a test event** on the webhook's page in **Settings > Webhooks**.
* **Over the API**: `POST /v3/webhooks/test` with any key of the project.

```bash theme={null}
curl -sX POST https://api.opper.ai/v3/webhooks/test \
  -H "Authorization: Bearer $OPPER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "callback_url": "https://example.com/webhooks/opper" }'
# → { "meta": {}, "data": { "delivered": true, "status_code": 204, "webhook_id": "test_...:webhook.test", "duration_ms": 142 } }
```

The answer is a `200` whether or not your endpoint took the event: `delivered`
is `true` for a `2xx`, `status_code` is what your endpoint answered, and
`error` says what went wrong otherwise. The URL goes through the same
[rules](#url-rules) as a job's (`400` names the rule), and a project can send
10 test events a minute (`429` past that).

## Settings > Webhooks

The **Settings > Webhooks** page in the [Opper platform](https://platform.opper.ai)
is where admins and owners manage webhooks without the API:

* **The list** shows every project with a webhook: its URL, whether it is on, and the last 7 days of deliveries (delivered, failed, retrying).
* **Add webhook** picks a project that has none yet and saves its endpoint URL. It is on at once.
* **The webhook's page** edits the URL, reveals, copies and rotates the signing secret, sends a test event, and removes the webhook. Removing clears the URL and keeps the secret.

Members who are not admins or owners see who manages webhooks instead. An
admin hands out the secret, like an API key.

## Reference

| Method | Path | Who |
| - | - | - |
| `GET` | `/v3/webhooks/secret` | A key whose owner is an organization admin or owner. |
| `POST` | `/v3/webhooks/secret/rotate` | A key whose owner is an organization admin or owner. |
| `GET` | `/v3/webhooks/settings` | Any key of the project. |
| `PATCH` | `/v3/webhooks/settings` | A key whose owner is an organization admin or owner. |
| `POST` | `/v3/webhooks/test` | Any key of the project. 10 a minute per project, 30 per organization. |

`callback_url` is a field of [Generate a video](https://docs.opper.ai/v3-api-reference/video/create-video.md)
and [Generate an image](https://docs.opper.ai/v3-api-reference/images/create-image.md).

## What's next

> [card] [Video](https://docs.opper.ai/build/multimodal/video.md)
>
> Submit a video job and get its result.

> [card] [Images](https://docs.opper.ai/build/multimodal/images.md)
>
> Generate images, synchronously or as an async job.


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