REST API

Webhooks

Register an HTTPS endpoint, get a signed event when work settles, verify it with the standardwebhooks library.

Read as Markdown

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.

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.

The three routes

EndpointScopeRate limit per keyWhat it does
GET /webhooksRead120 a minute (agent_read)Lists this workspace's endpoints. Never the secret
POST /webhooksGenerate20 a minute (webhook_manage)Registers one endpoint. Answers the signing secret, once
DELETE /webhooks/{id}Generate20 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.
  • Owners and admins can do more in the app at 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

POST/api/v1/webhooksAPI key
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

Strict. An unknown key is 400 invalid_config.

Prop

Type

Response

201, no Location header.

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="
}

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.

The endpoint object

FieldTypeNotes
webhook_idstringwh_ prefix
urlstringAs stored, after normalizing
eventsstring[]What this endpoint receives
statusactive | failing | disabledSee endpoint health
consecutive_failuresintegerDeliveries in a row that used every attempt. One success resets it to 0
descriptionstring | null
created_at, updated_atstringISO 8601

Register errors

CauseCodeHTTPRetry
Body fails the schema, an unknown key, or an unknown event nameinvalid_config400no
URL is not public https, or its hostname does not resolveinvalid_config400no
Description over 200 charactersinvalid_config400no
The workspace already has 10 endpointsinvalid_config400no
Key lacks Generateinsufficient_scope403no
Over 20 changes a minute on this keyrate_limited429yes
Signing is not set up on our sideinternal_error500yes

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

List endpoints

GET/api/v1/webhooksAPI key

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

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/api/v1/webhooks/{id}API key
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

Subscribe to any of these six:

EventFires whendata
generation.completedA generation completed and its charge was capturedA generation
generation.failedA generation failed and its hold was releasedA generation
batch.settledEvery sibling of a variants submit that fanned out (a video model) has finishedA batch
workflow_run.completedA run ended completed or partialA run
workflow_run.failedA run ended failed or canceledA run
credits.lowAvailable credits fell below the threshold, once per refillCredits

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.

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.

The delivery

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

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=
HeaderWhat it is
webhook-idThe delivery id (whd_). The same on every retry, and equal to the body's id. Dedupe on it
webhook-timestampUnix seconds of this attempt. A retry hours later carries a fresh one
webhook-signaturev1, then the base64 HMAC SHA-256 of {id}.{timestamp}.{body}, keyed with your signing secret
user-agentRiffAds-Webhooks/1.0. Allow it by name if a firewall sits in front

This is the Standard Webhooks scheme, so the standardwebhooks library checks it as it is. There is no x-riffads-signature header and no other scheme.

The envelope

Body
{
  "id": "whd_0193c8f0a1b24e7f9d3c5a6b7e8f00c1",
  "type": "generation.completed",
  "created_at": "2026-09-17T10:07:39.120Z",
  "data": { }
}
FieldNotes
idSame value as webhook-id
typeThe event name
created_atWhen the event happened, ISO 8601. Not when this attempt was sent
dataOne of the shapes below

Verify every delivery

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

Terminal
npm install standardwebhooks
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>;

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

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

AttemptSent
1Right away
2About 5 minutes after attempt 1
3About 30 minutes after attempt 2
4About 2 hours after attempt 3
5About 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

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

statusWhenWhat happens
activeNormalReceives events
failing3 exhausted deliveries in a rowStill receives events. consecutive_failures shows the count
disabled20 exhausted deliveries in a rowReceives 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.

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.
  • GET /assets/{asset_id}/download returns a fresh 10 minute link for any file in the library. Library.

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

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

Generation events

generation.completed and generation.failed.

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.

FieldTypeNotes
generation_idstringRead it with GET /generations/{id}
capability_idstring | null
statuscompleted | failedA canceled job reports failed, as on a read
output_kindimage video audio text | null
outputs[]array{ index, asset_id, kind }. No link. Empty on a failure and for a text result
outputs_expected, outputs_deliveredinteger | null
creditsobjectcredits_held, credits_charged, settlement, terminal. No wallet balance
charge_summarystringOne readable sentence
batch_group_id, batch_indexstring | null, integer | nullSet for a sibling of a variants submit that fanned out
workflow_run_idstring | nullSet for a workflow step
purposedeliverable | preview | intermediate | nullWhy the row exists
errorobject | null{ code, message } on generation.failed, else null
created_at, completed_atstring, string | nullISO 8601
disclosurestringA fixed sentence: RiffAds publishes nothing, and the file is AI generated

batch.settled

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.

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

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.
  • nodes[].error is { code, message } or null.

credits.low

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.

Worked example: know when a video is ready

Register once

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

Submit as usual

POST /generations. Keep the generation_id. Generations.

Receive, verify, dedupe

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

Fetch the file in the job

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.

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.

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.
  • No link in any body. Fetch links when you need the file.

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.

On this page