Webhooks
Register an HTTPS endpoint, get a signed event when work settles, verify it with the standardwebhooks library.
Read as MarkdownStop 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
| 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.
- 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
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.
{
"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
| 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 |
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
| 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.
List endpoints
GET/api/v1/webhooksAPI key
No params, no paging. Newest first, 10 at most.
{
"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
{
"ok": true,
"webhook_id": "wh_0193c8f0a1b24e7f9d3c5a6b7e8f00b1",
"deleted": true
}200with a body, never204.- 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:
| Event | Fires when | data |
|---|---|---|
generation.completed | A generation completed and its charge was captured | A generation |
generation.failed | A generation failed and its hold was released | A generation |
batch.settled | Every sibling of a variants submit that fanned out (a video model) has finished | A batch |
workflow_run.completed | A run ended completed or partial | A run |
workflow_run.failed | A run ended failed or canceled | A run |
credits.low | Available credits fell below the threshold, once per refill | Credits |
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:
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 scheme, so the standardwebhooks library checks it as it is. There is no x-riffads-signature header and no other scheme.
The envelope
{
"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
Check the signature on the raw bytes before you parse or act on anything. Install the library:
npm install standardwebhooksimport { 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:
- Verify on the raw body. Parse only after
verifypasses. It returns the parsed body. - Refuse a stale timestamp.
verifyrefuses one older than 5 minutes. Keep your server clock in sync. - Dedupe on
webhook-id. A retry, or a copy after a timeout on your side, carries the same id. - Answer any 2XX within 10 seconds. The response body is never read.
- 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.
| 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
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
failingendpoint toactive. - Only an owner or admin can enable a
disabledendpoint, 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
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}returnsoutputs[].url, a link that lives 10 minutes. Generations.GET /assets/{asset_id}/downloadreturns 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.
{
"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
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.
{
"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."
}
}statusiscompleted,failedorpartial. Neverrunning.- Each
generations[]entry has the generation event shape, withoutdisclosure. - 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.
{
"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_idslists ids only. Read the run withGET /workflow-runs/{id}for files and text. Workflows API.nodes[].erroris{ code, message }ornull.
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_creditsis 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
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_urlorcallback_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_generationandget_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.