# Webhooks (/api/webhooks)



Stop polling. Register one HTTPS endpoint and RiffAds POSTs a signed event to it when a generation, a batch or a workflow run settles, or when your credits run low.

<Boundary title="An event reports your own work. Nothing is ever posted.">
  RiffAds never publishes to Meta, TikTok, YouTube, X or any other platform, and no event asks it to. A delivery carries ids and facts about work in your workspace. What happens to the file next is up to you.
</Boundary>

## The three routes [#the-three-routes]

| Endpoint                | Scope    | Rate limit per key             | What it does                                             |
| ----------------------- | -------- | ------------------------------ | -------------------------------------------------------- |
| `GET /webhooks`         | Read     | 120 a minute (`agent_read`)    | Lists this workspace's endpoints. Never the secret       |
| `POST /webhooks`        | Generate | 20 a minute (`webhook_manage`) | Registers one endpoint. Answers the signing secret, once |
| `DELETE /webhooks/{id}` | Generate | 20 a minute (`webhook_manage`) | Removes one endpoint and its pending deliveries          |

* None of them costs credits.
* At most **10 endpoints** per workspace.
* Registering an endpoint needs the **Generate** scope, not a scope of its own. A read-only key can list endpoints but not change them. [Authentication](/api/authentication).
* Owners and admins can do more in the app at [app.riffads.com/settings/webhooks](https://app.riffads.com/settings/webhooks): send a test event, rotate the secret, disable or enable an endpoint, and read the delivery history. Those are app only. No API route does them.

## Register an endpoint [#register-an-endpoint]

<Endpoint method="POST" path="/webhooks" />

```bash title="Terminal"
curl -s -X POST https://app.riffads.com/api/v1/webhooks \
  -H "Authorization: Bearer $RIFFADS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/riffads",
    "events": ["generation.completed", "generation.failed"],
    "description": "Production render worker"
  }'
```

### Request body [#request-body]

Strict. An unknown key is `400 invalid_config`.

<TypeTable
  type="{
  url: {
    type: 'string',
    required: true,
    description: 'Where deliveries go. 1 to 2048 characters. Public https only: no username or password in it, and not a private or local address.',
  },
  events: {
    type: 'string[]',
    required: true,
    description: 'One or more event names from the table below. An unknown name is refused.',
  },
  description: {
    type: 'string',
    required: false,
    description: 'Up to 200 characters, for people. Name the system that receives it.',
  },
}"
/>

### Response [#response]

`201`, no `Location` header.

```json title="Response: 201"
{
  "ok": true,
  "webhook": {
    "webhook_id": "wh_0193c8f0a1b24e7f9d3c5a6b7e8f00b1",
    "url": "https://hooks.example.com/riffads",
    "events": ["generation.completed", "generation.failed"],
    "status": "active",
    "consecutive_failures": 0,
    "description": "Production render worker",
    "created_at": "2026-09-17T10:00:02.114Z",
    "updated_at": "2026-09-17T10:00:02.114Z"
  },
  "secret": "whsec_3kq0aYkFbJ0xw3Nl9Hk9vS8Q6Hn2bT1mY5cR7dP4eWg="
}
```

<Callout type="warn" title="The secret is shown once">
  `secret` appears in this answer and nowhere else. The list, the delete answer and the settings page never show it again. Store it in your secret manager now. Lost it? Delete the endpoint and register a new one, or ask an owner or admin to rotate it in the app.
</Callout>

### The endpoint object [#the-endpoint-object]

| Field                      | Type                                | Notes                                                                   |
| -------------------------- | ----------------------------------- | ----------------------------------------------------------------------- |
| `webhook_id`               | string                              | `wh_` prefix                                                            |
| `url`                      | string                              | As stored, after normalizing                                            |
| `events`                   | string\[]                           | What this endpoint receives                                             |
| `status`                   | `active` \| `failing` \| `disabled` | See [endpoint health](#endpoint-health)                                 |
| `consecutive_failures`     | integer                             | Deliveries in a row that used every attempt. One success resets it to 0 |
| `description`              | string \| null                      |                                                                         |
| `created_at`, `updated_at` | string                              | ISO 8601                                                                |

### Register errors [#register-errors]

| Cause                                                           | Code                 | HTTP | Retry |
| --------------------------------------------------------------- | -------------------- | ---- | ----- |
| Body fails the schema, an unknown key, or an unknown event name | `invalid_config`     | 400  | no    |
| URL is not public https, or its hostname does not resolve       | `invalid_config`     | 400  | no    |
| Description over 200 characters                                 | `invalid_config`     | 400  | no    |
| The workspace already has 10 endpoints                          | `invalid_config`     | 400  | no    |
| Key lacks Generate                                              | `insufficient_scope` | 403  | no    |
| Over 20 changes a minute on this key                            | `rate_limited`       | 429  | yes   |
| Signing is not set up on our side                               | `internal_error`     | 500  | yes   |

The message names the problem. Every other key refusal (401, 410, plan) is the same as on any route: [authentication](/api/authentication).

## List endpoints [#list-endpoints]

<Endpoint method="GET" path="/webhooks" />

No params, no paging. Newest first, 10 at most.

```json title="Response: 200"
{
  "ok": true,
  "webhooks": [
    {
      "webhook_id": "wh_0193c8f0a1b24e7f9d3c5a6b7e8f00b1",
      "url": "https://hooks.example.com/riffads",
      "events": ["generation.completed", "generation.failed"],
      "status": "failing",
      "consecutive_failures": 4,
      "description": "Production render worker",
      "created_at": "2026-09-17T10:00:02.114Z",
      "updated_at": "2026-09-19T08:12:40.502Z"
    }
  ]
}
```

## Delete an endpoint [#delete-an-endpoint]

<Endpoint method="DELETE" path="/webhooks/{id}" />

```json title="Response: 200"
{
  "ok": true,
  "webhook_id": "wh_0193c8f0a1b24e7f9d3c5a6b7e8f00b1",
  "deleted": true
}
```

* `200` with a body, never `204`.
* Pending deliveries are removed with it. Nothing is sent to it again.
* Another workspace's id, and one already deleted, both answer `404 not_found`. A second delete is a 404, which means it is gone.

## Events [#events]

Subscribe to any of these six:

| Event                    | Fires when                                                                        | `data`                             |
| ------------------------ | --------------------------------------------------------------------------------- | ---------------------------------- |
| `generation.completed`   | A generation completed and its charge was captured                                | [A generation](#generation-events) |
| `generation.failed`      | A generation failed and its hold was released                                     | [A generation](#generation-events) |
| `batch.settled`          | Every sibling of a `variants` submit that fanned out (a video model) has finished | [A batch](#batchsettled)           |
| `workflow_run.completed` | A run ended `completed` or `partial`                                              | [A run](#workflow-run-events)      |
| `workflow_run.failed`    | A run ended `failed` or `canceled`                                                | [A run](#workflow-run-events)      |
| `credits.low`            | Available credits fell below the threshold, once per refill                       | [Credits](#creditslow)             |

One more type exists: `webhook.test`. It is sent only when someone presses **Send test** in the app, to that one endpoint. It is not subscribable and it is signed like every other event. Its `data` is `webhook_id` plus a `message`. Accept it with a 2XX and ignore it.

<Callout type="info" title="Every generation fires, not only the one you asked for">
  Batch siblings and workflow steps are generations too, and each one is charged, so each one sends its own `generation.*` event. `data.purpose` says why the row exists: `deliverable`, `preview` or `intermediate`. Want only finished work? Act on `deliverable`.
</Callout>

## The delivery [#the-delivery]

Every delivery is one POST with a JSON body and these headers:

```text title="Request headers"
POST /riffads HTTP/1.1
content-type: application/json
user-agent: RiffAds-Webhooks/1.0
webhook-id: whd_0193c8f0a1b24e7f9d3c5a6b7e8f00c1
webhook-timestamp: 1758103659
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
```

| Header              | What it is                                                                                        |
| ------------------- | ------------------------------------------------------------------------------------------------- |
| `webhook-id`        | The delivery id (`whd_`). The same on every retry, and equal to the body's `id`. **Dedupe on it** |
| `webhook-timestamp` | Unix seconds of **this attempt**. A retry hours later carries a fresh one                         |
| `webhook-signature` | `v1,` then the base64 HMAC SHA-256 of `{id}.{timestamp}.{body}`, keyed with your signing secret   |
| `user-agent`        | `RiffAds-Webhooks/1.0`. Allow it by name if a firewall sits in front                              |

This is the [Standard Webhooks](https://www.standardwebhooks.com) scheme, so the `standardwebhooks` library checks it as it is. There is no `x-riffads-signature` header and no other scheme.

### The envelope [#the-envelope]

```json title="Body"
{
  "id": "whd_0193c8f0a1b24e7f9d3c5a6b7e8f00c1",
  "type": "generation.completed",
  "created_at": "2026-09-17T10:07:39.120Z",
  "data": { }
}
```

| Field        | Notes                                                                 |
| ------------ | --------------------------------------------------------------------- |
| `id`         | Same value as `webhook-id`                                            |
| `type`       | The event name                                                        |
| `created_at` | When the **event** happened, ISO 8601. Not when this attempt was sent |
| `data`       | One of the shapes below                                               |

## Verify every delivery [#verify-every-delivery]

Check the signature on the **raw bytes** before you parse or act on anything. Install the library:

```bash title="Terminal"
npm install standardwebhooks
```

<Tabs items="['Next.js route handler','Express']">
  <Tab value="Next.js route handler">
    ```ts title="app/api/riffads/route.ts"
    import { Webhook, WebhookVerificationError } from "standardwebhooks";

    // The whsec_... string exactly as POST /webhooks returned it.
    const verifier = new Webhook(process.env.RIFFADS_SIGNING_SECRET!);

    export async function POST(request: Request): Promise<Response> {
        // The signature covers the exact bytes sent. Read the body as text and
        // never re-serialize it before checking.
        const rawBody = await request.text();
        const headers = {
            "webhook-id": request.headers.get("webhook-id") ?? "",
            "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
            "webhook-signature": request.headers.get("webhook-signature") ?? "",
        };

        let event: { id: string; type: string; created_at: string; data: any };
        try {
            // Checks the signature, and refuses a timestamp more than
            // 5 minutes old (or 5 minutes in the future): that is a replay.
            event = verifier.verify(rawBody, headers) as typeof event;
        } catch (error) {
            if (error instanceof WebhookVerificationError) {
                return new Response("invalid signature", { status: 400 });
            }
            throw error;
        }

        // A retry resends the same webhook-id. Handle each id once.
        const firstTime = await markDeliverySeen(headers["webhook-id"]);
        if (!firstTime) return new Response(null, { status: 204 });

        // Answer within 10 seconds. Queue the slow part (reading the
        // generation, downloading the file) instead of doing it here.
        await enqueueJob(event);
        return new Response(null, { status: 204 });
    }

    // Your own storage. Make it atomic, such as an insert on a unique
    // column, so two copies arriving together are not both handled.
    declare function markDeliverySeen(webhookId: string): Promise<boolean>;
    declare function enqueueJob(event: unknown): Promise<void>;
    ```
  </Tab>

  <Tab value="Express">
    ```ts title="server.ts"
    import express from "express";
    import { Webhook, WebhookVerificationError } from "standardwebhooks";

    const app = express();
    const verifier = new Webhook(process.env.RIFFADS_SIGNING_SECRET!);

    // express.raw keeps the body as a Buffer. A JSON body parser on this route
    // re-serializes the body and every signature check fails.
    app.post("/riffads", express.raw({ type: "application/json" }), async (req, res) => {
        let event: { id: string; type: string; data: any };
        try {
            event = verifier.verify(req.body, req.headers as Record<string, string>) as typeof event;
        } catch (error) {
            if (error instanceof WebhookVerificationError) return res.status(400).end();
            throw error;
        }

        const webhookId = req.header("webhook-id")!;
        if (!(await markDeliverySeen(webhookId))) return res.status(204).end();

        await enqueueJob(event);
        res.status(204).end();
    });

    declare function markDeliverySeen(webhookId: string): Promise<boolean>;
    declare function enqueueJob(event: unknown): Promise<void>;
    ```
  </Tab>
</Tabs>

What the receiver must do:

1. **Verify on the raw body.** Parse only after `verify` passes. It returns the parsed body.
2. **Refuse a stale timestamp.** `verify` refuses one older than 5 minutes. Keep your server clock in sync.
3. **Dedupe on `webhook-id`.** A retry, or a copy after a timeout on your side, carries the same id.
4. **Answer any 2XX within 10 seconds.** The response body is never read.
5. **Keep the secret server side.** Rotating it in the app takes effect at once, for pending retries too, so deploy the new one right away.

## Retries [#retries]

Anything but a 2XX within 10 seconds is a failed attempt: a 4XX or 5XX, a timeout, a dropped connection, or a redirect.

| Attempt | Sent                                                                  |
| ------- | --------------------------------------------------------------------- |
| 1       | Right away                                                            |
| 2       | About 5 minutes after attempt 1                                       |
| 3       | About 30 minutes after attempt 2                                      |
| 4       | About 2 hours after attempt 3                                         |
| 5       | About 12 hours after attempt 4, so roughly 14.5 hours after the event |

* After attempt 5 the delivery is **exhausted**. It is never sent again.
* **Redirects are not followed.** A 301, 302, 307 or 308 counts as a failure. Register the final URL.
* The URL is checked again at every attempt. A hostname that now points at a private address fails.
* Deliveries can arrive out of order, and a retry can land after a newer event. Order by the envelope's `created_at`, and read the resource for its current state.

### Endpoint health [#endpoint-health]

Counted per **exhausted delivery**, not per attempt. An hour of downtime costs some retries, not the endpoint.

| `status`   | When                             | What happens                                                  |
| ---------- | -------------------------------- | ------------------------------------------------------------- |
| `active`   | Normal                           | Receives events                                               |
| `failing`  | 3 exhausted deliveries in a row  | Still receives events. `consecutive_failures` shows the count |
| `disabled` | 20 exhausted deliveries in a row | Receives nothing                                              |

* One 2XX resets the count to 0 and a `failing` endpoint to `active`.
* Only an owner or admin can enable a `disabled` endpoint, in the app. Events from while it was disabled are **not replayed**.
* A failure on our side never counts against your endpoint.

## Payloads carry ids, not links [#payloads-carry-ids-not-links]

No delivery body contains a URL. A body is stored once and resent for hours, and a file link lives 10 minutes. So a body carries the ids and the money, and you fetch the file when you want it:

* `GET /generations/{id}` returns `outputs[].url`, a link that lives 10 minutes. [Generations](/api/generations).
* `GET /assets/{asset_id}/download` returns a fresh 10 minute link for any file in the library. [Library](/api/library).

Text results (`output_text`, and `result` for `analyze_media`) are not in the body either. Read them from `GET /generations/{id}`.

<Callout type="warn" title="The later event wins">
  A generation can send `generation.failed` and later `generation.completed` for the same `generation_id`, when a render that was written off is recovered and charged. The later one is the truth. Before you act on a generation event, read `GET /generations/{id}`.
</Callout>

## Generation events [#generation-events]

`generation.completed` and `generation.failed`.

```json title="Body: generation.completed"
{
  "id": "whd_0193c8f0a1b24e7f9d3c5a6b7e8f00c1",
  "type": "generation.completed",
  "created_at": "2026-09-17T10:07:39.120Z",
  "data": {
    "generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
    "capability_id": "veo_31",
    "status": "completed",
    "output_kind": "video",
    "outputs": [
      { "index": 0, "asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0044", "kind": "video" }
    ],
    "outputs_expected": 1,
    "outputs_delivered": 1,
    "credits": {
      "credits_held": 440,
      "credits_charged": 400,
      "settlement": "captured",
      "terminal": true
    },
    "charge_summary": "Charged 400 credits.",
    "batch_group_id": null,
    "batch_index": null,
    "workflow_run_id": null,
    "purpose": "deliverable",
    "error": null,
    "created_at": "2026-09-17T10:04:11.482Z",
    "completed_at": "2026-09-17T10:07:39.120Z",
    "disclosure": "RiffAds never publishes anything on your behalf. Every file this event refers to is AI generated: disclose that wherever you publish it."
  }
}
```

Numbers are examples.

| Field                                   | Type                                                 | Notes                                                                          |
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ |
| `generation_id`                         | string                                               | Read it with `GET /generations/{id}`                                           |
| `capability_id`                         | string \| null                                       |                                                                                |
| `status`                                | `completed` \| `failed`                              | A canceled job reports `failed`, as on a read                                  |
| `output_kind`                           | `image` `video` `audio` `text` \| null               |                                                                                |
| `outputs[]`                             | array                                                | `{ index, asset_id, kind }`. No link. Empty on a failure and for a text result |
| `outputs_expected`, `outputs_delivered` | integer \| null                                      |                                                                                |
| `credits`                               | object                                               | `credits_held`, `credits_charged`, `settlement`, `terminal`. No wallet balance |
| `charge_summary`                        | string                                               | One readable sentence                                                          |
| `batch_group_id`, `batch_index`         | string \| null, integer \| null                      | Set for a sibling of a `variants` submit that fanned out                       |
| `workflow_run_id`                       | string \| null                                       | Set for a workflow step                                                        |
| `purpose`                               | `deliverable` \| `preview` \| `intermediate` \| null | Why the row exists                                                             |
| `error`                                 | object \| null                                       | `{ code, message }` on `generation.failed`, else `null`                        |
| `created_at`, `completed_at`            | string, string \| null                               | ISO 8601                                                                       |
| `disclosure`                            | string                                               | A fixed sentence: RiffAds publishes nothing, and the file is AI generated      |

## batch.settled [#batchsettled]

Fires once, when no sibling of a `variants` submit is still running.

Only a submit that fanned out into siblings (a video model, with a `batch_group_id`) has a batch. An image model makes every take in one generation, so its `variants` send `generation.completed` or `generation.failed` and never `batch.settled`. Subscribe to `generation.*` too if you submit image variants.

```json title="Body: batch.settled (data trimmed)"
{
  "id": "whd_0193c8f0a1b24e7f9d3c5a6b7e8f00c9",
  "type": "batch.settled",
  "created_at": "2026-09-17T10:09:02.310Z",
  "data": {
    "batch_group_id": "bg_0193c8f0a1b24e7f9d3c5a6b7e8f0099",
    "status": "partial",
    "variants": 4,
    "variants_finished": 4,
    "outputs_delivered": 3,
    "credits": {
      "credits_held": 168,
      "credits_charged": 126,
      "settlement": "captured",
      "terminal": true
    },
    "charge_summary": "Charged 126 credits for the 3 of 4 outputs delivered.",
    "generations": [
      { "generation_id": "gen_0193...a1", "batch_index": 0, "status": "completed" }
    ],
    "disclosure": "RiffAds never publishes anything on your behalf. Every file this event refers to is AI generated: disclose that wherever you publish it."
  }
}
```

* `status` is `completed`, `failed` or `partial`. Never `running`.
* Each `generations[]` entry has the generation event shape, without `disclosure`.
* Each sibling also sends its own `generation.*` event if you subscribe to those.

## Workflow run events [#workflow-run-events]

`workflow_run.completed` carries `status` `completed` or `partial`. `workflow_run.failed` carries `failed` or `canceled`. The event name says which side of the line, `status` says exactly where.

```json title="Body: workflow_run.completed (nodes trimmed)"
{
  "id": "whd_0193c8f0a1b24e7f9d3c5a6b7e8f00d4",
  "type": "workflow_run.completed",
  "created_at": "2026-09-17T10:12:02.900Z",
  "data": {
    "workflow_run_id": "wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077",
    "workflow_id": "wf_0193c8f0a1b24e7f9d3c5a6b7e8f0055",
    "status": "completed",
    "credits": {
      "credits_estimated": 742,
      "credits_charged": 402,
      "settlement": "captured",
      "terminal": true
    },
    "nodes": [
      {
        "node_id": "f_video",
        "label": "The face cam",
        "status": "completed",
        "credits": 400,
        "generation_ids": ["gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033"],
        "error": null
      }
    ],
    "started_at": "2026-09-17T10:04:11.482Z",
    "finished_at": "2026-09-17T10:12:02.900Z",
    "disclosure": "RiffAds never publishes anything on your behalf. Every file this event refers to is AI generated: disclose that wherever you publish it."
  }
}
```

* `nodes[].generation_ids` lists ids only. Read the run with `GET /workflow-runs/{id}` for files and text. [Workflows API](/api/workflows).
* `nodes[].error` is `{ code, message }` or `null`.

## credits.low [#creditslow]

```json title="Body: credits.low"
{
  "id": "whd_0193c8f0a1b24e7f9d3c5a6b7e8f00e2",
  "type": "credits.low",
  "created_at": "2026-09-18T16:25:00.000Z",
  "data": { "available_credits": 640, "threshold_credits": 1000 }
}
```

* Fires once per refill: a wallet that stays low does not fire again until credits are added and it runs low a second time.
* Checked every few minutes, not at the moment of a spend.
* `threshold_credits` is the line it crossed. Top up in the app, at [app.riffads.com/settings/billing](https://app.riffads.com/settings/billing).

## Worked example: know when a video is ready [#worked-example-know-when-a-video-is-ready]

<Steps>
  <Step>
    ### Register once [#register-once]

    `POST /webhooks` with `events: ["generation.completed", "generation.failed"]`. Store `secret`.
  </Step>

  <Step>
    ### Submit as usual [#submit-as-usual]

    `POST /generations`. Keep the `generation_id`. [Generations](/api/generations).
  </Step>

  <Step>
    ### Receive, verify, dedupe [#receive-verify-dedupe]

    A `generation.completed` delivery arrives. Verify it, check `webhook-id` is new, answer `204`, queue a job.
  </Step>

  <Step>
    ### Fetch the file in the job [#fetch-the-file-in-the-job]

    ```bash title="Terminal"
    curl -s https://app.riffads.com/api/v1/generations/gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033 \
      -H "Authorization: Bearer $RIFFADS_API_KEY"
    ```

    Take `generation.outputs[0].url` and download it now. It lives 10 minutes. Need it later? `GET /assets/{asset_id}/download` for a fresh one.
  </Step>
</Steps>

A webhook is a nudge, not your source of truth. Keep a slow sync as a backstop, such as `GET /generations?updated_since=...` every few minutes, so a disabled endpoint or a missed event never loses work. [List generations](/api/generations#list-generations).

## Not here [#not-here]

* **No per-request `webhook_url` or `callback_url`.** Submit and invoke bodies are strict, so either field is refused as an unknown key (`400 invalid_config`). Register an endpoint once instead.
* **No rotate, enable, test or delivery history over the API.** Those are in the app.
* **No replay** of an exhausted delivery, or of events missed while an endpoint was disabled.
* **No MCP tool.** Webhooks are for your server. An agent reads results with `wait_for_generation` and `get_generation`. [MCP tools](/mcp/tools).
* **No link in any body.** Fetch links when you need the file.

<Boundary title="RiffAds returns files and links. It never posts them.">
  No event, route or setting publishes to a social platform. Your endpoint is told the work is ready, and your code decides what to do with it.
</Boundary>
