# Capabilities (/capabilities)
Every thing RiffAds can make, by `capability_id`. Send that id to REST, MCP or the CLI. Same id, same fields everywhere.
This page is the map. The live list for your workspace is the truth: read it with [`GET /capabilities`](/api/capabilities), `list_capabilities` ([MCP](/mcp/tools)) or `riffads capabilities` ([CLI](/cli/commands)).
## Read the live list first [#read-the-live-list-first]
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/capabilities \
-H "Authorization: Bearer $RIFFADS_API_KEY"
# one capability: its config_schema plus a valid example_config
curl -s https://app.riffads.com/api/v1/capabilities/veo_31 \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
| `status` | Means |
| --------------- | ------------------------------------------------------------------------ |
| `available` | Live. Estimate and submit. |
| `requires_plan` | Your plan does not include it. `required_plan` names the plan that does. |
| `coming_soon` | Listed, not live. Estimates and submits are refused. |
Unknown keys in `config` are refused with `invalid_config`, never ignored. Read `config_schema` before your first submit. A workspace may see fewer options than listed here.
* `capability_id` is a plain string, not an enum. New ids appear with no deploy. Don't hardcode the list.
* `category` is `avatar`, `video`, `image`, `tool` or `preset`.
* `output_kind` is `image`, `video`, `audio` or `text`. Category is not output: `auto_caption` is a `tool` that outputs `video`.
* Media slots take asset ids (`ast_` plus 32 hex chars) from [uploads](/api/uploads), always as an array, even for one file.
* Every capability ends in a signed file link (or text). Nothing posts anywhere.
## Talking actor [#talking-actor]
`actor_ultra`: an actor speaks your script. Category `avatar`, output `video`.
| Field | Required | Notes |
| ------------- | ------------------- | ------------------------------------------------------------------------ |
| `script` | script or recording | Up to 1500 characters. |
| `voiceId` | no | Falls back to the actor's default voice. |
| `voice_audio` | script or recording | Your own recording instead of a script. Up to 60 s. MP3, WAV, OGG, WEBM. |
| `resolution` | no | `720p` only. |
* `actor_id` (or `actor_image_asset_id`), `voice_id` and `approved_voice_generation_id` are **top-level submit fields**, not `config` keys.
* Script road: run `tts` first, wait, then `actor_ultra` with `approved_voice_generation_id`. Recording road: fill `voice_audio`, no `tts` needed.
* No `aspect_ratio`: refused. Shape follows the actor image.
* No `variants`: refused. No captions switch: run `auto_caption` after.
* The id is `actor_ultra`. `talking_actor` is not an id.
Full flow: [Talking actor ads](/guides/talking-actor).
## Video models [#video-models]
One prompt, one generation, one video.
| id | Name | Frames and references | Aspect ratio | Duration (s) | Resolution | Audio toggle |
| ------------------ | -------------------------- | ----------------------------------------------------- | ------------------------------------------- | --------------- | --------------------- | -------------------- |
| `veo_31` | Veo 3.1 | up to 3 reference images, **or** start plus end frame | `auto`, `16:9`, `9:16` | 4 to 8, step 2 | `720p`, `1080p`, `4k` | yes |
| `kling_v3` | Kling V3 | start, end frame | `9:16`, `16:9`, `1:1` | 3 to 15 | `720p`, `1080p` | yes |
| `kling_3_standard` | Kling 3 Standard | start, end frame, elements | `16:9`, `9:16`, `1:1` | 3 to 15 | set by tier | yes |
| `kling_3_pro` | Kling 3 Pro | same as Standard | same | 3 to 15 | set by tier | yes |
| `kling_3_4k` | Kling 3 4K | same as Standard | same | 3 to 15 | set by tier | yes |
| `omni_flash` | Omni Flash | up to 10 reference images, **or** one source video | `16:9`, `9:16` | 3 to 10 | not published | no, always has sound |
| `seedance_15_pro` | Seedance 1.5 Pro | start, end frame | `9:16`, `16:9`, `1:1` | 4 to 12 | `720p`, `1080p` | yes |
| `sora_2` | Sora 2 | start frame only | `9:16`, `16:9` | 4 to 20, step 4 | `720p`, `1080p` | no |
| `h3_max` | MiniMax H3 Max | start, end frame | `21:9`, `16:9`, `4:3`, `1:1`, `3:4`, `9:16` | 5 to 15 | `480p`, `768p` | no |
| `seedance_2` | Seedance 2 (`coming_soon`) | reference images, videos, audio, plus frames | six ratios, `21:9` to `9:16` | 4 to 15 | `720p`, `1080p` | yes |
All take `prompt` (required, 8000 chars, Kling 3: 2500), `count` and `prompt_enhancer` (off by default).
Slot rules, checked before anything runs:
* `end_frame` needs `start_frame`: Kling V3, Kling 3, Seedance 1.5 Pro, H3 Max.
* Veo 3.1: no references mixed with frames. A start frame alone is refused: send both.
* Omni Flash: references **or** a source video, not both.
* Seedance 2: max 12 reference files. Reference audio needs a reference image or video. Reference video 2 to 15 s combined. Reference audio max 15 s combined.
* Kling 3 elements: up to 4, max one with a video, needs a start frame.
* A start frame or reference can narrow valid `aspect_ratio`, `resolution` and `duration`. The schema enums show the union. The estimate endpoint checks the exact config.
## Image models [#image-models]
| id | Name | Shape control | Quality | Max reference images |
| ----------------------- | ---------------------- | ---------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------ |
| `nb_pro` | Nano Banana Pro | `aspect_ratio` (`auto`, `21:9` to `9:16`), `resolution` `1K`, `2K`, `4K` | none | 6 |
| `nb_2` | Nano Banana 2 | `aspect_ratio` (`auto`, `8:1` to `1:8`), `resolution` `1K`, `2K`, `4K` | none | 6, plus one context video and one context audio (15 MB each) |
| `gpt_image_15` | GPT Image 1.5 | `image_size`: `auto`, `1536x1024`, `1024x1024`, `1024x1536` | `low`, `medium`, `high` | 2 |
| `gpt_image_2` | GPT Image 2 | `image_size`, published list | fixed | 16 |
| `gpt_image_25_sunburst` | GPT Image 2.5 Sunburst | `image_size`: `1024x768`, `1024x1024`, `1024x1536`, `2560x1440`, `3840x2160` | `low`, `medium`, `high`, `max` | 2 |
| `gpt_image_25_flare` | GPT Image 2.5 Flare | same as Sunburst | same as Sunburst | 2 |
| `seedream_45` | Seedream 4.5 | `image_size` | none | 10 |
| `seedream_5_lite` | Seedream 5 Lite | `image_size` | none | 4 |
| `seedream_5_pro` | Seedream 5 Pro | `image_size` | none | 4 |
| `grok_image` | Grok Image | `aspect_ratio` (`20:9` to `9:20`) | none | 3 |
| `grok_image_quality` | Grok Image Quality | same aspects, `resolution` `1k`, `2k` | none | 3 |
* `image_size` **or** `aspect_ratio`, never both. The schema says which.
* Up to 4 outputs per request over the API. GPT Image 2 publishes `count` up to 10: anything above 4 is clamped to 4 with a message.
* Seedream 4.5 with references: output count is clamped to the number of references.
## Audio and text [#audio-and-text]
| id | Name | Output | Fields and limits |
| ------------------ | ---------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `tts` | Text to Speech | audio | `script` (up to 1500 chars), optional `voiceId`. |
| `create_music` | Create Music | audio | `prompt` (1 to 2000 chars), `duration` 1 to 300 s (default 60), `force_instrumental` (default `true`), `lyrics` (up to 3000 chars, needed unless instrumental). |
| `speech_to_speech` | Speech to Speech | audio | `source_audio` up to 120 s, plus `voiceId`. |
| `transcribe` | Transcribe | text | `source_media`: one video or recording. No speech: refused. |
| `script_llm` | AI Writer | text | `instructions` (12000 chars), `context` (optional, 6000), `reference_image` (up to 4), `model_class` `fast`, `balanced`, `smart` (default `fast`), `max_output_length` 100 to 1500 (default 600, output cut at that length). |
## Editing and media tools [#editing-and-media-tools]
Start from a file, not a prompt. One generation, one output, except `split_scenes`.
| id | Takes | Gives back | Hard limit |
| ------------------- | ------------------------------------ | ------------------------------- | ------------------------------- |
| `auto_caption` | `source_video` | same clip, captions burned in | 600 s |
| `text_overlay` | `source_media` (photo or video) | same file with a headline | video 600 s, text 300 chars |
| `trim_video` | `source_video` | one section | 600 s |
| `stitch` | `videos`: 2 to 10 clips | one video, in array order | 120 s combined |
| `split_scenes` | `source_video` | up to 10 clips | 600 s, scenes at least 0.5 s |
| `merge_layers` | `background` plus `layers` (up to 6) | one composited video | background 120 s |
| `extract_frame` | `source_video` | one PNG | `timestamp` 0 to 600 s |
| `extract_audio` | `source_video` | one 192 kbps MP3 | 600 s, silent video refused |
| `change_speed` | `source_media` (video or audio) | 0.25x to 4x, pitch kept | 600 s |
| `change_voice` | `source_video` plus `voiceId` | same words, new speaker | 120 s |
| `resize` | `source_media` (photo or video) | reframed file | video 120 s |
| `upscale` | `source_media` (photo or video) | higher resolution | video 30 s |
| `remove_background` | `source_media` (photo or video) | subject, no background | unreadable video length refused |
| `skin_enhance` | `source_image` (portrait) | cleaned-up copy | one photo |
| `camera_angle` | `source_image` | same scene, new camera position | no prompt |
In-house tools take input files up to 500 MB. An input over a limit is refused before anything runs.
### Tool settings [#tool-settings]
| id | Settings (default) |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto_caption` | `caption_style`: `punch`, `impact`, `boxed`, `word_pop`, `clean`, `marker`, `handwritten`, `terminal` (`punch`). `caption_position`: `auto`, `top`, `center`, `bottom` (`auto`). |
| `text_overlay` | `text` (1 to 300 chars, required). `text_align`: `left`, `center`, `right` (`center`). `position`: `top`, `center`, `bottom` (`top`). `start` seconds (`0`). `duration` 0.1 to 600 (`3`). `font`: `insta`, `tiktok` (`insta`). `background`: `none`, `dark`, `white` (`none`). `text_size`: `default`, `custom` (`default`). `text_size_px` 8 to 400 (`64`, only with `custom`). `start` and `duration` are ignored for photos. |
| `trim_video` | `start` seconds (`0`). `length` 0.1 to 600 (`5`). |
| `split_scenes` | `mode`: `auto` finds cuts, `manual` splits equally (`auto`). `threshold` 1 to 90, lower means more scenes, auto only (`17`). `scene_count` 2 to 10, max in auto, exact in manual (`6`). |
| `extract_frame` | `timestamp` 0 to 600, step 0.1. Past the end returns the last frame. |
| `change_speed` | `speed` 0.25 to 4, step 0.05 (`1`). |
| `resize` | `aspect_ratio`: `9:16` (1080x1920), `4:5` (1080x1350), `1:1` (1080x1080), `16:9` (1920x1080) (`9:16`). `mode`: `fit` (bars), `crop` (fill), `smart` (model extends the picture, images only) (`fit`). |
| `camera_angle` | `horizontal`: `front`, `left_45`, `right_45`, `left_profile`, `right_profile`, `behind` (`front`). `vertical`: `eye_level`, `low`, `high`, `overhead` (`eye_level`). `zoom`: `keep`, `wide`, `medium`, `close_up` (`keep`). |
`stitch`, `merge_layers`, `extract_audio`, `upscale`, `remove_background` and `skin_enhance` have no settings beyond their files.
Chain tools by feeding one output's asset id into the next input slot.
## Presets [#presets]
Pick a template, add one file. Several steps run behind it.
| id | Name | Takes | Notes |
| ---------------------- | -------------- | -------------------------------- | -------------------------------------------------------- |
| `preset_fashion_tryon` | Fashion Try On | template plus one clothes photo | 5 or 10 s, audio toggle, up to 4 variants |
| `preset_show_your_app` | Show Your App | template plus one app screenshot | always 5 s, no duration or audio field, up to 4 variants |
Presets use the `preset_` prefix and can launch with no deploy. The live list is the only full inventory.
## Variants [#variants]
Send top-level `variants` on submit to get N takes of one config.
* Max **4**. Maps to the capability's `count` field and wins over a `count` inside `config`.
* No `count` field on the capability (talking actor included): `variants` is refused.
* The response has `generation_ids` and a `batch_group_id`. Read all takes with `GET /batches/{batch_group_id}` or `get_batch`. Watch every id, not just the first.
## Refusals for an id [#refusals-for-an-id]
| `capability_status` | `code` | HTTP | Meaning |
| ------------------- | ------------------------ | ---- | ------------------------------------------------- |
| `coming_soon` | `capability_unavailable` | 404 | Listed, not live yet |
| `retired` | `capability_unavailable` | 404 | Withdrawn. Old generations still read fine |
| `requires_plan` | `required_plan` | 403 | Plan does not include it. Carries `required_plan` |
| `unknown` | `capability_unavailable` | 404 | Never existed or misspelled |
None are retryable. Re-read the list and pick an `available` row. All codes: [Errors](/reference/errors).
# RiffAds docs (/)
Send a script and an actor. Get back a lip-synced video ad.
Works from your code, your AI agent or your terminal.
## Pick your way in [#pick-your-way-in]
## First video in 5 minutes [#first-video-in-5-minutes]
## Before you start [#before-you-start]
* **Account:** sign up at [app.riffads.com](https://app.riffads.com). A paid plan is required.
* **Key:** owners and admins create API keys in the app.
* **MCP:** no key needed. Sign in from your client.
## 3 things that break first integrations [#3-things-that-break-first-integrations]
| Gotcha | Fix |
| ---------------------------------------------- | ----------------------------------------------------------------------- |
| Talking actor ads are 2 jobs | Voice first (`tts`), then `actor_ultra`. [Guide](/guides/talking-actor) |
| `max_credits` equal to the estimate is refused | Send at least `ceil(estimate * 1.1)`. [Generations](/api/generations) |
| Download links expire in 10 minutes | Download the file. Don't store the link. [Results](/guides/results) |
## What RiffAds won't do [#what-riffads-wont-do]
* **No posting.** Nothing publishes to Meta, TikTok, YouTube or X. You get a file.
* **No workflow building by agents.** Build workflows in the app. Agents run them.
Point your agent at [/llms.txt](/llms.txt) or [/llms-full.txt](/llms-full.txt). Add `.md` to any page URL for plain Markdown.
# Authentication (/api/authentication)
Everything you need to make an authenticated call to the REST API.
```text title="The two lines you need"
Base URL https://app.riffads.com/api/v1
Auth Authorization: Bearer sk_live_...
```
* One version: `v1`. No version header.
* `riffads.com` is the marketing site. It does not serve `/api/v1`.
* MCP clients use OAuth, not keys. See [MCP connect](/mcp/connect).
No CORS headers, no `OPTIONS` handler. The key is a secret. Call from a backend, task runner, CI job or terminal. Never a browser.
## Send the key [#send-the-key]
```bash title="Terminal"
export RIFFADS_API_KEY="sk_live_..."
curl -s https://app.riffads.com/api/v1/capabilities \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
```ts
const res = await fetch("https://app.riffads.com/api/v1/capabilities", {
headers: { Authorization: `Bearer ${process.env.RIFFADS_API_KEY}` },
});
const body = await res.json();
if (!res.ok) {
// Every failure is { error: { ok, code, message, retryable, credits_charged } }
throw new Error(`${body.error.code}: ${body.error.message}`);
}
```
### Header rules [#header-rules]
`x-api-key: sk_live_...` works anywhere `Authorization` does.
| Case | Result |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Both headers sent | A usable `Authorization: Bearer` wins. A non-Bearer `Authorization` counts as absent, so `x-api-key` is read |
| Scheme casing | Case-insensitive: `bearer`, `Bearer`, `BEARER` |
| Extra whitespace | Allowed between scheme and key |
| Non-Bearer scheme (`Basic abc`), no `x-api-key` | Counts as no header. Plain 401 |
| Key in a query param | Not supported. 401 |
Missing header, malformed header and unknown key all get the same 401, byte for byte.
## Get a key [#get-a-key]
Owners and admins create keys at [app.riffads.com/api-keys](https://app.riffads.com/api-keys). No API endpoint creates, rotates or lists keys.
* The workspace plan must include the API. Every paid plan does.
* Prefix is `sk_live_` everywhere. No `sk_test_`, no sandbox. Every call is real.
* Shown once. Stored as a hash. Lost it? Create a new one.
* A key belongs to the **workspace**, not the person who made it. It only reaches its own workspace.
* Keys **never expire**. They work until revoked.
* Name is required, up to 60 characters. Name it after where it lives (`CI pipeline, staging`).
* A key can carry an optional 24 hour spend cap, set in the app.
## Scopes [#scopes]
Pick scopes when you create the key. **They can never be widened.** Need another scope? Create a new key.
| Scope | Opens |
| --------- | ------------------------------------------------------------------------------------------------------------------------ |
| Read | On every key. Capabilities, actors, voices, balance, estimates, all generation, batch and workflow reads. Spends nothing |
| Generate | Read, plus uploads and `POST /generations` |
| Workflows | Read, plus both workflow invoke routes |
A Generate key can't invoke a workflow. A Workflows key can't submit a generation. Need both? Tick both.
| Endpoint | Scope |
| ----------------------------------------------------- | ------------- |
| `GET /capabilities`, `GET /capabilities/{id}` | Read |
| `POST /estimates` | Read |
| `GET /actors`, `GET /voices` | Read |
| `GET /generations/{id}`, `GET /generations/{id}/wait` | Read |
| `GET /batches/{id}` | Read |
| `GET /workflows/templates`, `GET /workflow-runs/{id}` | Read |
| `POST /generations` | **Generate** |
| `POST /uploads`, `POST /uploads/{assetId}/finalize` | **Generate** |
| `POST /workflows/{id}/invoke` | **Workflows** |
| `POST /workflows/templates/{key}/invoke` | **Workflows** |
A read-only key can't spend. Safe for dashboards and reports.
## Revoke and rotate [#revoke-and-rotate]
* Revoke in the app. It takes effect on the next call.
* A revoked key answers `401 not_authorized`.
* Work already running finishes.
* To rotate: create the new key, deploy it (both work meanwhile), then revoke the old one.
* No cap on keys per workspace. One key per integration keeps limits and revocation separate.
* Committed a key to a repo? Revoke it now.
## Auth errors [#auth-errors]
Every failure is wrapped in `error`. Every 401 sends `WWW-Authenticate: Bearer realm="RiffAds"`.
```json title="Response: 401"
{
"error": {
"ok": false,
"code": "not_authorized",
"message": "This RiffAds API key is not valid. Check the Authorization header, or create a new key at riffads.com.",
"retryable": false,
"credits_charged": 0
}
}
```
| Cause | `code` | HTTP | Retry |
| -------------------------------------------------- | ----------------------- | ------- | ------ |
| No header, non-Bearer scheme, blank or unknown key | `not_authorized` | 401 | no |
| Key revoked | `not_authorized` | 401 | no |
| Key expired (only very old keys) | `not_authorized` | 401 | no |
| Key's request allowance used up | `quota_exceeded` | 429 | **no** |
| Too many requests | `rate_limited` | 429 | yes |
| Key lacks the scope this endpoint needs | `insufficient_scope` | 403 | no |
| Workspace deleted | `workspace_unavailable` | **410** | no |
| Plan does not include the API | `required_plan` | 403 | no |
| Read-only connection tried to spend | `read_only_connection` | 403 | no |
| Key check failed on our side | `internal_error` | 500 | yes |
When several things are wrong, you get them in this order: header, key (revoked, allowance, rate limit, scope), workspace, plan.
Error messages say "create a new key at riffads.com". Keys are made at [app.riffads.com/api-keys](https://app.riffads.com/api-keys).
Usually the header. Check for a trailing newline, stray quotes, a non-Bearer scheme, or the key in a query param.
The key lacks Generate. The message names the missing scope. Create a key with Generate, then revoke the old one.
Any unknown `/api/v1/...` path answers `404 not_found` without checking the key, so a typo never looks like a bad key. A wrong method on a real path is a plain `405`.
Ids from another workspace answer `not_found`, same as ids that never existed. Check the id came from this key's workspace.
## Rate limits [#rate-limits]
Two brakes. Either can return `429 rate_limited`.
**Per key:** 120 requests a minute. The number is copied onto the key when it is created.
**Per action:** fixed one minute windows, counted per key. Two keys never share a bucket.
| Bucket | Per minute | Endpoints |
| ------------- | ---------- | ------------------------------------------------------------------------------------------------- |
| Reads | 120 | Capabilities, actors, voices, balance, generation and batch reads, wait, templates, workflow runs |
| Estimates | 60 | `POST /estimates` |
| Submits | 20 | `POST /generations` |
| Uploads | 30 | `POST /uploads` and finalize |
| Workflow runs | 20 | Both invoke routes |
* One `wait` call costs 1 read, however long it blocks.
* No `X-RateLimit-*` headers.
* A 429 sends `Retry-After` only when the body has `retry_after_seconds`. Same number.
* `quota_exceeded` is a 429 you can't retry and sends no `Retry-After`.
All numbers: [limits](/reference/limits). Retry rules: [conventions](/api/conventions).
# Capabilities API (/api/capabilities)
Two reads before you submit: what you can run, and what its `config` must look like. For the full catalog in words, see [capabilities](/capabilities).
* Scope `read`. Every key has it.
* Rate bucket `agent_read`: **120 calls a minute per key**, shared with other reads.
* `capability_id` is a free string, never an enum. Presets have their own ids (`preset_fashion_tryon`). Read the list, don't hardcode it.
## List capabilities [#list-capabilities]
| Param | Type | Default | Notes |
| -------- | ---------------- | ------- | -------------------------- |
| `limit` | positive integer | 25 | Clamped to 1 to 50 |
| `cursor` | string | none | The previous `next_cursor` |
* Lenient: `?limit=abc`, `0`, `-3` or empty act as if omitted.
* No `search`, `category`, `page` or `offset`.
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/capabilities \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
```json title="Response: 200 (trimmed)"
{
"ok": true,
"capabilities": [
{
"capability_id": "actor_ultra",
"name": "Talking Actor",
"description": "OmniHuman 1.5: an actor speaks your script.",
"category": "avatar",
"output_kind": "video",
"status": "available"
},
{
"capability_id": "veo_31",
"name": "Veo 3.1",
"description": "Google Veo 3.1 video generation with optional audio.",
"category": "video",
"output_kind": "video",
"status": "available"
},
{
"capability_id": "nb_pro",
"name": "Nano Banana Pro",
"description": "Gemini 3 Pro Image: the premium hero shot, up to 4K, best-in-class text rendering.",
"category": "image",
"output_kind": "image",
"status": "available"
}
],
"next_cursor": "nb_pro"
}
```
The full body also has `total`: the rows this workspace sees, retired ones left out.
* **Order:** category (avatar, video, image, preset, tool), then `capability_id` ascending.
* **`next_cursor`:** the last row's `capability_id`. `null` on the last page. A stale cursor restarts at page one.
* **Hidden:** retired rows and rows not offered to agents. Ask for one by id and the detail endpoint says why.
* **It changes without notice:** plan gates, retirements, launches. Read it each session. Sent with `Cache-Control: no-store`.
* **Agent only:** AI Writer (`script_llm`) and Transcribe (`transcribe`) are listed here, not in the app. Both return text.
## Read one schema [#read-one-schema]
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/capabilities/actor_ultra \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
```json title="Response: 200"
{
"ok": true,
"capability": {
"capability_id": "actor_ultra",
"name": "Talking Actor",
"description": "OmniHuman 1.5: an actor speaks your script.",
"category": "avatar",
"output_kind": "video",
"config_schema": {
"type": "object",
"properties": {
"script": {
"description": "Script.",
"type": "string",
"maxLength": 1500
},
"voiceId": {
"description": "Voice. A voice id from list_voices.",
"type": "string",
"minLength": 1
},
"voice_audio": {
"description": "Audio. Upload a voice recording to use instead of a script. Up to 60s, MP3, WAV, OGG or WEBM. Up to 1 audio asset id(s). Accepts an upload or anything this project already produced.",
"maxItems": 1,
"type": "array",
"items": { "type": "string", "pattern": "^ast_[0-9a-f]{32}$" }
},
"resolution": {
"default": "720p",
"description": "Resolution.",
"type": "string",
"enum": ["720p"]
}
},
"additionalProperties": false,
"description": "Configuration for Talking Actor. Only the keys listed here are accepted: any other key is refused rather than ignored. A key with a default may be omitted. Call estimate_generation to price a config before submitting it."
},
"example_config": {
"resolution": "720p"
}
}
}
```
No `status` on the detail body. A `200` means you can run it.
### Schema rules [#schema-rules]
* **Strict.** `additionalProperties: false`. An unknown key in `config` (like `maxCredits`) is `400 invalid_config`.
* **Locked preset fields are absent.** They are not yours to set.
* **Keys with a default may be left out.** `required` lists only required keys with no default. `actor_ultra` has none, so it has no `required` array. It still needs a script or an uploaded recording: that either/or is checked at submit.
* **`example_config` is the smallest valid config.** It includes required keys that have defaults, so it can differ from `required`. File slots hold the placeholder `ast_00000000000000000000000000000000`: swap in real ids from [uploads](/api/uploads).
* **`count` max is 4** over the API.
* **Asset ids match `^ast_[0-9a-f]{32}$`.**
* **Read the descriptions.** File limits, per-file durations, step sizes, slot dependencies and group rules live there.
### Two file slot shapes [#two-file-slot-shapes]
Plain tools take an array of bare ids:
```json title="config_schema.properties.source_video, from auto_caption"
{
"description": "Video. Up to 1 video asset id(s). Each file may run up to 600 seconds. Accepts an upload or anything this project already produced.",
"minItems": 1,
"maxItems": 1,
"type": "array",
"items": { "type": "string", "pattern": "^ast_[0-9a-f]{32}$" }
}
```
Image and video models take one strict object per file, with the role pinned and an alias you pick:
```json title="config_schema.properties.start_frame, from sora_2"
{
"description": "Start Frame. Up to 1 image file(s), each as an asset id plus its own alias, with role \"start_frame\".",
"maxItems": 1,
"type": "array",
"items": {
"type": "object",
"properties": {
"assetId": { "type": "string", "pattern": "^ast_[0-9a-f]{32}$" },
"alias": { "type": "string", "pattern": "^(image|video|audio)[1-9]\\d*$" },
"role": { "type": "string", "const": "start_frame" }
},
"required": ["assetId", "alias", "role"],
"additionalProperties": false
}
}
```
Aliases count from 1 per kind (`image1`, `image2`, `video1`). Don't reuse an alias across slots in one config. The config level `description` says this too.
### What the schema can't tell you [#what-the-schema-cant-tell-you]
* **Settings that depend on a file.** A start frame or reference image can change the valid `aspect_ratio`, `resolution` and `duration`. Enums show every value any case allows. Check a real config with `POST /estimates`. [Generations API](/api/generations).
* **Price.** None here. `POST /estimates` returns the number to base `max_credits` on.
* **REST names.** Descriptions name MCP tools (`list_voices`, `estimate_generation`). Over REST those are `GET /voices` and `POST /estimates`.
### Fields that are not config [#fields-that-are-not-config]
`actor_id`, `actor_image_asset_id`, `voice_id` and `approved_voice_generation_id` go on the **submit body**, not in `config`. Inside `config` they are unknown keys. [Generations API](/api/generations), [actors and voices](/api/library).
Sending `aspect_ratio` is an unknown key. The shape comes from the actor. Script road: run `tts` first and pass its `generation_id` as `approved_voice_generation_id`. Audio road: fill `voice_audio` with your recording, no `tts` needed. [Talking actor guide](/guides/talking-actor).
## Errors [#errors]
An id you can't use adds `capability_status` to the error envelope.
| `capability_status` | `code` | HTTP | Meaning |
| ------------------- | ------------------------ | ---- | ----------------------------------------------------- |
| `coming_soon` | `capability_unavailable` | 404 | Listed, not live yet |
| `retired` | `capability_unavailable` | 404 | Withdrawn. Old generations still read fine |
| `requires_plan` | `required_plan` | 403 | Your plan doesn't include it. Carries `required_plan` |
| `unknown` | `capability_unavailable` | 404 | Never existed, misspelled, or not offered to agents |
```json title="Response: 404"
{
"error": {
"ok": false,
"code": "capability_unavailable",
"message": "No such capability. Call list_capabilities for the current list.",
"retryable": false,
"credits_charged": 0,
"capability_status": "unknown"
}
}
```
None are retryable. Read the list again and pick an `available` row. All codes: [error codes](/reference/errors).
# Conventions (/api/conventions)
The rules every `/api/v1` endpoint follows. Learn them once.
## Success is bare [#success-is-bare]
Fields sit at the top level next to `ok: true`. No `data` wrapper.
```json title="Response: 200 (trimmed)"
{
"ok": true,
"actors": [
{
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"name": "Maya",
"gender": "female",
"default_voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"is_platform": true
}
],
"next_cursor": "eyJpc09yZyI6dHJ1ZSwiaXNDb3JlIjpmYWxzZSwiY3JlYXRlZEF0IjoiMjAyNi0wOS0wMVQxMDoxMjowMFoiLCJpZCI6ImFjdF8wMTkzIn0"
}
```
* Wire fields are snake\_case.
* Exception: fields inside `config` come from the capability schema and can be camelCase (`voiceId`). Read `config_schema` from [`GET /capabilities/{id}`](/api/capabilities).
## Errors are wrapped [#errors-are-wrapped]
```json title="Response: 402"
{
"error": {
"ok": false,
"code": "insufficient_credits",
"message": "Not enough credits for this request.",
"retryable": false,
"credits_charged": 0,
"shortfall": 240
}
}
```
Always inside `error`:
Extra fields, only when they apply:
| Extra | Type | Appears on |
| --------------------- | ------ | ---------------------------------------------------------------------------------------------- |
| `shortfall` | number | `insufficient_credits` |
| `retry_after_seconds` | number | `rate_limited`, a temporary `request_blocked`, `submission_in_flight` for an identical request |
| `limit` | object | `max_credits_exceeded`, `spend_limit_exceeded`. `limit.bound_by` names the cap that refused |
| `in_flight` | object | `submission_in_flight`, with `generation_id` and `status` |
| `blocked_reason` | string | `request_blocked`: the code that caused the block |
| `capability_status` | string | `capability_unavailable`, and `required_plan` from the capability check |
| `required_plan` | string | `required_plan`: the plan that includes it |
No `errors` array, no `details` object. Every code: [errors](/reference/errors).
## HTTP status [#http-status]
Status comes from the code, same everywhere. The ones that surprise people:
* `workspace_unavailable` is **410**, not 403.
* `moderation_blocked` is **422**.
* `insufficient_scope` is **403**, not 401.
* Three codes are 429: `rate_limited` (retry), `quota_exceeded` (don't), `request_blocked` (don't).
* Another workspace's id answers **404 `not_found`**, never forbidden.
* Unknown `/api/v1` paths answer `404 not_found` in the envelope, with no key check. A wrong method on a real path is a plain `405`, not the envelope.
```json title="Response: 404"
{
"error": {
"ok": false,
"code": "not_found",
"message": "GET /api/v1/generation/gen_123 is not a RiffAds API endpoint. See /api/v1/capabilities to start, or check the docs for the current path list.",
"retryable": false,
"credits_charged": 0
}
}
```
## Response headers [#response-headers]
| Header | When | Value |
| ------------------ | ------------------------------------------------------- | ---------------------------------------------------------- |
| `Cache-Control` | Every response | `no-store` |
| `Content-Type` | Every response | `application/json` |
| `Retry-After` | A 429 with `retry_after_seconds` | Same integer as the body |
| `WWW-Authenticate` | Every 401 | `Bearer realm="RiffAds"` |
| `Location` | The 201 from `POST /generations` and both invoke routes | `/api/v1/generations/{id}` or `/api/v1/workflow-runs/{id}` |
That is the full list. No `X-RateLimit-*`, no request id, no version header. `POST /uploads` returns 201 without `Location`.
## Request bodies are strict [#request-bodies-are-strict]
Send a JSON object with `Content-Type: application/json`.
**An unknown key is refused, never ignored.** Inside `config` too. `maxCredits` instead of `max_credits` is a 400.
```json title="Response: 400"
{
"error": {
"ok": false,
"code": "invalid_config",
"message": "That request body is not valid. max_credits: Invalid input: expected number, received undefined; Unrecognized key: \"maxCredits\".",
"retryable": false,
"credits_charged": 0
}
}
```
| Cause | Message |
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
| Not JSON | `That request body is not valid JSON. Send a JSON object and set Content-Type: application/json.` |
| `null`, array or scalar | `That request body is not a JSON object. Send an object with the documented fields at its top level.` |
| Fails the schema | `That request body is not valid. {path}: {issue}; {path}: {issue}.` Up to 5 issues, then ` Plus N more.` |
Refused in any body: `project_id`, `organization_id`, `idempotency_key`, `source`, `quote`, and any webhook or callback field. Invoke bodies also refuse `workflow_id` and `template_key`.
## Query params are lenient [#query-params-are-lenient]
A param that can't be read counts as **omitted**, not an error.
| You send | Server reads |
| -------------------------------------------------------------- | ------------------------ |
| `?limit=25` | 25 |
| `?limit=abc`, `?limit=`, `?limit=0`, `?limit=-3`, `?limit=2.5` | omitted, default applies |
| `?limit=1000` | clamped to the max |
| `?search=%20%20` | omitted |
| `?search=%20maya%20` | `maya` |
## Pagination [#pagination]
Cursor only. No `page`, `offset`, `per_page` or `after`.
| Endpoint | `limit` default | `limit` max | `total` |
| ------------------- | --------------- | ----------- | ------- |
| `GET /capabilities` | 25 | 50 | yes |
| `GET /actors` | 25 | 50 | **no** |
| `GET /voices` | 25 | 50 | yes |
```bash title="Terminal"
curl -sG https://app.riffads.com/api/v1/capabilities \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
--data-urlencode "limit=50" \
--data-urlencode "cursor=auto_caption"
```
* Pass `next_cursor` back as `cursor`, exactly. Don't parse or build one.
* `next_cursor` is `null` on the last page. Stop there.
* A stale or bad cursor restarts from page one. It never returns an empty page.
* `total` counts rows this workspace can see, after filters.
No paging, returns everything: `GET /workflows/templates`, `GET /batches/{id}`, `GET /workflow-runs/{id}`.
## Idempotency and replays [#idempotency-and-replays]
**There is no `Idempotency-Key` header and no `idempotency_key` field.** Sending the field is a 400. The server makes its own key per request.
Resending a submit after the first job finished starts a new, paid job. Lost the response? Read the generation. Never resubmit to find out.
A submit that matches a job **still running** in this workspace does not start a second one. You get the existing job back with `replay: true`.
```json title="Response: 201 (trimmed)"
{
"ok": true,
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"generation_ids": ["gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033"],
"replay": true,
"next_action": "This exact request was already running in this workspace, so RiffAds handed back the generation that is already going rather than starting and charging for a second one. GET /api/v1/generations/gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033/wait, and keep calling it until still_running is false. Do not hand the checking back to the person and do not ask them to prompt you again."
}
```
* A replay is still `201` with `Location`. Read `replay` in the body.
* Only while the first job is `queued`, `rendering` or `post_processing`.
* Never for submits with `variants` above 1.
* Identical request running for someone else in the workspace: `409 submission_in_flight` with `retry_after_seconds: 30` instead.
## Retries [#retries]
`retryable` is the contract.
* **`false`:** never send the same request again. Change it or stop.
* **`true`:** retry a few times. Sleep `retry_after_seconds` when present, else back off. Then stop.
```ts
if (!body.error.retryable) {
// Change the request or stop. Do not resend this one.
throw new Error(`${body.error.code}: ${body.error.message}`);
}
const waitSeconds = body.error.retry_after_seconds ?? backoffSeconds;
```
### Blocked requests [#blocked-requests]
Repeated failures of the **same exact request** put it on a block list. The block is keyed on the request (workspace, capability, actor, voice, uploads, config), not on you.
| Block | Armed after | Lasts |
| --------- | ---------------------------------------------------------------------------------- | ------------ |
| Temporary | 3 failures from `provider_unavailable`, `internal_error`, or some content refusals | about 1 hour |
| Permanent | 2 content refusals | 30 days |
* Blocked: `429 request_blocked`, `retryable: false`, with `blocked_reason`.
* Temporary blocks carry `retry_after_seconds`. Permanent ones don't.
* Adding junk fields won't escape a block. Change the script or inputs.
* A successful submit clears the flags for that request.
* Money refusals never arm a block.
```json title="Response: 429"
{
"error": {
"ok": false,
"code": "request_blocked",
"message": "This exact request was refused by content review 2 times, so RiffAds will not run it again. Change the script or the inputs and send a new request. Resending this one unchanged will be refused without being checked.",
"retryable": false,
"credits_charged": 0,
"blocked_reason": "moderation_blocked"
}
}
```
## next\_action is advice [#next_action-is-advice]
Submits, waits, uploads and invokes return `next_action`: a sentence written for an AI agent. Don't branch code on it. Branch on `still_running`, `status` and `replay`.
Some `next_action` sentences name MCP tools (`wait_for_generation`, `get_generation`, `finalize_upload`) even over REST. Read those as the matching REST call.
## Ids and timestamps [#ids-and-timestamps]
| Prefix | Thing |
| ---------- | ----------- |
| `gen_` | Generation |
| `bg_` | Batch group |
| `act_` | Actor |
| `voc_` | Voice |
| `ast_` | Upload |
| `sk_live_` | API key |
* Timestamps are ISO 8601 UTC: `2026-09-17T10:04:11.482Z`.
* Statuses: [statuses and ids](/reference/statuses-and-ids).
## Not in the API [#not-in-the-api]
* **No outbound webhooks.** A webhook or callback field is a 400. Use the wait endpoint.
* **No SSE or websockets.**
* **No OpenAPI file, no SDK.** These pages are the spec.
* **No test keys.** `sk_live_` only.
* **No generation list, no cancel, no key management, no workflow authoring.**
# Generations (/api/generations)
Every endpoint that makes a video, image or audio file, and every field they take and return.
| Endpoint | Scope | Rate limit | What it does |
| ---------------------------- | -------- | ---------- | ---------------------------------------------------------- |
| `POST /estimates` | Read | 60/min | Returns the number to put in `max_credits`. Starts nothing |
| `POST /generations` | Generate | 20/min | Starts a job. `201` with `Location` |
| `GET /generations/{id}` | Read | 120/min | Reads one generation. Answers now |
| `GET /generations/{id}/wait` | Read | 120/min | Blocks up to 20 seconds, then says if done |
| `GET /batches/{id}` | Read | 120/min | Reads every sibling of a `variants` submit |
There is **no list endpoint and no cancel endpoint.** Keep every `generation_id` you get.
## Estimate [#estimate]
Returns the number you need for `max_credits`. Starts nothing, charges nothing, runs no content check.
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/estimates \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "tts",
"config": {
"script": "Three reasons this bottle keeps water cold all day."
}
}'
```
```json title="Response: 200"
{
"ok": true,
"capability_id": "tts",
"credits": 4,
"is_ceiling": false,
"breakdown": [
{ "label": "Tool", "unit": "second", "quantity": 4, "credits": 4 }
]
}
```
### Request body [#request-body]
Strict. Exactly two keys. A third is a 400.
* No `max_credits`, `variants`, `actor_id` or `approved_voice_generation_id` here. Those are submit fields.
* Pricing several variants? Put `count` inside `config`.
* Capabilities that work on a file (`auto_caption`, `change_voice`, `remove_background`, `resize`, `speech_to_speech`, `trim_video`, `upscale`) need the upload id in `config`. Missing file: `invalid_config`. [Uploads](/api/uploads).
```json title="Request: file-based capability"
{
"capability_id": "auto_caption",
"config": {
"source_video": ["ast_9f2c41ab7d0e4c8fb35a6e1d2c904f77"]
}
}
```
The submit refuses any config key the schema does not list. The estimate is not always that strict. Send only keys from `config_schema`.
### Response [#response]
| `breakdown[]` field | Type | Notes |
| ------------------- | ------- | ---------------------------------------------------------------- |
| `label` | string | Display text only |
| `unit` | string | `second`, `image`, `thousand_chars`, `million_tokens`, `request` |
| `quantity` | number | |
| `credits` | integer | |
Numbers change over time. Call the estimate before each submit. Don't cache it.
### Estimate errors [#estimate-errors]
| Code | HTTP | Retry | Cause |
| ------------------------ | ---- | ----- | ----------------------------------------------------- |
| `rate_limited` | 429 | yes | Over 60 a minute |
| `capability_unavailable` | 404 | no | Unknown, retired or not live. Has `capability_status` |
| `required_plan` | 403 | no | Not on this plan. Has `required_plan` |
| `invalid_config` | 400 | no | Config fails the schema |
| `not_priced` | 409 | no | Capability can't be used yet. Try another |
| `pricing_unavailable` | 503 | yes | Our side. Retry shortly |
| `internal_error` | 500 | yes | Our side |
## Submit [#submit]
Starts a job. Answers when the job is **accepted**, not done.
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/generations \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "veo_31",
"config": { "prompt": "A steel bottle on a kitchen counter, morning light, slow push in." },
"max_credits": 440
}'
```
### Request body [#request-body-1]
`max_credits` must be at least `ceil(estimate * 1.1)`. Estimate 400 means send 440 or more. Less gets `402 max_credits_exceeded`, nothing started. This is the most common first-integration failure.
* Unknown key, in the body or in `config`: `400 invalid_config` naming the key. `maxCredits` is refused.
* These fields don't exist and answer 400: `project_id`, `organization_id`, `quote`, `source`, `purpose`, `idempotency_key`, `workflow_run_id`, `pending_actor`, `webhook_url`, `callback_url`.
* Naming trap: body field is `voice_id`, config field is `voiceId`.
* `variants` fills the capability's own `count` field and wins over `count` in `config`. A capability with no `count` refuses `variants`.
### One submission at a time per key [#one-submission-at-a-time-per-key]
A second `POST /generations` on the same key while the first is running gets `409 submission_in_flight`. Wait on `in_flight.generation_id`, then submit.
```json title="Response: 409"
{
"error": {
"ok": false,
"code": "submission_in_flight",
"message": "This API key already has a generation running (gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033, rendering). Wait for it to finish, or check on it with get_generation, before starting another one.",
"retryable": true,
"credits_charged": 0,
"in_flight": {
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"status": "rendering"
}
}
}
```
* `variants: 4` is one submit, one slot.
* A stuck job frees the slot after 10 minutes.
* Jobs started in the app don't count.
* Need parallel jobs? Use a second key.
### Response [#response-1]
```json title="Response: 201 Created"
{
"ok": true,
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"generation_ids": ["gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033"],
"run_id": "run_9f2c1b",
"replay": false,
"credits": {
"credits_held": 440,
"credits_charged": null,
"settlement": "open",
"terminal": false,
"wallet_balance": 4104
},
"capability_id": "veo_31",
"capability_name": "Veo 3.1",
"output_kind": "video",
"outputs_expected": 1,
"charge_summary": "440 credits are on hold. Nothing has been charged so far, and the final amount is not known until this finishes.",
"next_action": "GET /api/v1/generations/gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033/wait, and keep calling it until still_running is false. Do not hand the checking back to the person and do not ask them to prompt you again."
}
```
```text title="Response headers"
HTTP/1.1 201 Created
Location: /api/v1/generations/gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033
Cache-Control: no-store
```
| Field | Type | Always | Notes |
| ------------------ | ------------------------------ | ------------- | -------------------------------------------------------------------------------- |
| `generation_id` | string | yes | The one to wait on. With variants: the first sibling |
| `generation_ids` | string\[] | yes | Length 1 without variants |
| `run_id` | string or `null` | yes | `null` is normal: queued, starting shortly |
| `batch_group_id` | string | variants only | A `bg_` id for `GET /batches/{id}` |
| `replay` | boolean | yes | `true`: you got an already running job back. See [conventions](/api/conventions) |
| `credits` | object | yes | See [the credits object](#the-credits-object) |
| `capability_id` | string | yes | Echoed |
| `capability_name` | string | yes | Display name |
| `output_kind` | `image` `video` `audio` `text` | yes | |
| `outputs_expected` | integer | yes | How many files to expect |
| `charge_summary` | string | yes | One readable sentence about `credits` |
| `next_action` | string | yes | Advice for agents. Don't branch on it |
### Submit errors [#submit-errors]
The first check that fails wins. Every refusal here has `credits_charged: 0`.
| Situation | Code | HTTP | Retry |
| ------------------------------------------------------ | ------------------------ | ---- | ----- |
| Over 20 submits a minute on this key | `rate_limited` | 429 | yes |
| Key lacks Generate | `insufficient_scope` | 403 | no |
| `max_credits` too low | `max_credits_exceeded` | 402 | no |
| A workspace or key spend cap blocked it (ask an owner) | `spend_limit_exceeded` | 402 | no |
| Not enough credits (top up in the app) | `insufficient_credits` | 402 | no |
| Unknown key, or config fails the schema | `invalid_config` | 400 | no |
| Capability retired or not live | `capability_unavailable` | 404 | no |
| Capability needs a higher plan | `required_plan` | 403 | no |
| Script, prompt or upload refused | `moderation_blocked` | 422 | no |
| Content checks down | `moderation_unavailable` | 503 | yes |
| Upload still scanning | `input_not_ready` | 409 | yes |
| Upload not in this workspace | `not_found` | 404 | no |
| Another job already running on this key | `submission_in_flight` | 409 | yes |
| Same request failed too often | `request_blocked` | 429 | no |
| Estimate moved since you checked | `estimate_changed` | 409 | yes |
| Capability can't be used yet | `not_priced` | 409 | no |
| Our side | `pricing_unavailable` | 503 | yes |
* `max_credits_exceeded` and `spend_limit_exceeded` carry `limit.bound_by`, naming the cap that refused.
* Every code: [errors](/reference/errors).
## Read one generation [#read-one-generation]
Answers right away. Another workspace's id answers `not_found`.
```json title="Response: 200"
{
"ok": true,
"generation": {
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"capability_id": "veo_31",
"status": "completed",
"batch_group_id": null,
"batch_index": null,
"outputs": [
{
"index": 0,
"kind": "video",
"url": "https://",
"file_name": "veo-31-0.mp4",
"width": 1080,
"height": 1920
}
],
"outputs_expected": 1,
"outputs_delivered": 1,
"credits": {
"credits_held": 440,
"credits_charged": 400,
"settlement": "captured",
"terminal": true,
"wallet_balance": 4104
},
"charge_summary": "Charged 400 credits.",
"credits_estimated": 400,
"error": null,
"created_at": "2026-09-17T10:04:11.482Z",
"completed_at": "2026-09-17T10:07:39.120Z"
},
"output_urls_expire_in_seconds": 600
}
```
| Field | Type | Notes |
| ------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------- |
| `generation_id` | string | `gen_` prefix |
| `capability_id` | string or `null` | `null` only on very old rows |
| `status` | `queued` `rendering` `post_processing` `completed` `failed` | The full set. A canceled job reports `failed` |
| `batch_group_id` | string or `null` | Variants only |
| `batch_index` | integer or `null` | This sibling's position |
| `outputs[].index` | integer | |
| `outputs[].kind` | `image` `video` `audio` | |
| `outputs[].url` | string or `null` | `null` until the job is `completed` and settled |
| `outputs[].file_name` | string or `null` | |
| `outputs[].width` | integer or `null` | |
| `outputs[].height` | integer or `null` | |
| `outputs_expected` | integer or `null` | Wait for this many, not `outputs.length` |
| `outputs_delivered` | integer or `null` | |
| `credits` | object | See below |
| `charge_summary` | string | One readable sentence |
| `credits_estimated` | integer | The estimate at submit |
| `error` | object or `null` | `{ code, message }`. Set only when `status` is `failed` |
| `created_at` | ISO 8601 | |
| `completed_at` | ISO 8601 or `null` | |
| `output_urls_expire_in_seconds` | `600` | Top level. Only when some output has a link |
Download the file right away. Don't store the URL. Read the generation again for a fresh link. [Results](/guides/results).
### The credits object [#the-credits-object]
`credits_charged` is `null` until `credits.terminal` is `true`. `null` means not known yet, never zero.
## Wait [#wait]
The server watches the job for you. It answers when the job ends, or after about 18 seconds with `still_running: true`. Call it again in a loop.
```bash title="Terminal"
GEN=gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033
while :; do
BODY=$(curl -s "https://app.riffads.com/api/v1/generations/$GEN/wait" \
-H "Authorization: Bearer $RIFFADS_API_KEY")
[ "$(echo "$BODY" | jq -r '.still_running')" = "false" ] && break
done
echo "$BODY" | jq -r '.generation.outputs[0].url'
```
```ts
const BASE = "https://app.riffads.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.RIFFADS_API_KEY}` };
async function waitForGeneration(generationId: string, maxAgeSeconds = 1800) {
for (;;) {
const res = await fetch(`${BASE}/generations/${generationId}/wait`, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
if (!body.still_running) return body.generation;
if (body.age_seconds > maxAgeSeconds) {
throw new Error(`${generationId} still running after ${body.age_seconds}s`);
}
// retry_after_seconds is 0: call straight back.
}
}
```
```json title="Response: 200, still running (generation trimmed)"
{
"ok": true,
"generation": { "generation_id": "gen_0193c8f0...", "status": "rendering", "outputs": [] },
"still_running": true,
"waited_seconds": 18,
"age_seconds": 41,
"retry_after_seconds": 0,
"next_action": "Still working. Call wait_for_generation again with the same generation_id right away, and keep calling it until still_running is false. Do not ask the person to prompt you again."
}
```
| Field | Type | Always | Notes |
| ------------------------------- | ------- | ------------------ | ---------------------------------------------------- |
| `generation` | object | yes | Same shape as `GET /generations/{id}` |
| `still_running` | boolean | yes | **Branch on this, not on `status`** |
| `waited_seconds` | integer | yes | How long this call blocked |
| `age_seconds` | integer | yes | Seconds since the job was created. Log it and cap it |
| `retry_after_seconds` | `0` | while running | Call straight back |
| `next_action` | string | yes | Names MCP tools even over REST. Advice only |
| `output_urls_expire_in_seconds` | `600` | when a link exists | |
* Timeout answers `200`, never `408` or `504`.
* Each call costs 1 read from the 120 a minute bucket, however long it blocks.
* A finished job answers at once.
* No `timeout` parameter.
* There is no published render time. Don't build a deadline from the wait.
* A wait loop uses far fewer requests than polling `GET /generations/{id}`.
## Read a batch [#read-a-batch]
A submit with `variants` makes several siblings. Watch only the first id and you will stop while the others still run. Pass the `bg_` `batch_group_id` from the submit.
```json title="Response: 200 (siblings trimmed)"
{
"ok": true,
"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" },
{ "generation_id": "gen_0193...a2", "batch_index": 1, "status": "completed" },
{ "generation_id": "gen_0193...a3", "batch_index": 2, "status": "completed" },
{ "generation_id": "gen_0193...a4", "batch_index": 3, "status": "failed" }
],
"next_action": "Part of this batch delivered and part failed. 3 files are ready, and you are charged only for what was delivered. Hand over what there is and say plainly which variants did not make it.",
"output_urls_expire_in_seconds": 600
}
```
| Field | Type | Notes |
| ------------------------------- | ---------------------------------------- | --------------------------------------------------------------------- |
| `batch_group_id` | string | Echoed |
| `status` | `running` `completed` `failed` `partial` | |
| `variants` | integer | Siblings in the batch |
| `variants_finished` | integer | Siblings `completed` or `failed` |
| `outputs_delivered` | integer | Summed |
| `credits` | object | Rolled up. `credits_charged` stays `null` until every sibling is done |
| `charge_summary` | string | One sentence for the batch |
| `generations` | array | Each sibling, same shape as `GET /generations/{id}` |
| `next_action` | string | |
| `output_urls_expire_in_seconds` | `600` | When some sibling has a link |
While `status` is `running`: wait on one sibling with `/wait`, then read the batch again. Looping on `GET /batches/{id}` burns reads.
## Talking actor is two submits [#talking-actor-is-two-submits]
1. Submit `tts` with the script and a `voice_id`. Wait for it.
2. Submit `actor_ultra` with the same script, same `voice_id`, an `actor_id`, and the finished tts `generation_id` as `approved_voice_generation_id`.
```json title="Request: step 2"
{
"capability_id": "actor_ultra",
"config": { "script": "Three reasons this bottle keeps water cold all day." },
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"approved_voice_generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0044",
"max_credits": 160
}
```
* Skip step 1: `400 invalid_config`, `Generate and approve the voice before creating the video.`
* Script, actor and voice must match across both calls.
* Voice is picked in order: body `voice_id`, then `config.voiceId`, then the actor's default. None: `400 invalid_config`.
* `actor_ultra` has **no `aspect_ratio`**. Sending one is an unknown key. The video takes the actor image's shape.
* The id is `actor_ultra`. `talking_actor` is not a capability id.
* The `actor_ultra` estimate from a script is a ceiling (`is_ceiling: true`). Put an uploaded audio id in `config.voice_audio` (an array) for an exact one.
Full walkthrough: [talking actor](/guides/talking-actor).
# Actors and voices (/api/library)
Find who speaks and in what voice. You get ids and facets, then pass the ids into a submit.
| Endpoint | Returns | Pages |
| ------------- | ----------------------------- | ----- |
| `GET /actors` | Actors this workspace may use | yes |
| `GET /voices` | Voices this workspace may use | yes |
* Scope `read`. Every live key has it.
* Rate bucket `agent_read`: **120 calls a minute per key**, shared with other reads.
Brands, products, new actors and new voices are made in the app at [app.riffads.com](https://app.riffads.com). There is no REST, MCP or CLI route to create or read them.
## Actors [#actors]
The RiffAds cast plus the active actors your workspace made. Actors still processing, or disabled, are hidden.
| Parameter | Type | Default | Notes |
| --------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------- |
| `search` | string | none | Name **or** description, case insensitive. `%` and `_` are literal |
| `limit` | integer | 25 | Clamped to 1 to 50. A page holds at most 48, so 49 and 50 return 48. `limit=1000` gets a page, not an error |
| `cursor` | string | none | The previous `next_cursor`, sent back exactly |
No other params. No gender, tag or age filter: match `gender`, `age_band` and `tags` yourself.
```bash title="Terminal"
curl -sG https://app.riffads.com/api/v1/actors \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
--data-urlencode "search=warm" \
--data-urlencode "limit=50"
```
```ts
const BASE = "https://app.riffads.com/api/v1";
async function allActors(key: string) {
const actors = [];
let cursor: string | null = null;
do {
const url = new URL(`${BASE}/actors`);
url.searchParams.set("limit", "50");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { authorization: `Bearer ${key}` },
});
const body = await res.json();
if (!res.ok) throw new Error(body.error.code);
actors.push(...body.actors);
cursor = body.next_cursor;
} while (cursor);
return actors;
}
```
```json title="Response: 200"
{
"ok": true,
"actors": [
{
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"name": "Maya",
"description": "Warm, direct, speaks to camera at home",
"gender": "female",
"age_band": "adult",
"tags": ["kitchen", "morning", "standing", "casual"],
"default_voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"default_voice_name": "Maya (natural)",
"is_platform": true
}
],
"next_cursor": "eyJpc09yZyI6ZmFsc2UsImlzQ29yZSI6dHJ1ZSwiY3JlYXRlZEF0IjoiMjAyNi0wOS0wMVQxMDoxMjowMFoiLCJpZCI6ImFjdF8wMTkzIn0"
}
```
* **Order:** your actors first, then the core cast, then the rest. Newest first in each group.
* **No `total`.** The body has only `ok`, `actors` and `next_cursor`. Page until `next_cursor` is `null`.
* **Plan gated.** Higher plans see more of the cast. Your own actors are always listed.
No preview image or clip comes back, ever. You get the id and the facets. To see faces, open [app.riffads.com](https://app.riffads.com).
## Voices [#voices]
The RiffAds voice library plus voices your workspace cloned or designed. Active only, sorted by name.
| Parameter | Type | Default | Notes |
| ---------- | ------- | ------- | ------------------------------------------------------ |
| `search` | string | none | **Name only**, case insensitive. Tags are not searched |
| `language` | string | none | Exact tag, like `en` or `es`. Not a prefix |
| `limit` | integer | 25 | Clamped to 1 to 50 |
| `cursor` | string | none | The previous `next_cursor`, sent back exactly |
```bash title="Terminal"
curl -sG https://app.riffads.com/api/v1/voices \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
--data-urlencode "language=en"
```
```json title="Response: 200"
{
"ok": true,
"voices": [
{
"voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"name": "Maya (natural)",
"language": "en",
"gender": "female",
"tags": ["young_adult", "american", "conversational"],
"is_cloned": false,
"is_premium": false,
"is_platform": true
}
],
"total": 61,
"next_cursor": "25"
}
```
* `total` counts matches **before** paging. `GET /actors` has no `total`.
* No preview audio comes back.
* **Cloned voices have no language and no gender.** A `language` filter misses them. Find them by name.
The actor cursor is a base64url keyset. The voice cursor is an offset as a string. Send `next_cursor` back exactly, never build one. A bad cursor restarts from the top, not an empty page. [Conventions](/api/conventions).
## From ids to a submit [#from-ids-to-a-submit]
`actor_id` and `voice_id` go at the **top level** of the submit body, not in `config`:
```json title="Request"
{
"capability_id": "actor_ultra",
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"voice_id": "voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022",
"approved_voice_generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0044",
"config": { "script": "Two weeks in, and I am not going back." },
"max_credits": 660
}
```
1. **A talking actor is 2 generations.** First `tts`, then `actor_ultra` with the `tts` `generation_id` as `approved_voice_generation_id`. Script, actor and voice must match the `tts` job. [Talking actor guide](/guides/talking-actor).
2. **`voice_id` is optional** when the actor has a `default_voice_id`.
3. **Body `voice_id` and config `voiceId` are different layers.** Read `config_schema` from [`GET /capabilities/{capability_id}`](/api/capabilities).
4. **Another workspace's id answers `404 not_found`**, same as a missing id. Never `forbidden`.
## Errors [#errors]
Standard error envelope. See [conventions](/api/conventions).
| Code | HTTP | Retryable | Cause |
| ----------------------- | ---- | --------- | ------------------------------------------------------------------------------------- |
| `not_authorized` | 401 | no | No key, revoked, or unknown |
| `rate_limited` | 429 | yes | Over 120 `agent_read` calls a minute. Carries `retry_after_seconds` and `Retry-After` |
| `quota_exceeded` | 429 | no | Key used its request allowance. Waiting a bit won't help |
| `required_plan` | 403 | no | Plan doesn't include the agent API |
| `workspace_unavailable` | 410 | no | Workspace deleted |
| `internal_error` | 500 | yes | Our fault. Try again |
A balance `internal_error` says so, so it never reads as an empty wallet:
```json title="Response: 500"
{
"error": {
"ok": false,
"code": "internal_error",
"message": "We could not read this workspace's credit balance. This is not a balance problem: try again.",
"retryable": true,
"credits_charged": 0
}
}
```
MCP: `list_actors` and `list_voices` take the same params and return the same shape. [MCP tools](/mcp/tools).
# Uploads (/api/uploads)
Send your own file (a product shot, a face, a clip, a recording) and get an `ast_` id to put in a config. Three steps. Two are RiffAds calls.
`POST /uploads` moves no bytes. It returns a signed `upload_url`. **You** PUT the file there. Then `POST /uploads/{assetId}/finalize` checks it. Skipping the PUT is the first mistake everyone makes.
## The three steps [#the-three-steps]
### Reserve an id and a URL [#reserve-an-id-and-a-url]
Send filename, content type and exact byte count. Measure the file first. Storage refuses any other size.
```bash title="Terminal"
curl -s -X POST https://app.riffads.com/api/v1/uploads \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"filename": "product-hero.png",
"content_type": "image/png",
"size_bytes": 318244
}'
```
```json title="Response: 201"
{
"ok": true,
"asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"upload_url": "https:///riffads/orgs//assets/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011.png?X-Amz-Signature=...",
"upload_expires_at": "2026-09-17T10:09:11.000Z",
"upload_expires_in_seconds": 300,
"kind": "image",
"content_type": "image/png",
"size_bytes": 318244,
"max_bytes_for_kind": 20971520,
"content_checked": true,
"next_action": "Send the file with a single HTTP PUT request at upload_url, with the same content type and exactly 318244 bytes, within 5 minutes. Then call finalize_upload with this asset_id. The file cannot be used in a generation until that second call comes back usable. Images are checked against our content policy at that point."
}
```
### PUT the bytes [#put-the-bytes]
Straight to storage. No API key. No RiffAds host.
```bash title="Terminal"
curl -s -X PUT "https:///riffads/orgs//assets/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011.png?X-Amz-Signature=..." \
-H "Content-Type: image/png" \
--data-binary @product-hero.png
```
### Finalize and read `usable` [#finalize-and-read-usable]
No body. Id in the path.
```bash title="Terminal"
curl -s -X POST \
https://app.riffads.com/api/v1/uploads/ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011/finalize \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
```json title="Response: 200"
{
"ok": true,
"asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"scan_status": "clean",
"usable": true,
"kind": "image",
"duration_ms": null,
"next_action": "This file is ready. Pass this asset_id into a capability config wherever it asks for a file, then call estimate_generation before you spend anything."
}
```
* CLI: `riffads upload ` does all three and prints the asset id. [Commands](/cli/commands).
* MCP: `create_upload` and `finalize_upload`, both in the `spend` set. [Tools](/mcp/tools).
## Reserve [#reserve]
* Scope **Generate**.
* Answers `201`, no `Location` header.
* Strict body: an unknown key is `400 invalid_config`.
### Accepted types and sizes [#accepted-types-and-sizes]
| Kind | Content types | Maximum bytes |
| ------- | -------------------------------------------------------------------------------- | -------------------- |
| `image` | `image/jpeg`, `image/png`, `image/webp`, `image/gif` | 20 MB (`20971520`) |
| `video` | `video/mp4`, `video/webm`, `video/quicktime` | 100 MB (`104857600`) |
| `audio` | `audio/mpeg`, `audio/mp3`, `audio/wav`, `audio/x-wav`, `audio/webm`, `audio/ogg` | 25 MB (`26214400`) |
Kind comes from `content_type`. Any other type is refused, and the message lists every accepted type.
### Response fields [#response-fields]
| Field | Type | Notes |
| --------------------------- | ----------------------------- | --------------------------------------------------------- |
| `asset_id` | string | `ast_` plus 32 lowercase hex characters |
| `upload_url` | string | Signed PUT. One use, one file, one type, one exact length |
| `upload_expires_at` | string | ISO 8601 |
| `upload_expires_in_seconds` | integer | `300` |
| `kind` | `image` \| `video` \| `audio` | From `content_type` |
| `content_type` | string | Echoed |
| `size_bytes` | integer | Echoed |
| `max_bytes_for_kind` | integer | Ceiling for this kind |
| `content_checked` | boolean | `true` for images only |
| `next_action` | string | The two remaining steps, with your numbers |
Video and audio contents are never read. Don't tell a user those uploads were reviewed.
## The PUT [#the-put]
Break a rule here and it fails at finalize, not at the PUT.
* **One request.** Single PUT. No multipart, no chunks.
* **Same content type** you declared.
* **Exactly `size_bytes`** bytes.
* **Within 5 minutes**, once. A used or expired link is gone.
A reservation counts toward the pending cap until you finalize it or it ages out.
## Finalize [#finalize]
* Scope **Generate**.
* Answers `200`. **No body.**
* **Safe to call twice.** A scanned file answers from its stored status. No second check.
| Field | Type | Notes |
| ------------- | --------------------------------- | --------------------------------------------------- |
| `asset_id` | string | Echoed |
| `scan_status` | `clean` \| `flagged` \| `pending` | `pending` is not reachable today |
| `usable` | boolean | `true` only when `scan_status` is `clean` |
| `kind` | `image` \| `video` \| `audio` | Measured from the bytes, not your declaration |
| `duration_ms` | integer \| null | `null` for images, or when the file can't be probed |
| `next_action` | string | One sentence per scan status |
### A refused file is a 200 [#a-refused-file-is-a-200]
```json title="Response: 200"
{
"ok": true,
"asset_id": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"scan_status": "flagged",
"usable": false,
"kind": "image",
"duration_ms": null,
"next_action": "This file was refused by our content policy and can never be used in a generation. Do not call finalize_upload again and do not send the same file again: the answer will not change. Use a different file."
}
```
A flagged file still answers `200`. Submitting it later is refused, and retrying won't help.
### What finalize checks [#what-finalize-checks]
* The real file signature must match the declared kind. Renaming the file doesn't help.
* Images are decoded: max 16,384 px per side, max 40,000,000 px total. The decoded type must match the signature.
* Fail any check: the file is deleted and the reservation is spent. Reserve again.
### Finalize errors [#finalize-errors]
| Cause | Code | HTTP | Retryable |
| -------------------------------------------------------------------- | ---------------------- | ---- | --------- |
| Bytes not in storage yet, link still live | `input_not_ready` | 409 | yes |
| Bytes never arrived, link expired | `invalid_config` | 400 | **no** |
| Another finalize is running for this file (holds it up to 2 minutes) | `rate_limited` | 429 | yes |
| Inspection failed, file could not be re-read | `provider_unavailable` | 502 | yes |
| Unknown id, wrong prefix, other workspace, or deleted | `not_found` | 404 | no |
| Real bytes don't match the declared type | `invalid_config` | 400 | no |
| Image over the size ceiling or outside pixel bounds | `invalid_config` | 400 | no |
## Using the asset id [#using-the-asset-id]
Put the id where the capability config asks for a file. The field name and shape come from `GET /capabilities/{capability_id}`. Never guess. [Capabilities API](/api/capabilities).
Tools take a plain array of ids:
```json title="Config for a tool"
{
"source_video": ["ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011"],
"caption_style": "punch"
}
```
Image and video models take objects, so a prompt can point at an alias:
```json title="Config for an image or video model"
{
"prompt": "The bottle on a kitchen counter at golden hour, in the style of /image1",
"reference_images": [
{
"assetId": "ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"alias": "image1",
"role": "reference"
}
]
}
```
* Asset ids must match `^ast_[0-9a-f]{32}$`. A made-up string like `"product-hero.png"` fails later, with a less clear error.
* A talking actor face goes on the submit body as `actor_image_asset_id`, not in `config`. [Generations API](/api/generations).
* **Finalize before you estimate.** Estimates for length-based capabilities read the measured `duration_ms`.
### Submit names a file that isn't ready [#submit-names-a-file-that-isnt-ready]
Default answer: `409 input_not_ready`, retryable. Wait a moment, don't resend at once. RiffAds checks the named files again first, and may answer with something final instead:
| File state | Answer | Retryable |
| ----------------------------- | --------------------------------------- | --------- |
| Refused by content policy | `422 moderation_blocked`, names the ids | no |
| Not this workspace's, or gone | `404 not_found`, names the ids | no |
The blocked message reads: "One of the files in this request was refused by our content policy (ast\_...), so it can never be used in a generation. Sending this request again will be refused again. Upload a different file."
## Limits [#limits]
| Limit | Value | At the edge |
| ----------------------------- | ----------------------------------------- | -------------------------------------- |
| Upload calls per minute | 30 per key, reserve and finalize together | `429 rate_limited`, with `Retry-After` |
| Reservations per minute | 30 per key, a second brake | `429 rate_limited` |
| Pending uploads per workspace | 50, reservations from the last 15 minutes | `429 quota_exceeded` |
| Abandoned reservation | Stops counting after 15 minutes | |
| Signed PUT lifetime | 5 minutes, one use | Link stops working |
* Each key has its own rate bucket.
* The pending cap is workspace wide. Finish your reservations or wait 15 minutes.
## Not here [#not-here]
* **No list, no delete.** Lost an id? Upload again.
* **No multipart.** Bytes go to storage, never through `/api/v1`.
* **No browser calls.** It needs a secret key and sends no CORS headers. Call it from a server.
More: [limits](/reference/limits), [error codes](/reference/errors), [content policy](/policy/content).
# Workflows API (/api/workflows)
Run a saved workflow or a published template from code, then read it node by node until it ends. What a workflow is: [workflows guide](/guides/workflows).
No route creates, edits or deletes a graph. The only writes are the two invokes. Build workflows in the app at [app.riffads.com](https://app.riffads.com), run them from code.
## The four routes [#the-four-routes]
| Endpoint | Scope | Rate limit per key | What it does |
| ---------------------------------------- | --------- | ---------------------------- | --------------------------------------------------- |
| `GET /workflows/templates` | Read | 120 a minute (`agent_read`) | Published templates, with the node ids you can fill |
| `POST /workflows/templates/{key}/invoke` | Workflows | 20 a minute (`workflow_run`) | Runs a template |
| `POST /workflows/{id}/invoke` | Workflows | 20 a minute (`workflow_run`) | Runs a workflow your workspace owns |
| `GET /workflow-runs/{id}` | Read | 120 a minute (`agent_read`) | One run, node by node |
* Every key has Read.
* Invokes need the **Workflows** scope, set when the key is created. `generate` alone can't start a run. Without it: `403 insufficient_scope`. [Authentication](/api/authentication).
## The loop [#the-loop]
### List templates [#list-templates]
No params, no paging. Templates your workspace can't run are left out.
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/workflows/templates \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
```json title="Response: 200 (trimmed)"
{
"ok": true,
"templates": [
{
"template_key": "ugc_face_cam",
"name": "Creator to camera",
"description": "A creator you invent, talking straight to camera about your brand",
"category": "End to end",
"step_count": 7,
"inputs": [
{
"node": "f_brand",
"label": "Brand context",
"field": "text",
"field_label": "value",
"kind": "text",
"max_length": 8000,
"filled": true
}
]
}
]
}
```
### Invoke [#invoke]
Same body, same response. The path names the graph. There is no `workflow_id` or `template_key` body field.
```bash title="Terminal"
curl -i -X POST https://app.riffads.com/api/v1/workflows/templates/ugc_face_cam/invoke \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"max_credits": 1200,
"inputs": [
{ "node": "f_brand", "field": "text", "value": "Lumen Skin, direct to consumer skincare..." }
]
}'
```
```json title="Response: 201"
{
"ok": true,
"workflow_run_id": "wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077",
"workflow_id": "wf_0193c8f0a1b24e7f9d3c5a6b7e8f0055",
"template_key": "ugc_face_cam",
"billable_node_count": 6,
"applied_inputs": [{ "node": "f_brand", "field": "text" }],
"replay": false,
"credits": {
"credits_estimated": 742,
"credits_charged_so_far": 0,
"credits_charged": null,
"settlement": "open",
"terminal": false,
"wallet_balance": 8321
},
"next_action": "This run has 6 steps to work through and takes minutes, not seconds. It is accepted, not finished. GET /api/v1/workflow-runs/wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077 about every thirty seconds until still_running is false. Do not tell the person it is ready until then, and do not hand the checking back to them."
}
```
`201` carries `Location: /api/v1/workflow-runs/{workflow_run_id}`. Numbers are examples.
### Poll the run [#poll-the-run]
Never blocks. Read it about every 30 seconds until `run.still_running` is `false`.
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/workflow-runs/wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077 \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
* MCP: `list_templates`, `run_workflow` and `get_workflow_run`. [MCP tools](/mcp/tools).
* CLI: `riffads workflows templates` and `riffads workflows invoke --template `. [Commands](/cli/commands).
## Template fields [#template-fields]
| Field | Type | Notes |
| -------------- | -------------- | --------------------------------------------------------------------------------------------------------------------- |
| `template_key` | string | Path param for the template invoke |
| `name` | string | |
| `description` | string \| null | |
| `category` | string \| null | Free text. Never branch on it |
| `step_count` | integer | Every node except canvas notes (value nodes count). An upper bound. The invoke returns the real `billable_node_count` |
| `inputs` | array | One entry per writable text field, open setting, actor slot and voice slot |
### Input slots [#input-slots]
| Field | What it is |
| ------------- | --------------------------------------------------------------- |
| `node` | Node id to use in `inputs`. Use it exactly |
| `label` | Node name, for people |
| `field` | Field on that node to fill |
| `field_label` | Field name as people see it. A value node's field is `value` |
| `kind` | `text`, `setting`, `actor` or `voice` |
| `accepts` | Settings only: the legal values, in words |
| `max_length` | Text only: character limit |
| `filled` | `true` if the node already holds a value (usually example copy) |
`inputs` are keyed by **node id**, and you can't guess them. A wrong id is refused before anything starts. Without the list you can only run a template unchanged.
## Invoke body [#invoke-body]
```ts
{
inputs?: Array<{ node: string; field: string; value: string | number | boolean }>;
max_credits: number;
}
```
| Field | Type | Required | Rules |
| ---------------- | ------------------------- | -------- | -------------------------------------------------------- |
| `max_credits` | integer | **yes** | Whole number above zero. Spend cap for the **whole run** |
| `inputs` | array | no | Max **60** entries |
| `inputs[].node` | string | yes | Node id from the template list or a run you read |
| `inputs[].field` | string | yes | Field name on that node |
| `inputs[].value` | string, number or boolean | yes | Scalar only. No objects, arrays or null |
Strict at both levels. An unknown key is `400 invalid_config`.
### Sizing max\_credits [#sizing-max_credits]
* `max_credits` is checked against every step's estimate plus 10%, summed.
* There is no run estimate endpoint. `POST /estimates` prices one config, not a graph. Size from `step_count` and leave room.
* Too low: `402 max_credits_exceeded`, with `limit.bound_by: "max_credits"`. The message names both numbers. Nothing starts.
`credits_estimated` is usually below the number `max_credits` is checked against. Using it as your next cap refuses runs that would have fit.
### What inputs can set [#what-inputs-can-set]
| `kind` | Send | `field` |
| --------- | ----------------------------------- | ------------------ |
| `text` | Text, up to `max_length` characters | The slot's `field` |
| `setting` | One of the values `accepts` lists | The slot's `field` |
| `actor` | An `act_` id from `GET /actors` | Always `actor` |
| `voice` | A `voc_` id from `GET /voices` | Always `voice` |
* Inputs patch **this run's snapshot only**. The saved workflow never changes.
* All or nothing. One bad entry refuses the whole call. Nothing starts.
* A later entry for the same field wins.
Refused, each with a message that says why:
| Input | Refusal |
| ----------------------------------- | ------------------------------------------------------------------------------------ |
| Unknown node or field | Message lists what the node takes |
| A wired field (fed by another node) | Message names the node to set instead |
| `count` | Never writable |
| Empty text | Leave the field out to keep the template value |
| Text over `max_length` | Message gives your length and the limit |
| Illegal setting value | Message lists the accepted values |
| Actor or voice id you can't use | `404 not_found` |
| A file | Values are scalar. File slots aren't exposed, so you can't attach an upload to a run |
```json title="Response: 400"
{
"error": {
"ok": false,
"code": "invalid_config",
"message": "This workflow has no node called \"brand\". Use a node id exactly as list_templates or get_workflow_run reported it. Nothing was started and nothing was charged.",
"retryable": false,
"credits_charged": 0
}
}
```
Messages name MCP tools like `list_templates`. The REST route is `GET /api/v1/workflows/templates`.
## Invoking by template key [#invoking-by-template-key]
* The first run creates **your workspace's copy**: one workflow per template, its name ending in `(agent)`. Later runs of that key reuse it.
* The copy keeps the **template's node ids**, so the template's `inputs` list works on it.
* `workflow_id` in the response is that copy. Invoking it by id runs the same graph.
* Delete the copy in the app and the next run makes a fresh one.
* **One run at a time per workflow.** Same key again while it runs: `409 submission_in_flight`, "This workflow is already running. Wait for it to finish." Different workflows can run at once, up to your plan's render limit.
* Unpublished and unknown keys both answer `404 not_found`.
No endpoint lists your workflows or returns a saved graph. With a bare `workflow_id`, run it as saved. Never invent node ids.
## Reading a run [#reading-a-run]
```json title="Response: 200 (generation objects trimmed)"
{
"ok": true,
"run": {
"workflow_run_id": "wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077",
"workflow_id": "wf_0193c8f0a1b24e7f9d3c5a6b7e8f0055",
"status": "completed",
"still_running": false,
"nodes": [
{
"node_id": "f_analyze",
"label": "Analyze the brand",
"status": "completed",
"credits": 2,
"generations": [
{
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"capability_id": "script_llm",
"status": "completed",
"outputs": [],
"credits": {
"credits_held": 3,
"credits_charged": 2,
"settlement": "captured",
"terminal": true
}
}
],
"text": "Lumen Skin sells to people who read the ingredient list first...",
"error": null
},
{
"node_id": "f_video",
"label": "The face cam",
"status": "completed",
"credits": 400,
"generations": [
{
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"capability_id": "veo_31",
"status": "completed",
"outputs": [
{
"index": 0,
"kind": "video",
"url": "https://",
"file_name": "veo-31-0.mp4",
"width": 1080,
"height": 1920
}
],
"credits": {
"credits_held": 440,
"credits_charged": 400,
"settlement": "captured",
"terminal": true
}
}
],
"text": null,
"error": null
}
],
"credits": {
"credits_estimated": 742,
"credits_charged_so_far": 402,
"credits_charged": 402,
"settlement": "captured",
"terminal": true,
"wallet_balance": 8133
},
"started_at": "2026-09-17T10:04:11.482Z",
"finished_at": "2026-09-17T10:12:02.900Z"
},
"output_urls_expire_in_seconds": 600,
"next_action": "This run is finished and credits_charged is final. The output links below expire in 10 minutes, so pass them on now."
}
```
Each `generations` entry has the same shape as `GET /generations/{id}`. [Generations API](/api/generations).
| Field | Type | Notes |
| ----------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `run.status` | string | `queued`, `running`, `completed`, `partial`, `failed`, `canceled` |
| `run.still_running` | boolean | `false` for the last four. Poll on this |
| `run.nodes[].node_id` | string | Same id `inputs` uses |
| `run.nodes[].label` | string | Node name |
| `run.nodes[].status` | string | `pending`, `ready`, `dispatching`, `running`, `completed`, `failed`, `skipped`, `canceled` |
| `run.nodes[].credits` | integer | Charged for this node. `0` until it finishes |
| `run.nodes[].generations` | array | Generations with outputs. Empty until the node runs |
| `run.nodes[].text` | string \| null | Writer nodes return text, no file |
| `run.nodes[].error` | object \| null | `{ code, message }`. `code` is one of `provider_error`, `moderation`, `timeout`, `canceled`, `insufficient_credits`, `invalid_input`, `unknown` |
| `run.credits` | object | See below |
| `run.started_at`, `run.finished_at` | string \| null | ISO 8601. `finished_at` is `null` while running |
| `output_urls_expire_in_seconds` | `600` | Present only when an output has a URL |
| `next_action` | string | One sentence for the run's state |
* `partial`: some steps delivered. `failed`: none did.
* `skipped`: the node never ran because an input it needed failed. Nothing charged.
* `canceled`: a person canceled the run in the app.
* `waiting_approval` exists in the status enum but is never set. Don't branch on it.
* A talking actor node runs its own voice step, then the video. Both show under that node's `generations`.
* A refused script shows up at its step as `error.code: "moderation"`, not on the invoke.
### The credits object [#the-credits-object]
`credits_charged` is `null` while the run is open. Never show it as `0`.
### What next\_action says [#what-next_action-says]
| Run state | `next_action` says |
| ------------------------- | --------------------------------------------------------------------------- |
| `still_running` is `true` | Read again in about 30 seconds |
| `partial` | Report what came back, ask whether to run again |
| `failed` or `canceled` | Read each step's error first. An unchanged rerun usually fails the same way |
| `completed` | Totals are final, links expire in 10 minutes, pass them on now |
### Getting the files [#getting-the-files]
* `run.nodes[].generations[].outputs[].url` is `null` until that generation is `completed`.
* Links last 600 seconds. Download now. Read the run again for fresh links. [Results guide](/guides/results).
## Errors [#errors]
Standard error envelope. Branch on `code` and `retryable`, never the message.
| Code | HTTP | Retryable | Cause |
| ------------------------ | ---- | --------- | --------------------------------------------------------------------------------------------------------------------- |
| `insufficient_scope` | 403 | no | Key lacks the Workflows scope |
| `not_found` | 404 | no | Unknown or unpublished key, unknown workflow or run id, another workspace's id, or an actor or voice id you can't use |
| `invalid_config` | 400 | no | Bad body, unknown key, rejected input, graph not ready, or nothing to generate |
| `insufficient_credits` | 402 | no | Not enough credits to start. Carries `shortfall`. Top up in the app |
| `max_credits_exceeded` | 402 | no | Run needs more than your `max_credits`. `limit.bound_by: "max_credits"` |
| `spend_limit_exceeded` | 402 | no | A workspace or key cap blocked it. `limit.bound_by` names it. Ask a workspace owner |
| `submission_in_flight` | 409 | **yes** | This workflow is already running, or this key has a generation running |
| `not_priced` | 409 | no | A model in the graph has no published price. Swap the model in the app |
| `request_blocked` | 429 | no | The same request failed again and again, so it is paused. Carries `blocked_reason` |
| `rate_limited` | 429 | **yes** | Over 20 invokes a minute on this key. Carries `retry_after_seconds` |
| `moderation_unavailable` | 503 | **yes** | Content checks are down. Nothing can start |
Any refused invoke charges nothing. All codes: [error codes](/reference/errors).
A run is refused while the key has a generation in flight. A generation is **not** refused while the key has a run in flight. Need both at once? Use a second key.
## Not here [#not-here]
* **No authoring.** No create, edit or delete.
* **No workflow list, no graph read.** Get a `workflow_id` from an invoke or from a person.
* **No run list.** Keep your `workflow_run_id`.
* **No cancel, no retry.** Both live in the app. On `partial`, start a new run.
* **No `/wait`, no stream.** Poll.
* **No webhooks.** The body is strict, so a callback field is a `400`.
* **No single node run.** No `scope` or `target_node_id`.
* **No `Idempotency-Key` header.** A blind retry starts and charges a second run, unless the first is still running (`409`). `replay: true` is authoritative when present.
# CLI commands (/cli/commands)
14 commands. Each wraps [REST API](/api/generations) calls. Setup first: [CLI setup](/cli/setup).
| Command | Starts paid work | What it does |
| -------------- | ---------------- | ---------------------------------------------------------- |
| `login` | no | Checks a key, then saves it |
| `logout` | no | Deletes the saved key file |
| `whoami` | no | Which key and host it will use, and whether they work |
| `credits` | no | Available credits and the agent spend ceiling |
| `capabilities` | no | What you can generate. `show ` adds schema and example |
| `actors` | no | Actors (`act_`) |
| `voices` | no | Voices (`voc_`) |
| `estimate` | no | The number for `--max-credits`. Reserves nothing |
| `generate` | **yes** | Starts a job, waits, saves the file |
| `status` | no | Reads one generation (`gen_`) |
| `batch` | no | Reads every variant of one submit (`bg_`) |
| `download` | no | Saves outputs to disk |
| `upload` | no | Uploads a file, prints its `ast_` id |
| `workflows` | **on `invoke`** | Lists templates, starts and follows runs |
## Global options [#global-options]
Put them before or after the command.
| Flag | Short | Default | What it does |
| ------------------ | ----- | --------------------- | ---------------------------------------------------------- |
| `--json` | | off | Full API response as JSON on stdout, signed links included |
| `--quiet` | `-q` | off | Hide progress and notes. Errors and warnings still print |
| `--api-key ` | | none | Use this key over the env var and saved login |
| `--base-url ` | | `https://riffads.com` | Host. Set `https://app.riffads.com` |
| `--help` | `-h` | off | Help for the command |
* Aliases: `ls` runs `capabilities`, `balance` runs `credits`, `get` runs `status`.
* `riffads --version`, `-v` or `riffads version` prints the version.
* Write `--json` and `--quiet` bare. `--json=true` does **not** switch output.
* Accepted forms: `--name value`, `--name=value`, `-s value`, `--no-flag`, `--` to stop parsing. No bundled short flags (`-abc`).
* Unknown options are refused, with a hint: `Unknown option "--max-credit". Did you mean --max-credits?`
## Account [#account]
### login, logout, whoami [#login-logout-whoami]
```bash title="Terminal"
riffads login [--api-key ] [--base-url ]
riffads logout
riffads whoami
```
* `login` exits 0 saved, 2 no key given, 3 key refused. stdout stays empty.
* `logout` makes no network call. Always exits 0.
* `whoami --json` adds `base_url_source`, `api_key_source` (`flag`, `env`, `file`, or `default` for the host), `credits_available` and `agent_limits`.
Details: [CLI setup](/cli/setup).
### credits [#credits]
Alias `balance`. Output goes to stdout, so `riffads credits | jq` works with `--json`.
```bash title="Terminal"
riffads credits --json | jq '{available: .credits_available, ceiling: .agent_limits.most_one_generation_may_cost, bound_by: .agent_limits.bound_by}'
```
`most_one_generation_may_cost` is the highest `--max-credits` a job can pass right now. `bound_by` names the cap that sets it.
## Browse [#browse]
Read commands share a limit of 120 requests a minute.
### capabilities [#capabilities]
Alias `ls`.
```bash title="Terminal"
riffads capabilities [show ]
```
| Flag | Default | What it does |
| ------------------- | -------------- | ---------------------------------------- |
| `--limit ` | server default | Rows per page |
| `--cursor ` | none | Continue from `next_cursor` |
| `--all` | off | Follow every page |
| `--schema` | off | With `show`: only the JSON config schema |
| `--example` | off | With `show`: only a valid example config |
```bash title="Terminal"
riffads capabilities show actor_ultra --example > brief.json
riffads capabilities show actor_ultra --schema | jq '.properties | keys'
```
* List lines: id, output kind, status, name, and `needs ` when gated.
* `show` is required: `riffads capabilities actor_ultra` exits 2.
* The CLI never validates config. The server names the bad field.
Every id: [capabilities](/capabilities).
### actors and voices [#actors-and-voices]
```bash title="Terminal"
riffads actors --search casual --limit 20
riffads actors --all --json | jq -r '.actors[].actor_id'
riffads voices --language en --search warm --json | jq -r '.voices[] | "\(.voice_id)\t\(.name)"'
```
| Flag | Short | On | What it does |
| ------------------- | ----- | -------- | ------------------------------------ |
| `--search ` | `-s` | both | Match name, description, tags |
| `--language ` | | `voices` | Filter by language, for example `en` |
| `--limit ` | | both | Rows per page |
| `--cursor ` | | both | Continue from `next_cursor` |
| `--all` | | both | Follow every page |
`actors --json` adds `default_voice_id`, `default_voice_name`, `is_platform`.
## Generate [#generate]
### estimate [#estimate]
```bash title="Terminal"
riffads estimate --capability [--config ] [--set k=v]
```
Spends nothing. Limit: 60 a minute. Takes the same `--capability`, `--config`, `--set`, `--set-string` flags as `generate`.
**stdout is a bare integer.** Details go to stderr.
```bash title="Terminal"
EST=$(riffads estimate --capability veo_31 --config @look.json)
MAX=$(( (EST * 11 + 9) / 10 )) # ceil(estimate * 1.1)
```
`at most` on stderr means `is_ceiling` is true. `--json` gives `credits`, `is_ceiling`, `breakdown`, and `note` and `seconds` when present.
### generate [#generate-1]
```bash title="Terminal"
riffads generate --capability --config --max-credits
```
| Flag | Short | Required | Default | What it does |
| --------------------------------------------- | ----- | -------------- | -------------- | -------------------------------------------------- |
| `--capability ` | `-c` | yes | | Capability to run |
| `--config ` | | no | `{}` | Capability settings |
| `--set ` | | no, repeatable | | One field, coerced |
| `--set-string ` | | no, repeatable | | One field, kept as a string |
| `--max-credits ` | `-m` | **yes** | none | Most this job may cost, all variants |
| `--variants ` | | no | server default | Several takes of one config, up to 4, each charged |
| `--actor ` | | no | | Library actor |
| `--actor-image-asset ` | | no | | Uploaded image as the face |
| `--voice ` | | no | | Voice |
| `--approved-voice-generation ` | | no | | Finished `tts` generation for a talking actor |
| `--no-wait` | | no | off | Print the id and exit 0 |
| `--timeout ` | | no | 1800 | Stop waiting after this |
| `--output ` | `-o` | no | | Save outputs here |
The bare estimate is refused with `max_credits_exceeded`, exit 4. `--max-credits` has no default: leaving it out exits 2.
What it does:
1. Submits. The id prints to stdout first, so a broken wait still leaves you the id.
2. Waits on the server's wait endpoint. Retryable refusals during the wait are slept off, up to 5 in a row.
3. Saves outputs with `-o` as `-`. Absolute paths go to stdout, one per line.
```text title="stderr (sample)"
Veo 3.1: 484 credits held for 1 output
gen_8c31fa: completed, 1 of 1 delivered
held 484 credits, charged 462 credits (captured)
[0] video https://files.riffads.com/org/gen_8c31fa-0.mp4 (signed link hidden: use --json or riffads download)
```
* Wait timeout: exit 5. Nothing is canceled.
* Ctrl+C stops the wait, not the job. Exit 130.
* `generate -o` has no `--force`. An existing file warns and exits 2 even if the job worked. Use `riffads download -o ./out --force`.
* One job at a time per key. A second gets `submission_in_flight`, exit 5.
* `run_id` null prints `Queued, starting shortly.` Not a failure.
#### Talking actor: two generations [#talking-actor-two-generations]
Voice first. Its id drives the video.
```bash title="Terminal"
SCRIPT='Three reasons these sneakers sold out twice.'
VOICE=$(riffads generate -c tts \
--config "{\"script\":\"$SCRIPT\"}" \
-m "$VOICE_MAX" --voice voc_ada_en --no-wait)
riffads status "$VOICE" --wait
riffads generate -c actor_ultra \
--config "{\"script\":\"$SCRIPT\"}" \
-m "$ACTOR_MAX" \
--actor act_7f2a91 --voice voc_ada_en \
--approved-voice-generation "$VOICE" \
--output ./out
```
* `tts` needs `--voice`.
* `actor_ultra` has no `aspect_ratio`. Output is 720p, start frame shape, up to 60 seconds of audio.
More: [talking actor](/guides/talking-actor).
### Passing a config [#passing-a-config]
`estimate` and `generate` parse config the same way.
```bash title="Terminal"
--config '{"script":"..."}' # inline
--config @brief.json # a file
--config - # stdin
```
* Must be a JSON object. Omitted: `{}`.
* `--set` runs after `--config`. Dotted paths nest: `--set voice.speed=1.2` gives `{"voice":{"speed":1.2}}`.
* `--set` coerces `true`, `false`, `null` and numbers. `--set-string` never coerces and wins on the same path.
```bash title="Terminal"
jq -n --arg s "$SCRIPT_TEXT" '{script:$s}' \
| riffads generate -c tts --voice voc_ada_en --config - -m "$MAX"
```
## Follow and download [#follow-and-download]
### status [#status]
Alias `get`.
```bash title="Terminal"
riffads status [--wait] [--timeout ]
```
| Flag | Short | Default | What it does |
| --------------------- | ----- | ------- | ------------------------ |
| `--wait` | `-w` | off | Block until the job ends |
| `--timeout ` | | 1800 | Stop waiting after this |
* stdout: `queued`, `rendering`, `post_processing`, `completed` or `failed`. A canceled job reads `failed`.
* An unsettled charge prints `not settled yet`, never `0`.
### batch [#batch]
```bash title="Terminal"
riffads batch [--wait] [--timeout ]
```
Same flags as `status`. stdout: `running`, `completed`, `failed` or `partial`. Exit code is the worst variant's.
### download [#download]
```bash title="Terminal"
riffads download [--output ] [--index ] [--force]
```
| Flag | Short | Default | What it does |
| ---------------- | ----- | ------- | --------------------------------- |
| `--output ` | `-o` | `.` | Target folder, created if missing |
| `--index ` | | all | Only this output |
| `--force` | `-f` | off | Overwrite existing files |
```bash title="Terminal"
for f in $(riffads download gen_8c31fa -o ./out); do ffprobe "$f"; done
```
* Takes an id, never a URL. It fetches fresh signed links itself (links last 600 seconds).
* Absolute paths on stdout, one per line.
* Writes `.part`, renames when complete. A file is complete or absent.
* Up to 3 attempts per call.
* Existing file without `--force`: warning, exit 2. Nothing delivered yet: exit 7.
### upload [#upload]
```bash title="Terminal"
riffads upload [--content-type ]
```
Uploads and finalizes. Only the `ast_` id goes to stdout.
| Extensions | Type |
| ---------------------------------------- | ----- |
| `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif` | image |
| `.mp4`, `.mov`, `.webm` | video |
| `.mp3`, `.wav`, `.ogg` | audio |
Other extensions are refused. `--content-type` overrides the guess. Size limits: [uploads](/api/uploads).
```bash title="Terminal"
riffads generate -c actor_ultra \
--actor-image-asset "$(riffads upload face.png)" \
--voice voc_ada_en --approved-voice-generation "$VOICE" \
--config @brief.json -m "$MAX"
```
Exit 0 only when the file finalizes as `usable: true`. Otherwise 2.
## Workflows [#workflows]
```bash title="Terminal"
riffads workflows [...]
```
| Flag | Short | Default | What it does |
| ---------------------------- | ----- | ------------------------ | --------------------------------------------------- |
| `--template ` | | none | With `invoke`: run a template, not a saved workflow |
| `--max-credits ` | `-m` | none, required to invoke | Most the **whole run** may cost |
| `--input ` | `-i` | none | One input on one node, repeatable |
| `--wait` | `-w` | off | Follow until done |
| `--timeout ` | | 3600 | Stop following after this |
### workflows templates [#workflows-templates]
Lists templates: key, step count, name on stdout. Each input as the `-i` flag to pass on stderr. No subcommand runs this one.
```text title="stdout"
product_demo 4 steps Product demo
```
There is no list of saved workflows. Copy the workflow id from the app.
### workflows invoke [#workflows-invoke]
```bash title="Terminal"
riffads workflows invoke -m [-i node.field=value] [--wait]
riffads workflows invoke --template -m [-i node.field=value] [--wait]
```
```bash title="Terminal"
riffads workflows invoke --template product_demo \
--max-credits 900 \
-i script.text='new sneakers, upbeat' \
--wait --timeout 5400
```
* Exactly one of workflow id or `--template`.
* `--input` splits on the first `=` and the last `.` before it. Values coerce `true`, `false` and numbers. Everything else is a string.
* stdout: the run id (`wfr_...`).
### workflows run [#workflows-run]
```bash title="Terminal"
riffads workflows run [--wait] [--timeout ]
```
Reads a run once, or with `--wait` checks every 30 seconds. stdout: the run status. stderr: one line per node.
| Run state | Exit |
| ----------------------------------- | ---- |
| Still running at timeout | 5 |
| Partial | 7 |
| Failed or canceled, nothing charged | 6 |
| Failed after some steps charged | 7 |
| Otherwise | 0 |
More: [workflows](/guides/workflows).
## Output streams [#output-streams]
| Stream | Carries |
| ------ | --------------------------------------------------------------- |
| stdout | Data: ids, file paths, asset ids, status words, `--json` bodies |
| stderr | People: spinner, costs, hints, warnings, errors |
```bash title="Terminal"
GEN=$(riffads generate -c tts --config '{"script":"hello there"}' --voice voc_ada_en -m 40 --no-wait)
ASSET=$(riffads upload face.png)
STATE=$(riffads status "$GEN")
```
Signed links never print to stdout unless you pass `--json`.
## JSON output [#json-output]
`--json` prints the response body on stdout. Failures print too:
```json title="stdout under --json"
{
"error": {
"ok": false,
"code": "max_credits_exceeded",
"message": "This generation costs more than the max_credits you set (400 credits). This one needs 462 credits. Raise max_credits, or ask for something cheaper, such as a shorter or lower resolution render.",
"retryable": false,
"credits_charged": 0,
"limit": { "bound_by": "max_credits", "limit_credits": 400, "required_credits": 462 }
}
}
```
The id line prints before the JSON. Split submit from read instead:
```bash
GEN=$(riffads generate -c tts --config @brief.json --voice voc_ada_en -m 60 --no-wait)
riffads status "$GEN" --wait --json | jq
```
| Call | JSON body | Has |
| --------------------------------------- | ----------------------------------- | ------------------- |
| `generate --no-wait --json` | Submit body | `generation_ids` |
| `generate --json`, one output, finished | Wait body plus settled `generation` | no `generation_ids` |
| `generate --json` with `--variants` | Batch body | `generations` |
| `status --json` | Generation body | |
| `batch --json` | Batch body | `generations` |
`login`, `logout` and `download` print no JSON on success.
`credits_charged` is `null` until the job settles. Never read `null` as `0`.
## Exit codes [#exit-codes]
| Exit | Meaning | Error codes |
| ---- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 0 | Worked | |
| 1 | Something broke. Tell a human | `internal_error` |
| 2 | Change the request | `invalid_config`, `capability_unavailable`, `not_found`, `moderation_blocked`, `request_blocked` |
| 3 | Fix key, scope, plan or workspace | `not_authorized`, `no_workspace`, `connection_not_configured`, `workspace_unavailable`, `required_plan`, `read_only_connection`, `insufficient_scope` |
| 4 | Not enough credits, or a spend cap | `insufficient_credits`, `max_credits_exceeded`, `spend_limit_exceeded`, `not_priced`, `quota_exceeded` |
| 5 | Refused, safe to send again | `input_not_ready`, `estimate_changed`, `pricing_unavailable`, `moderation_unavailable`, `rate_limited`, `submission_in_flight`, `provider_unavailable` |
| 6 | Job failed, nothing charged | |
| 7 | Job delivered only part of what was asked | |
| 130 | Ctrl+C. The job was not canceled | |
* 6 and 7 are job outcomes. A refused submit never exits 6 or 7, and prints no id.
* `internal_error` is `retryable: true` on the wire but exits 1, so CI does not retry an outage.
* Batches exit with the worst variant. Order, least to worst: `0 < 6 < 7 < 5 < 2 < 4 < 3 < 1`.
Meaning of each code: [errors](/reference/errors).
## Retries [#retries]
* Inside a wait, the CLI retries for you: up to 5 in a row, honouring `retry_after_seconds`, else 1, 2, 4, 8, 16 seconds.
* A submit is never retried. `rate_limited`, `submission_in_flight` and `provider_unavailable` on submit exit 5 at once.
* One job in flight per key. Use one key per parallel lane.
* Resending after the first job finished starts a second paid job.
* Timeouts cancel nothing. No render time is promised: budget generously, or `--no-wait` and follow later.
## CI script [#ci-script]
```bash title="ci-generate.sh"
#!/usr/bin/env bash
set -uo pipefail
export RIFFADS_API_KEY="${RIFFADS_API_KEY:?set the key}"
export RIFFADS_BASE_URL=https://app.riffads.com
# 1. Check key and host. Exits 3 if wrong.
riffads whoami || exit $?
# 2. Start from a valid example.
riffads capabilities show tts --example > brief.json
jq --arg s "Three reasons these sneakers sold out twice." \
'.script = $s' brief.json > brief.tmp && mv brief.tmp brief.json
VOICE=$(riffads voices --language en --json | jq -r '.voices[0].voice_id') || exit $?
# 3. Estimate. Free.
EST=$(riffads estimate --capability tts --config @brief.json) || exit $?
# 4. max_credits: ceil(estimate * 1.1).
MAX=$(( (EST * 11 + 9) / 10 ))
# 5. Submit and save. stdout: id on line 1, then one path per line.
riffads generate \
--capability tts \
--config @brief.json \
--voice "$VOICE" \
--max-credits "$MAX" \
--output ./out > gen.out
CODE=$?
GEN=$(head -n 1 gen.out)
case "$CODE" in
0) echo "delivered: $GEN" ;;
2) echo "fix the request or the content"; exit 2 ;;
3) echo "fix the key, its scope, the plan or the workspace"; exit 3 ;;
4) echo "top up in the app, or ask for less"; exit 4 ;;
5) echo "retryable: sleep and send the same call again"; exit 5 ;;
6) echo "the job failed and nothing was charged"; exit 6 ;;
7) echo "only part of it arrived"; riffads status "$GEN"; exit 7 ;;
*) echo "broken, tell a human"; exit 1 ;;
esac
```
* **No `set -e`.** It would exit before the `case` runs.
* **Do not capture `generate -o` in a variable.** stdout has the id, then paths. Redirect and take line 1.
* **Derive `MAX` every run.** Never hardcode it.
## Not in the CLI [#not-in-the-cli]
* No cancel, logs or publish command.
* No list of past generations or saved workflows. Keep your ids.
* No outbound webhooks.
* No workflow authoring.
* Nothing posts to Meta, TikTok, YouTube or X.
# CLI setup (/cli/setup)
Install `riffads`, point it at the API host, give it a key. Three minutes.
## You need [#you-need]
* Node 20.10.0 or newer.
* An API key (`sk_live_...`). Owners and admins create keys at [app.riffads.com/api-keys](https://app.riffads.com/api-keys). Tick **Generate** if the key should start jobs.
* A server, build box or your own machine. Never a browser: the key is a secret.
## Install [#install]
```bash title="Terminal"
npm install -g riffads
# or run it without installing
npx riffads@latest --version
```
## Set the host [#set-the-host]
```bash title="Terminal"
export RIFFADS_BASE_URL="https://app.riffads.com"
```
The CLI defaults to `https://riffads.com`, which does not serve the API. Set `RIFFADS_BASE_URL`, pass `--base-url https://app.riffads.com`, or let `riffads login --base-url ...` save it. Wrong host: `... did not answer as a RiffAds API endpoint. Check --base-url.`
## Sign in [#sign-in]
```bash title="Terminal"
riffads login --base-url https://app.riffads.com
```
It asks for the key in a hidden prompt, checks it, then saves it. Or pipe the key in:
```bash title="Terminal"
pass show riffads/ci | riffads login --base-url https://app.riffads.com
```
`login` takes the key from, in order:
1. `--api-key ` (lands in shell history, avoid it)
2. `RIFFADS_API_KEY`
3. stdin, when it is not a terminal
4. A hidden prompt, only on a terminal (so CI never hangs)
A refused key exits 3 and writes nothing. A read-only key saves fine, then `generate` fails with `insufficient_scope`.
```json title="~/.riffads/config.json"
{
"api_key": "sk_live_xxxxxxxxxxxx",
"base_url": "https://app.riffads.com"
}
```
Directory mode 0700, file mode 0600.
## Check what it will use [#check-what-it-will-use]
```bash title="Terminal"
riffads whoami
```
```text title="stdout"
host https://app.riffads.com (from file)
key ****a91f (from file)
credits 4,200 credits
```
Every command picks the key and host from, in order:
| Order | Source | Set with |
| ----- | ----------- | ------------------------------------- |
| 1 | Flag | `--api-key `, `--base-url ` |
| 2 | Environment | `RIFFADS_API_KEY`, `RIFFADS_BASE_URL` |
| 3 | Saved login | `~/.riffads/config.json` |
If `RIFFADS_API_KEY` is set, it wins over the key you just saved. Wrong workspace? `whoami` shows `(from env)`. Unset the variable.
Empty or whitespace values count as not set.
## Environment variables [#environment-variables]
| Variable | Effect | Default |
| --------------------- | ------------------------------------------------------------------- | --------------------- |
| `RIFFADS_API_KEY` | The key. Beats the saved login. | none |
| `RIFFADS_BASE_URL` | The host. Set it to `https://app.riffads.com`. | `https://riffads.com` |
| `RIFFADS_CONFIG_HOME` | Folder for `config.json`. | `~/.riffads` |
| `NO_COLOR` | Non-empty: no colour, no spinner. | unset |
| `FORCE_COLOR` | Anything but `0`: colour and spinner on. Beats `NO_COLOR` and `CI`. | unset |
| `CI` | Anything but empty, `0` or `false`: no spinner. | unset |
## CI [#ci]
Skip `login`. Set two variables from your secret store and check them first.
```bash title="CI"
export RIFFADS_API_KEY="$RIFFADS_KEY"
export RIFFADS_BASE_URL="https://app.riffads.com"
riffads whoami # free, exits 3 if the key or workspace is wrong
```
Exit codes, JSON output and a full CI script: [commands](/cli/commands).
## Sign out and rotate [#sign-out-and-rotate]
```bash title="Terminal"
riffads logout
```
* Deletes the local file only. The key still works. Always exits 0.
* Revoke keys at [app.riffads.com/api-keys](https://app.riffads.com/api-keys). Work already running finishes.
* Rotate: create a new key, `riffads login`, `riffads whoami`, revoke the old key.
## When sign-in fails [#when-sign-in-fails]
Errors print on stderr as `error: ()`.
| Message | Meaning | Exit |
| ----------------------------------------------------------------------------------- | -------------------------------------------- | ---- |
| `No RiffAds API key.` | No flag, env var or saved login. | 3 |
| `This RiffAds API key is not valid.` | Malformed or unknown key. | 3 |
| `This RiffAds API key has been revoked.` | Create a new key. | 3 |
| `This RiffAds API key was not given the Generate scope, which this endpoint needs.` | Read-only key. Create one with **Generate**. | 3 |
| `The workspace this RiffAds API key belongs to is no longer available.` | Workspace is gone. | 3 |
| `This RiffAds API key is sending requests too quickly.` | Rate limited. Wait and retry. | 5 |
| `... did not answer as a RiffAds API endpoint. Check --base-url.` | Wrong host. | 2 |
| `"..." is not a valid base URL.` | Host does not parse. | 2 |
| `"..." is not an http or https URL.` | Wrong scheme. | 2 |
A workspace without a plan that includes the API gets `required_plan`, exit 3. Every code: [errors](/reference/errors).
# Getting results (/guides/results)
A submit answers when the job is accepted, not done. You wait on it, then fetch a short-lived link.
## Wait for it [#wait-for-it]
`GET /generations/{id}/wait` blocks up to 20 seconds. It answers the moment the job ends. Still going? It returns `still_running: true`. Call again right away.
```ts
const BASE = "https://app.riffads.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.RIFFADS_API_KEY}` };
async function waitFor(generationId: string, maxAgeSeconds = 1800) {
for (;;) {
const res = await fetch(`${BASE}/generations/${generationId}/wait`, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
if (!body.still_running) return body.generation;
if (body.age_seconds > maxAgeSeconds) {
throw new Error(`${generationId} still running after ${body.age_seconds}s`);
}
}
}
```
```text
wait_for_generation { generation_id: "gen_..." } // repeat until still_running is false
get_generation { generation_id: "gen_..." } // one read, now
get_batch { batch_group_id: "bg_..." } // every variant of one submit
```
Each file also comes back as a `resource_link` block.
```bash title="Terminal"
riffads status gen_... --wait # block until done
riffads download gen_... -o ./out # save every output
```
* **Branch on `still_running`, not `status`.** It can end before `status` looks final.
* A timeout is a normal `200`, never `408` or `504`.
* Log `age_seconds`. It tells stuck from slow.
## Statuses [#statuses]
| Status | Means | Done? |
| ----------------- | ------------------------------------------------------ | ----- |
| `queued` | Accepted, not started | No |
| `rendering` | Working | No |
| `post_processing` | Saving the output | No |
| `completed` | Every output delivered | Yes |
| `failed` | Did not deliver. Read `error.code` and `error.message` | Yes |
There is no `canceled` status. A canceled job reads `failed`. More: [statuses and ids](/reference/statuses-and-ids).
## Variants [#variants]
Sent `variants: 3`? The submit returns a `batch_group_id` (`bg_`). Watching one id is not enough. Wait on one sibling, then read `GET /batches/{id}`. Batch status is `running`, `completed`, `failed` or `partial`.
## Signed links [#signed-links]
Every output sits in `generation.outputs[]`:
| Field | Notes |
| ----------------- | --------------------------------------------------------------- |
| `index` | Stable position. Name files after it |
| `kind` | `image`, `video` or `audio` |
| `url` | Signed link, or `null` until the job is `completed` and settled |
| `width`, `height` | Pixels, or `null` |
* Links live **600 seconds** (`output_urls_expire_in_seconds`).
* There is no refresh endpoint. Every read signs new links. Expired? Read the generation again.
* A link is a credential. Anyone holding it gets the file. Don't store it, log it or queue it.
* Compare `outputs_delivered` to `outputs_expected`, not the array length.
* Text capabilities (AI Writer, Transcribe) return no file.
## Download [#download]
```bash title="Terminal"
BASE=https://app.riffads.com/api/v1
GEN=gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033
curl -s "$BASE/generations/$GEN" -H "Authorization: Bearer $RIFFADS_API_KEY" \
| jq -r '.generation.outputs[] | select(.url != null) | [.index, .url] | @tsv' \
| while IFS=$'\t' read -r index url; do
curl -sS --fail -o "$GEN-$index" "$url"
done
```
Don't retry the same URL. Read the generation again for a fresh link, then fetch.
Fetch each link right after the read that signed it. Name saved files from `generation_id`, `index` and `kind`, not `file_name`. The download has no `Content-Disposition`, so browsers play it instead of saving it.
## Webhooks [#webhooks]
There are none. No `webhook_url`, no callback, no stream. Sending a callback field is a `400`. Use the wait loop.
Keep your `generation_id`. There is no list endpoint, so it is your only way back to the file.
# Talking actor ads (/guides/talking-actor)
A talking actor ad is two generations, in order:
1. `tts` turns your script into audio.
2. `actor_ultra` animates the actor to that audio. It needs the finished voice's `generation_id`.
Have your own recording? Skip the voice. [Jump down](#use-your-own-audio).
## Pick an actor and a voice [#pick-an-actor-and-a-voice]
List them with `GET /actors` and `GET /voices` (MCP: `list_actors`, `list_voices`). Actor ids start `act_`, voice ids `voc_`. If the actor has a default voice, you can leave out `voice_id`.
## Make the voice, then the video [#make-the-voice-then-the-video]
```bash title="Terminal"
BASE=https://app.riffads.com/api/v1
AUTH="Authorization: Bearer $RIFFADS_API_KEY"
SCRIPT="Two weeks of battery, in a case this small."
# 1. The voice
VOICE=$(curl -s -X POST "$BASE/generations" -H "$AUTH" -H "Content-Type: application/json" -d "{
\"capability_id\": \"tts\",
\"config\": { \"script\": \"$SCRIPT\" },
\"actor_id\": \"act_0193c8f0a1b24e7f9d3c5a6b7e8f0011\",
\"voice_id\": \"voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022\",
\"max_credits\": 10
}" | jq -r .generation_id)
# 2. Call until still_running is false and status is completed
curl -s "$BASE/generations/$VOICE/wait" -H "$AUTH"
# 3. The video, driven by that voice
curl -s -X POST "$BASE/generations" -H "$AUTH" -H "Content-Type: application/json" -d "{
\"capability_id\": \"actor_ultra\",
\"config\": { \"script\": \"$SCRIPT\" },
\"actor_id\": \"act_0193c8f0a1b24e7f9d3c5a6b7e8f0011\",
\"voice_id\": \"voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022\",
\"approved_voice_generation_id\": \"$VOICE\",
\"max_credits\": 700
}"
```
```text
submit_generation { capability_id: "tts", config: { script: "..." },
actor_id: "act_...", voice_id: "voc_...", max_credits: 10 }
wait_for_generation { generation_id: "gen_..." } // until still_running is false
generate_talking_actor { script: "...", actor_id: "act_...", voice_id: "voc_...",
approved_voice_generation_id: "gen_...", max_credits: 700 }
wait_for_generation { generation_id: "gen_..." }
```
Other `actor_ultra` settings go under `settings`.
```bash title="Terminal"
SCRIPT="Two weeks of battery, in a case this small."
VOICE=$(riffads generate -c tts --set-string script="$SCRIPT" \
--actor act_0193c8f0a1b24e7f9d3c5a6b7e8f0011 \
--voice voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022 -m 10 --no-wait)
riffads status "$VOICE" --wait
riffads generate -c actor_ultra --set-string script="$SCRIPT" \
--actor act_0193c8f0a1b24e7f9d3c5a6b7e8f0011 \
--voice voc_0193c8f0a1b24e7f9d3c5a6b7e8f0022 \
--approved-voice-generation "$VOICE" -m 700 --output ./out
```
`max_credits` values above are placeholders. Get the real number from the estimate endpoint (`estimate_generation`) for each step, then send at least `ceil(estimate * 1.1)`.
Same script, same `actor_id`, same `voice_id`. Build the script once and reuse the variable. Any change: `invalid_config`, `The approved voice no longer matches this generation.`
## What actor\_ultra takes [#what-actor_ultra-takes]
| Field | Where | Notes |
| ------------------------------------ | ------ | --------------------------------------------------------------------- |
| `actor_id` or `actor_image_asset_id` | body | One is required. An uploaded image works as the face |
| `voice_id` | body | Optional if the actor has a default voice. Config spells it `voiceId` |
| `approved_voice_generation_id` | body | The completed `tts` generation |
| `script` | config | Same words as the voice |
| `voice_audio` | config | Your own recording instead of a voice |
* **No `aspect_ratio`.** Refused. The video takes the actor image's shape.
* **No duration.** Audio sets the length. It must be under 60 seconds.
* **No `variants`.** Want 3 takes? Run the pair 3 times.
* The id is `actor_ultra`. `talking_actor` is refused.
## Use your own audio [#use-your-own-audio]
One generation. No `tts`, no script, no voice. [Upload](/api/uploads) the file first and wait for `usable: true`.
```json title="Request body"
{
"capability_id": "actor_ultra",
"config": { "voice_audio": ["ast_0193c8f0a1b24e7f9d3c5a6b7e8f0044"] },
"actor_id": "act_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"max_credits": 700
}
```
Send it with `submit_generation` on MCP, not `generate_talking_actor`. If both a recording and a script arrive, the recording wins.
## Common refusals [#common-refusals]
All are `invalid_config`, and nothing is charged.
| Message | Fix |
| ----------------------------------------------------------- | ------------------------------------- |
| `Generate and approve the voice before creating the video.` | Make the `tts` step first |
| `The approved voice no longer matches this generation.` | Send the same script, actor and voice |
| `Select a voice first.` | Send `voice_id` |
Video sent while the voice still runs: `submission_in_flight`. Wait, then resend.
Getting the file: [results](/guides/results).
# Running workflows (/guides/workflows)
A workflow is a saved graph of steps. Each step is an ordinary generation. One call runs them all, in order, and each step's output feeds the next.
* **Build workflows in the app, run them from code.** No API or MCP tool creates or edits a graph.
* A template is a ready-made workflow. Run it as is, or fill its inputs.
## 1. List templates [#1-list-templates]
```bash title="Terminal"
curl -s https://app.riffads.com/api/v1/workflows/templates \
-H "Authorization: Bearer $RIFFADS_API_KEY"
```
```text
list_templates {}
```
```bash title="Terminal"
riffads workflows templates
```
```json title="Response (one input shown)"
{
"ok": true,
"templates": [
{
"template_key": "ugc_face_cam",
"name": "Creator to camera",
"step_count": 7,
"inputs": [
{ "node": "f_brand", "label": "Brand context", "field": "text",
"field_label": "value", "kind": "text", "max_length": 8000, "filled": true }
]
}
]
}
```
Each `inputs` entry is a value you may set. `kind` is `text`, `setting`, `actor` or `voice`. Node ids can't be guessed, so read them from this list.
Template text inputs hold example copy about an invented product. Set them, or you get an ad for something you don't sell.
## 2. Start a run [#2-start-a-run]
```bash title="Terminal"
curl -s -X POST https://app.riffads.com/api/v1/workflows/templates/ugc_face_cam/invoke \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"max_credits": 1500,
"inputs": [
{ "node": "f_brand", "field": "text", "value": "Lumen Skin, direct to consumer skincare." }
]
}'
```
A saved workflow: `POST /workflows/{workflow_id}/invoke`, same body.
```text
run_workflow { template_key: "ugc_face_cam", max_credits: 1500,
inputs: [{ node: "f_brand", field: "text", value: "Lumen Skin, ..." }] }
```
Send `template_key` or `workflow_id`, never both.
```bash title="Terminal"
riffads workflows invoke --template ugc_face_cam -m 1500 \
-i f_brand.text='Lumen Skin, direct to consumer skincare.' --wait
```
The answer is `201` with a `workflow_run_id` (`wfr_`). Nothing is finished yet.
| Body field | Rule |
| ------------- | -------------------------------------------------------------------------- |
| `max_credits` | Required. Spend cap for the whole run, every step |
| `inputs` | Optional. Up to 60 `{ node, field, value }` |
| `value` | String, number or boolean. `actor` takes an `act_` id, `voice` a `voc_` id |
* Inputs change this run only. The saved workflow stays as it is.
* One bad input refuses the whole call. Nothing starts, nothing is charged.
* You can't pass files through `inputs`, and you can't set `count` or a wired field.
* The key needs the **Workflows** scope, or you get `403 insufficient_scope`.
* The first run of a template creates a copy in your workspace named with `(agent)`. Later runs reuse it.
Starting it again while it runs: `409 submission_in_flight`. A blind retry after the first run ends starts, and charges, a second run.
## 3. Watch the run [#3-watch-the-run]
There is no wait endpoint for runs. Read `GET /workflow-runs/{id}` (MCP: `get_workflow_run`, CLI: `riffads workflows run --wait`) about every 30 seconds until `run.still_running` is `false`.
| Run status | Means |
| ------------------- | ------------------------------------------------ |
| `queued`, `running` | Still going |
| `completed` | Every step finished |
| `partial` | Some steps finished, some failed or were skipped |
| `failed` | Nothing finished |
| `canceled` | Stopped before any step finished |
* Files are at `run.nodes[].generations[].outputs[].url`. Same 600 second signed links as a single generation. [Getting results](/guides/results).
* A failed step has `error.code`: `provider_error`, `moderation`, `timeout`, `canceled`, `insufficient_credits`, `invalid_input` or `unknown`.
* A `skipped` step never ran because an input failed.
## Stop or retry [#stop-or-retry]
Only in the app. There is no cancel or retry over REST, MCP or CLI. On `partial` or `failed`, read each step's error, fix the input, and start a new run.
A talking actor step makes its own voice first. No need to run [the two steps](/guides/talking-actor) yourself.
# Connect an MCP client (/mcp/connect)
Add one URL to your client. Sign in once in a browser. Your agent gets up to 17 RiffAds tools.
```text title="Server URL"
https://mcp.riffads.com
```
* **No API key.** MCP is OAuth 2.1 only. Keys (`sk_live_...`) are for the [REST API](/api/authentication) and the CLI.
* **The workspace needs a paid plan.** Without one, tools refuse with `required_plan`.
* Want a first video right away? Go to the [MCP quickstart](/quickstart/mcp).
## Add the server [#add-the-server]
Open this link to get the add dialog prefilled:
```text title="Prefilled connector dialog"
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=RiffAds&connectorUrl=https%3A%2F%2Fmcp.riffads.com
```
By hand: **Settings**, **Connectors**, **Add custom connector**. Paste `https://mcp.riffads.com`. Save.
```bash title="Terminal"
claude mcp add --transport http riffads https://mcp.riffads.com
```
Or commit it for the whole repo:
```json title=".mcp.json"
{
"mcpServers": {
"riffads": {
"type": "http",
"url": "https://mcp.riffads.com"
}
}
}
```
Each person signs in as themselves on the first tool call. Want the procedure too? Install the [Agent Skills plugin](/skills/overview).
```text title="Cursor install link"
https://cursor.com/en/install-mcp?name=riffads&config=eyJ0eXBlIjoiaHR0cCIsInVybCI6Imh0dHBzOi8vbWNwLnJpZmZhZHMuY29tIn0%3D
```
By hand: add an HTTP MCP server named `riffads` with this config:
```json title="Cursor MCP config"
{ "type": "http", "url": "https://mcp.riffads.com" }
```
Copilot agent mode.
```text title="VS Code install link"
vscode:mcp/install?%7B%22name%22%3A%22riffads%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.riffads.com%22%7D
```
By hand:
```json title="VS Code MCP config"
{ "name": "riffads", "type": "http", "url": "https://mcp.riffads.com" }
```
No install link. Open connector settings, add a custom connector, paste `https://mcp.riffads.com`.
```text title="ChatGPT connector settings"
https://chatgpt.com/#settings/Connectors
```
```json title="Generic MCP config"
{
"mcpServers": {
"riffads": {
"type": "http",
"url": "https://mcp.riffads.com"
}
}
}
```
| Setting | Value |
| ---------------------- | -------------------------------------------------- |
| Transport | Streamable HTTP, stateless |
| Method | `POST` with a JSON-RPC 2.0 body |
| `Content-Type` | `application/json` |
| `Accept` | `application/json, text/event-stream` |
| Auth | `Authorization: Bearer ` (OAuth 2.1) |
| Wall clock per request | 60 seconds |
* Old HTTP+SSE-only clients cannot connect.
* A reply can be a one-frame event stream. Accept both types.
* Authenticated `GET` answers `405`. There is no session.
`https://mcp.riffads.com`. No path, no trailing slash. A wrong URL means no sign-in window.
## Sign in [#sign-in]
1. The first tool call opens a browser on `app.riffads.com`.
2. Sign in with Google, or email and password.
3. Approve. You only see a consent screen if the client sends `prompt=consent` (Claude does not). There you pick the workspace and **spend** or **read only**.
## Check the connection [#check-the-connection]
Call `riffads_ping`. No arguments. Spends nothing.
```json title="riffads_ping result"
{
"ok": true,
"user_id": "...",
"organization_id": "org_...",
"workspace": "Acme",
"plan": "launch",
"mode": "spend",
"can_spend_credits": true,
"server_time": "2026-09-17T10:28:41.002Z"
}
```
* `workspace`: the workspace this connection uses.
* `can_spend_credits: false`: read only. The 5 spend tools are missing.
## Which workspace it uses [#which-workspace-it-uses]
An MCP token is a **person**, not a workspace. The connection holds the workspace.
| Your account | What happens |
| -------------------------------- | ------------------------------------------------------------------- |
| One workspace | Binds on first use. |
| More than one, no consent screen | Nothing binds. Every call refuses with `connection_not_configured`. |
| Client showed a consent screen | You picked it there. |
| No workspace | Every call refuses with `no_workspace`. |
```json title="connection_not_configured"
{
"ok": false,
"code": "connection_not_configured",
"message": "This RiffAds account has more than one workspace, so this connection does not know which one to spend from. Choose one at https://app.riffads.com/connections, then try again.",
"retryable": false,
"credits_charged": 0,
"connect_url": "https://app.riffads.com/connections"
}
```
Retrying changes nothing. Pick the workspace at [app.riffads.com/connections](https://app.riffads.com/connections), then call again.
## Read or spend [#read-or-spend]
| Mode | Tools | Can do |
| ------- | ----- | ------------------------------------------------------------------------------------------------------- |
| `read` | 12 | Browse, estimate, read generations and runs. |
| `spend` | 17 | Plus `submit_generation`, `generate_talking_actor`, `run_workflow`, `create_upload`, `finalize_upload`. |
* New connections can spend, unless you chose read only.
* Uploads count as spend tools.
* Change the mode at [app.riffads.com/connections](https://app.riffads.com/connections). Anyone can narrow their own. Only owners and admins can widen.
* After a change, refresh the client's tool list. A stale list calling a spend tool gets `read_only_connection`.
## OAuth discovery (custom clients) [#oauth-discovery-custom-clients]
Most clients do this for you. Building your own:
1. Call with no token. You get `401`:
```text title="WWW-Authenticate"
Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="https://mcp.riffads.com/.well-known/oauth-protected-resource"
```
2. Fetch `resource_metadata`:
```json title="GET https://mcp.riffads.com/.well-known/oauth-protected-resource"
{
"resource": "https://mcp.riffads.com",
"authorization_servers": ["https://app.riffads.com"],
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"bearer_methods_supported": ["header"],
"resource_name": "RiffAds"
}
```
3. Read `authorization_servers[0]`. Do not hardcode it. Fetch its `/.well-known/oauth-authorization-server` for the authorize, token and registration endpoints.
4. Register (dynamic registration works), authorize in a browser, exchange the code.
5. Send `Authorization: Bearer ` on every call.
Scopes gate nothing on MCP. The connection mode decides what a token can call.
Per address, before any token: 10 registrations per 10 minutes, 120 requests a minute on the MCP endpoint. Over either: `429` with `Retry-After` and no JSON-RPC body.
Token problems answer `401` with a plain OAuth body:
| `error_description` | Fix |
| -------------------------------------------------------------- | ---------------------------- |
| `Missing Authorization header` | Send the token. |
| `Invalid Authorization header format, expected 'Bearer TOKEN'` | Use the `Bearer` scheme. |
| `Unknown, revoked or expired access token` | Authorize again. |
| `Token has expired` | Refresh, or authorize again. |
| `Access token has no usable expiry` | Authorize again. |
| `Access token is not bound to a client` | Authorize again. |
## Troubleshooting [#troubleshooting]
| Symptom | Cause | Fix |
| ---------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------ |
| No sign-in window | Wrong URL, or not a streamable HTTP client | URL exactly `https://mcp.riffads.com` |
| Every tool answers `connection_not_configured` | Several workspaces, none picked | Pick one at [app.riffads.com/connections](https://app.riffads.com/connections) |
| Only 12 tools | Read only connection | Allow spending, refresh the tool list |
| `not_authorized` | Token revoked or bad, or account blocked | Reconnect and sign in again |
| `workspace_unavailable` | Workspace deleted, or you left it | Reconnect, pick a workspace you are in |
| `no_workspace` | Account has no workspace | Finish setup at [app.riffads.com](https://app.riffads.com), reconnect |
| `required_plan` | Plan does not include the agent API | The message names the plan that does |
| `429` with `Retry-After` during setup | Per-address limit | Wait, register once |
* Disconnecting deletes the tokens. The agent must reconnect from scratch. Work already running finishes.
* Switching workspace in the browser does not move a connection.
* Tool refusals are JSON with `code`, `message`, `retryable`, `credits_charged`. Branch on `code`. See [errors](/reference/errors).
# MCP tools (/mcp/tools)
17 tools. A `spend` connection sees all 17. A `read` connection sees 12: the 5 spend tools are not registered. Setup: [connect a client](/mcp/connect).
| Tool | Access | Limit per person | What it does |
| ------------------------ | --------- | ---------------- | ------------------------------------------------------ |
| `riffads_ping` | Read | none | Workspace, plan, whether this connection may spend |
| `list_capabilities` | Read | 120/min | A page of what this workspace can generate |
| `get_capability_schema` | Read | 120/min | One capability's JSON Schema and an example config |
| `estimate_generation` | Read | 60/min | The number for `max_credits`. Reserves nothing |
| `list_actors` | Read | 120/min | A page of actors |
| `list_voices` | Read | 120/min | A page of voices |
| `get_credit_balance` | Read | 120/min | Available credits and the agent spend ceiling in force |
| `submit_generation` | **Spend** | 20/min | Starts one generation |
| `generate_talking_actor` | **Spend** | 20/min | `submit_generation` for `actor_ultra` |
| `wait_for_generation` | Read | 120/min | Blocks up to 20 seconds, answers when done |
| `get_generation` | Read | 120/min | Reads one generation now |
| `get_batch` | Read | 120/min | Reads every variant of one submit |
| `create_upload` | **Spend** | 30/min | Reserves an `ast_` id and a signed PUT URL |
| `finalize_upload` | **Spend** | 30/min | Confirms the bytes landed, answers `usable` |
| `list_templates` | Read | 120/min | Runnable workflow templates and their inputs |
| `run_workflow` | **Spend** | 20/min | Runs a whole workflow |
| `get_workflow_run` | Read | 120/min | Reads a run node by node |
Limits count per person, not per connection. Over a limit: `rate_limited` with `retry_after_seconds`.
## Results and refusals [#results-and-refusals]
* **Success:** one text block of JSON with `ok: true` plus the tool's fields. Files also come as `resource_link` blocks with signed URLs.
* **Refusal:** `isError` set. Body: `ok: false`, `code`, `message`, `retryable`, `credits_charged`.
* **Schemas are strict.** Unknown keys are refused. `maxCredits` instead of `max_credits` is a refusal.
* **`capability_id` is a free string**, not an enum. New capabilities work with an old tool list.
* **Branch on `code` and `retryable`**, never on `message`. Every code: [errors](/reference/errors).
```json title="A refusal"
{
"ok": false,
"code": "max_credits_exceeded",
"message": "This generation costs more than the max_credits you set (600 credits). This one needs 660 credits. Raise max_credits, or ask for something cheaper, such as a shorter or lower resolution render.",
"retryable": false,
"credits_charged": 0,
"limit": { "bound_by": "max_credits", "limit_credits": 600, "required_credits": 660 }
}
```
`limit.bound_by` names the cap that refused: `max_credits` (yours to raise) or a workspace cap (ask an owner or admin).
## Orient [#orient]
### riffads\_ping [#riffads_ping]
Read. No arguments. Spends nothing, no rate limit.
```json title="Result"
{
"ok": true,
"user_id": "...",
"organization_id": "org_...",
"workspace": "Northwind Studio",
"plan": "growth",
"mode": "spend",
"can_spend_credits": true,
"server_time": "2026-09-17T10:04:11.882Z"
}
```
`mode: "read"` means no spend tools until a person changes it.
## Discover [#discover]
### list\_capabilities [#list_capabilities]
Read. Paged.
| Argument | Type | Required | Notes |
| -------- | ----------- | -------- | ------------------------------------ |
| `limit` | integer > 0 | no | Capped server side |
| `cursor` | string | no | `next_cursor` from the previous page |
Returns `capabilities[]`, `total`, `next_cursor`.
```json title="Result, trimmed"
{
"ok": true,
"next_cursor": "gpt_image_2",
"capabilities": [
{
"capability_id": "actor_ultra",
"name": "Talking Actor",
"description": "OmniHuman 1.5: an actor speaks your script.",
"category": "avatar",
"output_kind": "video",
"status": "available"
}
]
}
```
* `status`: `available`, `requires_plan`, `coming_soon`, `retired`, `unknown`.
* `requires_plan` rows carry `required_plan`.
* Retired rows are left out of the list.
* Category order: avatar, video, image, preset, tool.
Every id: [capabilities](/capabilities).
### get\_capability\_schema [#get_capability_schema]
Read.
| Argument | Type | Required |
| --------------- | ------ | -------- |
| `capability_id` | string | **yes** |
Returns `{ capability: { capability_id, name, description, category, output_kind, config_schema, example_config } }`.
* `config_schema`: JSON Schema for `config`.
* `example_config`: the smallest config that validates.
* Refusals may add `capability_status` and `required_plan`.
A schema-valid config can still be refused (some settings depend on other fields). Run `estimate_generation` to check a config before you submit.
## Price [#price]
### estimate\_generation [#estimate_generation]
Read. Reserves nothing, charges nothing.
| Argument | Type | Required |
| --------------- | ------ | -------- |
| `capability_id` | string | **yes** |
| `config` | object | **yes** |
```json title="Result. The numbers are arbitrary"
{
"ok": true,
"capability_id": "veo_31",
"credits": 400,
"is_ceiling": false,
"seconds": 8,
"breakdown": [
{ "label": "Video, 1080p with audio", "unit": "second", "quantity": 8, "credits": 400 }
]
}
```
* `is_ceiling: true`: an upper bound, not the exact number.
* `seconds` and `note` show up only when they apply.
* Numbers change. Estimate every time, do not cache.
Send at least `ceil(estimate * 1.1)`. The bare estimate is refused with `max_credits_exceeded`.
## Library [#library]
### list\_actors [#list_actors]
Read. Paged.
| Argument | Type | Required | Notes |
| -------- | ----------- | -------- | ------------------------------ |
| `search` | string | no | Matches names and descriptions |
| `limit` | integer > 0 | no | Capped server side |
| `cursor` | string | no | From the previous page |
Row: `{ actor_id, name, description, gender, age_band, tags[], default_voice_id, default_voice_name, is_platform }`, plus `next_cursor`. `is_platform` is `true` for RiffAds actors, `false` for your workspace's own. No preview images.
### list\_voices [#list_voices]
Read. Paged.
| Argument | Type | Required | Notes |
| ---------- | ----------- | -------- | -------------------------------------------- |
| `search` | string | no | Matches voice names |
| `language` | string | no | Exact language tag, for example `en` or `es` |
| `limit` | integer > 0 | no | Capped server side |
| `cursor` | string | no | From the previous page |
Row: `{ voice_id, name, language, gender, tags[], is_cloned, is_premium, is_platform }`, plus `total` and `next_cursor`. No preview audio.
### get\_credit\_balance [#get_credit_balance]
Read. No arguments.
```json title="Result. The numbers are arbitrary"
{
"ok": true,
"credits_available": 4820,
"credits_on_hold": 660,
"agent_limits": {
"max_credits_per_generation": 600,
"daily_credits_limit": 2000,
"spent_last_24_hours": 740,
"daily_credits_remaining": 1260,
"most_one_generation_may_cost": 600,
"bound_by": "org_per_generation"
}
}
```
* `most_one_generation_may_cost`: the highest `max_credits` a submit can pass right now.
* `bound_by`: `max_credits`, `org_per_generation`, `api_key_budget` or `org_daily`. API key fields never appear on MCP.
## Spend [#spend]
Spend tools are marked `destructiveHint: true`, so some clients ask before calling them.
### submit\_generation [#submit_generation]
**Spend.** Answers when the job is **accepted**, not when it is ready.
```json title="Arguments"
{
"capability_id": "veo_31",
"config": { "prompt": "...", "duration": 8, "resolution": "1080p" },
"max_credits": 440
}
```
```json title="Result, trimmed"
{
"ok": true,
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"generation_ids": ["gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033"],
"run_id": "run_9f2c1b",
"replay": false,
"capability_id": "veo_31",
"capability_name": "Veo 3.1",
"output_kind": "video",
"outputs_expected": 1,
"credits": { "credits_held": 440, "credits_charged": null, "settlement": "open", "terminal": false },
"charge_summary": "440 credits are on hold. Nothing has been charged so far.",
"next_action": "Call wait_for_generation with this generation_id..."
}
```
* `generation_ids` is always set. `batch_group_id` (`bg_`) appears only when `variants` is above 1.
* `run_id: null` is not a failure. Follow `generation_id`.
* `replay: true`: the same request already ran. You get that generation back, no second charge.
* `variants` above the limit is refused, never clamped.
* `voice_id` (argument) is not `voiceId` (inside `config`).
* No output link on submit.
A second submit while one is running gets `submission_in_flight` (retryable). Wait for the first, then send.
### generate\_talking\_actor [#generate_talking_actor]
**Spend.** Same as `submit_generation` with `capability_id: "actor_ultra"`.
1. `submit_generation` with `capability_id: "tts"`. Wait until it completes.
2. `generate_talking_actor` with that `generation_id` as `approved_voice_generation_id`.
Audio made for a different actor, voice or script is refused.
```json title="Step 1: the voice (submit_generation)"
{
"capability_id": "tts",
"config": { "script": "Three weeks in and I am not going back." },
"actor_id": "act_4f1e...",
"voice_id": "voc_9a20...",
"max_credits": 40
}
```
```json title="Step 2: the video (generate_talking_actor)"
{
"script": "Three weeks in and I am not going back.",
"actor_id": "act_4f1e...",
"voice_id": "voc_9a20...",
"approved_voice_generation_id": "gen_1f88...",
"max_credits": 700
}
```
* The `max_credits` values are placeholders. Estimate each step.
* `script` wins over a `script` inside `settings`.
* `actor_ultra` has no `aspect_ratio`. Sending one is refused as an unknown key. Output matches the start frame's shape.
* Result shape matches `submit_generation`.
Full walkthrough: [talking actor](/guides/talking-actor).
## Follow [#follow]
### wait\_for\_generation [#wait_for_generation]
Read. The right way to follow a job.
| Argument | Type | Required |
| --------------- | ------ | -------- |
| `generation_id` | string | **yes** |
Blocks up to **20 seconds** and answers the moment the job ends. `still_running: true`: call it again right away.
```json title="Result, trimmed"
{
"ok": true,
"still_running": true,
"waited_seconds": 20,
"age_seconds": 74,
"retry_after_seconds": 0,
"generation": {
"generation_id": "gen_0193c8...",
"status": "rendering",
"outputs": []
},
"next_action": "Still working..."
}
```
* Branch on `still_running`, not `status`.
* `age_seconds` is the job's total age.
* Pass a `progressToken` to get `notifications/progress` over an event stream.
### get\_generation [#get_generation]
Read. One check, now. Do not loop it: use `wait_for_generation`.
| Argument | Type | Required |
| --------------- | ------ | -------- |
| `generation_id` | string | **yes** |
```json title="Result, trimmed"
{
"ok": true,
"output_urls_expire_in_seconds": 600,
"generation": {
"generation_id": "gen_0193c8...",
"capability_id": "veo_31",
"status": "completed",
"batch_group_id": null,
"batch_index": null,
"outputs": [
{
"index": 0,
"kind": "video",
"url": "https://...signed...",
"file_name": "veo-31-0.mp4",
"width": 1080,
"height": 1920
}
],
"outputs_expected": 1,
"outputs_delivered": 1,
"credits": { "credits_held": 440, "credits_charged": 400, "settlement": "captured", "terminal": true },
"charge_summary": "400 credits were charged.",
"error": null,
"created_at": "2026-09-17T10:04:11.882Z",
"completed_at": "2026-09-17T10:09:52.104Z"
},
"next_action": "Done. The output links are short lived..."
}
```
* Statuses: `queued`, `rendering`, `post_processing`, `completed`, `failed`. A canceled job reads `failed`.
* `credits.credits_charged` is `null` until `credits.terminal` is `true`. Null is unknown, not zero.
* Each file also comes as a `resource_link` with `mimeType` and a title like `video 1 of 1`.
Download the file or hand the link on right away. Expired? Call `get_generation` again for a fresh link. See [results](/guides/results).
### get\_batch [#get_batch]
Read. Every variant of one submit.
| Argument | Type | Required |
| ---------------- | ------ | -------- |
| `batch_group_id` | string | **yes** |
Returns `status` (`running`, `completed`, `failed`, `partial`), `variants`, `variants_finished`, `outputs_delivered`, one `credits` total, `charge_summary`, `generations[]`, and a `resource_link` per delivered variant.
`get_batch` does not wait. Call `wait_for_generation` on a running id, then `get_batch` again. Batch `credits_charged` stays `null` until every variant ends.
## Upload a file [#upload-a-file]
Three steps. The middle one is your own HTTP call, not a tool.
### create\_upload [#create_upload]
**Spend.** Reserves a slot. No bytes, no credits.
| Kind | Types | Max |
| ----- | -------------------------------------------------------------------------------- | ------ |
| Image | `image/jpeg`, `image/png`, `image/webp`, `image/gif` | 20 MB |
| Video | `video/mp4`, `video/webm`, `video/quicktime` | 100 MB |
| Audio | `audio/mpeg`, `audio/mp3`, `audio/wav`, `audio/x-wav`, `audio/webm`, `audio/ogg` | 25 MB |
Returns `asset_id`, `upload_url`, `upload_expires_at`, `upload_expires_in_seconds`, `kind`, `content_type`, `size_bytes`, `max_bytes_for_kind`, `content_checked`, `next_action`.
### PUT the bytes [#put-the-bytes]
One HTTP `PUT` to `upload_url`. Same content type, exact byte count, within **5 minutes**.
### finalize\_upload [#finalize_upload]
**Spend.** Call after the `PUT` returns.
| Argument | Type | Required |
| ---------- | ------ | -------- |
| `asset_id` | string | **yes** |
Returns `{ asset_id, scan_status, usable, kind, duration_ms, next_action }`.
* `scan_status`: `pending`, `clean`, `flagged`.
* Use the `ast_` id in a config only after `usable: true`.
* `usable: false` with `flagged` is final. Use another file.
* Calling twice returns the same answer.
* `duration_ms` is measured server side. Null for images.
Only images are content checked. `content_checked` tells you. See [content policy](/policy/content).
## Workflows [#workflows]
Run a workflow built in the app, or a template. Agents cannot build or edit workflows. See [workflows](/guides/workflows).
### list\_templates [#list_templates]
Read. No arguments.
Returns `templates[]`. Each: `{ template_key, name, description, category, step_count, inputs[] }`.
```json title="One input"
{
"node": "f_brand",
"label": "Brand context",
"field": "text",
"field_label": "value",
"kind": "text",
"max_length": 8000,
"filled": true
}
```
* `kind`: `text`, `setting`, `actor`, `voice`.
* `setting` inputs carry `accepts`. `text` inputs carry `max_length`.
* `filled: true`: the node already has a value.
* Node ids cannot be guessed. Read them here.
### run\_workflow [#run_workflow]
**Spend.** Every step is charged.
* Exactly one of `template_key` or `workflow_id`. Both or neither: `invalid_config`.
* `value` is a string, number or boolean. Field `actor` takes an `act_` id, field `voice` takes a `voc_` id.
* All or nothing: one bad input refuses the whole run. The message names the node and field.
* `count` cannot be set. Asset inputs cannot be filled.
```json title="Arguments"
{
"template_key": "ugc_face_cam",
"inputs": [
{ "node": "f_brand", "field": "text", "value": "A refillable cleaning spray sold direct." }
],
"max_credits": 1200
}
```
```json title="Result, trimmed"
{
"ok": true,
"workflow_run_id": "wfr_7c1e...",
"workflow_id": "wf_2ab9...",
"template_key": "ugc_face_cam",
"billable_node_count": 6,
"applied_inputs": [{ "node": "f_brand", "field": "text" }],
"replay": false,
"next_action": "This run has 6 step(s) to work through..."
}
```
`billable_node_count` counts the steps that will run and charge. No output link on `run_workflow`.
### get\_workflow\_run [#get_workflow_run]
Read. The only way to follow a run. There is no wait tool for runs.
| Argument | Type | Required |
| ----------------- | ------ | -------- |
| `workflow_run_id` | string | **yes** |
Answers now. Call about every 30 seconds until `still_running` is `false`.
Returns `workflow_run_id`, `workflow_id`, `status`, `still_running`, `nodes[]`, `credits`, `started_at`, `finished_at`, `next_action`, and `output_urls_expire_in_seconds` once a file is ready.
* Run status: `queued`, `running`, `completed`, `partial`, `failed`, `canceled`.
* Node: `{ node_id, label, status, credits, generations[], text, error }`. Writer nodes return `text`, no file.
* Every file of every node comes as a `resource_link`.
* `credits.credits_charged` is `null` until the run ends. `credits_charged_so_far` grows while it runs.
* `partial`: some steps finished, failed steps charged nothing.
## Not on MCP [#not-on-mcp]
* No cancel tool. A job canceled elsewhere reads `failed`.
* No outbound webhooks.
* No list of past generations. Keep your ids.
* No MCP resources or prompts.
* No workflow authoring.
* Nothing posts to Meta, TikTok, YouTube or X.
Id prefixes: [statuses and ids](/reference/statuses-and-ids).
# Content policy (/policy/content)
What RiffAds refuses to make, and what you must do with what it makes.
## One check on every path [#one-check-on-every-path]
REST, MCP, the CLI, the app and workflow runs all run the same check. Refused on one, refused on all. The check runs before anything is charged, so a refusal carries `credits_charged: 0`.
## What is checked [#what-is-checked]
| Input | When |
| --------------------------------------------------------------- | ---------------------------------------- |
| Script | On submit |
| Free text fields (prompt, brief, lyrics, headline, instruction) | On submit |
| Uploaded images | When the upload is finalized, before use |
| Import URLs | Before anything is fetched |
Text is matched against a fixed rule list. A model pass can sit on top for wording the list misses.
| Category | Result |
| ------------------------------- | ----------------- |
| Sexual content involving minors | Refused |
| Explicit sexual content | Refused |
| Credible violence and threats | Refused |
| Illicit goods | Refused |
| Hate | Refused |
| Self harm | Warning, job runs |
| Impersonation | Warning, job runs |
Uploads return `content_checked: false` for video and audio. Nothing reads their contents. Don't tell your users they were reviewed.
## What a refusal looks like [#what-a-refusal-looks-like]
```json title="Response: 422"
{
"error": {
"ok": false,
"code": "moderation_blocked",
"message": "This request was refused by our content policy. Change the wording or the input; sending it again unchanged will be refused again.",
"retryable": false,
"credits_charged": 0
}
}
```
* One sentence. No category, no internal id. There is nothing to reverse engineer.
* A refused file names its `ast_` id. That file can never be used. Upload a different one.
* An upload still being scanned returns `input_not_ready`. That one is retryable.
## Resending fails [#resending-fails]
The rule list gives the same answer to the same words, forever. Repeats get blocked:
| Refused by | Refusals | Then | For |
| ------------- | -------- | ------------------------ | ------------ |
| The rule list | 2 | Refused without checking | 30 days |
| A model | 3 | Paused | about 1 hour |
A blocked request returns `request_blocked` (429, not retryable, `blocked_reason: "moderation_blocked"`). Change the script or the inputs. Blocks never cross workspaces.
If checks are down, agent submits return `moderation_unavailable` (503, retryable). Reads still work.
Think a refusal is wrong? There is no appeal button. Change the wording. A different request gets a fresh check.
## Everything is AI made [#everything-is-ai-made]
Every file RiffAds returns is AI generated or AI edited. Video, images, voice, music, scripts. That stays true with your own script or your own uploads.
**The file carries no label.** No watermark, no visible mark, no C2PA data. Some models may add hidden marks. RiffAds does not add, check or promise them.
RiffAds never publishes anything. You choose where the file goes, so the duty to disclose goes with you. No API, MCP or CLI response tells your agent to label. Build it into your process.
Label where law, a regulator or a platform requires it:
* **EU AI Act.** Transparency rules apply from 2 August 2026. Realistic content that looks like real people, places or events must be disclosed.
* Laws on deepfakes, synthetic media, political ads, and use of a person's name, image or voice.
* Advertising and consumer law, including fake reviews, testimonials and endorsements.
* Ad policies of every platform you run on.
Use a label people notice: "AI generated" or "Made with AI", or the platform's own AI label. To burn one in, use the `text_overlay` capability. [Capabilities](/capabilities). Not legal advice.
## Actors are not real people [#actors-are-not-real-people]
* Library actors are AI. They never used, tested or recommended anything. Don't present one as a real customer, reviewer or expert.
* Making an actor from a real person's photo, or cloning a real voice? **Get their written consent first.** Never anyone under 18. Keep a copy.
* RiffAds does not verify who is in a photo or voice sample. Impersonation wording is a warning, not a block. The job runs. The duty is yours.
## Review before you publish [#review-before-you-publish]
Nobody at RiffAds watches output. Expect sometimes:
* Wrong, missing or mispronounced words.
* Lips out of sync. Odd faces, hands or text.
* False product details or claims, including in AI-written scripts.
* Accidental likeness to a real person or brand.
The check runs on inputs, not on the finished file. Watch, listen, read. Make sure every claim is true.
Full rules: [AI Disclosure](https://riffads.com/ai-disclosure) and [Acceptable Use Policy](https://riffads.com/acceptable-use). [Legal](/policy/legal).
# Data and security (/policy/data-and-security)
What RiffAds stores, how long it keeps it, and how to keep your keys safe.
## Where files live [#where-files-live]
* Outputs are copied into RiffAds storage (Cloudflare R2) when a job finishes. Only that copy is served.
* Provider links are never stored or returned.
* Every file sits under your workspace. The workspace comes from your key or connection, never from request input.
* Files are private. They are served only through short-lived signed links with `Cache-Control: private, no-store`. No public URLs.
* Finished outputs never expire.
No code path publishes to Meta, TikTok, YouTube or X. Files leave only when you or your agent fetch a signed link.
## Link lifetimes [#link-lifetimes]
| Link | Lives for |
| ------------------------------------ | ------------------------------------------------- |
| Output link on REST, MCP and the CLI | 10 minutes (`output_urls_expire_in_seconds: 600`) |
| Upload PUT link | 5 minutes, one use |
Agent links are short because they end up in model transcripts.
Read the generation again for a fresh link. A stale URL returns 403. Don't retry it.
* `url` is `null` until the generation is finished. Branch on status, not on the URL.
* If signing fails, `url` is `null`, not an error. Read again.
## What else is kept [#what-else-is-kept]
| Thing | Kept |
| ---------------------------- | ---------------------------------------------------------------------- |
| Generation records | Kept. Deleting a project does not delete its generations or outputs. |
| Content check record | A SHA-256 fingerprint of the input. Never the words. |
| Abandoned upload reservation | Cleared after 15 minutes. |
| Revoked API key | Disabled, never deleted, so history shows which key started which job. |
There is no self-service workspace deletion.
## API keys [#api-keys]
* **Shown once.** The database stores a hash. Nobody at RiffAds can recover it. Lost it? Revoke it and create another.
* **Live only.** No test keys, no sandbox. Every key can spend what its scopes allow.
* **Never expire.** Revoke to stop one.
* **Made in the app.** Owners and admins create and revoke keys at app.riffads.com/api-keys. No endpoint creates, lists or rotates keys, so an agent can't issue itself one.
* **Name a workspace, not a person.** Want per-person tracking? One key per person or system.
* **No IP allowlist.** The key is the whole credential.
Revoking works on the very next call. Work already running still finishes and is charged.
Never put `sk_live_...` in browser code, mobile apps, public repos, CI logs or chat prompts. Whoever reads it can spend until it is revoked.
### No CORS on the REST API [#no-cors-on-the-rest-api]
`/api/v1` sends no CORS headers and has no `OPTIONS` handler. On purpose. Call it from a server, task runner, CI job or terminal. Never from a browser.
MCP is different: it allows CORS, because its token comes from an OAuth sign-in, not a secret key. [Connect MCP](/mcp/connect).
### CLI key file [#cli-key-file]
`riffads login` saves `~/.riffads/config.json` with mode `0600`. `RIFFADS_API_KEY` in the environment wins over the file. `riffads logout` deletes the file only. The key still works until you revoke it. [CLI setup](/cli/setup).
### Read-only access [#read-only-access]
* A key without the `generate` or `workflows` scope can read and estimate, never start work. It is refused before anything is written.
* A read-only MCP connection does not even see the spend tools.
Use read-only access for dashboards, reports and CI checks.
## Tenancy [#tenancy]
* **REST:** no workspace parameter or header. The workspace comes from the key.
* **MCP:** the token is a person, bound to one workspace. Checked again on every call.
* Another workspace's id and a missing id both return `not_found`. Nobody can probe what exists.
## What never leaves the server [#what-never-leaves-the-server]
* Provider names, model keys, prompts, signed URLs and stack traces never appear in an error.
* Your text is not stored by the content check. Only a fingerprint.
* Card numbers never reach RiffAds. Checkout runs on the payment provider's hosted page.
## Report a problem [#report-a-problem]
**Leaked key:** revoke it in the app first. It stops on the next call.
**Privacy or security report:** use the contact address in the [Privacy Policy](https://riffads.com/privacy). [Legal](/policy/legal).
# Legal (/policy/legal)
A short summary of the rules. The published documents are the agreement. If this page and a document disagree, the document wins.
They cover every surface: the app, REST API, MCP, CLI and Agent Skills. There is no separate developer agreement. The Acceptable Use Policy and AI Disclosure are part of the Terms: break one, break the Terms.
## You own what your keys and agents do [#you-own-what-your-keys-and-agents-do]
You are responsible for every action and charge from your account, your workspace members, and every API key, MCP connection, CLI login and Agent Skill you connect. That includes actions an agent takes without asking.
* **Supervise.** Give clear instructions. Watch actions and spend. Check outputs before you publish. Nobody at RiffAds reviews output.
* **Give only the access needed.** Reading only? Use a key without the `generate` or `workflows` scope, or a read-only MCP connection.
* **Set limits.** Every paid request needs `max_credits`. Owners and admins can add workspace and key caps. Caps don't replace supervision.
* **Keep keys secret.** Never in public code, browser code, shared docs or prompts. Never share, sell or publish one. Leaked? Revoke it now. Work already started can still finish and be charged.
* **Watch for tricks.** Hidden instructions in pages, files or messages can steer an agent. You are still responsible.
* **Connected tools have their own terms.** Your assistant or editor may keep prompts, scripts and download links. Check its terms.
## Banned use [#banned-use]
* Using agents to make policy-breaking content, to test the content checks, or to repeat refused requests.
* Getting around rate, spend, plan or seat limits.
* Scraping the site or bulk copying the actor or voice library. Use the API, MCP, CLI or Skills.
* Probing or load testing without written permission.
* Reselling or white labeling without a written agreement.
* Training a competing model on RiffAds or its outputs.
## Content rules [#content-rules]
Full list in the [Acceptable Use Policy](https://riffads.com/acceptable-use). What the checks catch: [Content policy](/policy/content).
* **Banned:** sexualizing minors, sexual content, hate, harassment, violence, extremism, self-harm, illegal goods and services, scams, fraud, malware.
* **Restricted ads:** gambling, alcohol, nicotine, cannabis, medicines, supplements, weapons, financial services, dating.
* **Honest ads:** an AI actor is not a real customer, reviewer or doctor. Every claim must be provable. Election and IP rules apply.
Consent the product can't check for you:
* Actor from a real person's photo, or a cloned voice: **written consent first.**
* Never anyone under 18.
* Never a public figure without written consent.
* Keep a copy of every consent.
## What you own [#what-you-own]
* **Your content stays yours:** scripts, prompts, uploads, voice samples, brand and product details. You license it to RiffAds only to run the service, including sending it to model providers.
* **You own your outputs** (versus RiffAds) as far as law allows, including paid ads and client work.
* Third-party model terms apply. Outputs are not guaranteed unique.
* Library actors and voices are not yours. You may use them in your outputs.
## Enforcement [#enforcement]
On a breach, or a likely one, RiffAds may:
* Block requests or remove content.
* Disable keys, connections or agent access.
* Limit features or spending.
* Suspend or terminate the workspace.
* Report illegal content to authorities where required.
Serious harm, legal risk or repeat breaches: right away, no notice. Think a decision was wrong? Write to the inbox named in the Acceptable Use Policy and say why.
## Report abuse or misuse of a likeness [#report-abuse-or-misuse-of-a-likeness]
Write to the abuse inbox in the [Acceptable Use Policy](https://riffads.com/acceptable-use). Include:
* Your name and contact.
* The content and where you saw it.
* Which rule it breaks, and why.
* Likeness or voice: proof you are the person, or can act for them.
* Copyright or trademark: the work or mark, and proof you own it or act for the owner.
Never send a false report. Found child sexual abuse material? Don't download, copy or forward it. Report it to police or a child protection hotline.
RiffAds never posts to Meta, TikTok, YouTube or X. Only the hosting platform can remove content there. Report it to them too.
Not legal advice. Your rules depend on where you are, where the ad runs, and which platform you use.
# Quickstart: REST API (/quickstart/api)
Your first video from a terminal in four calls. Base URL `https://app.riffads.com/api/v1`.
You need: a RiffAds workspace on a paid plan, `curl` and `jq`.
No CORS. Never call the API from a browser.
### Make a key [#make-a-key]
Owners and admins create keys at [app.riffads.com/api-keys](https://app.riffads.com/api-keys). Tick **Generate**, or submits fail with `403 insufficient_scope`. The key starts with `sk_live_` and is shown once.
```bash title="Terminal"
export RIFFADS_API_KEY="sk_live_..."
```
### Estimate [#estimate]
Text-to-video with `veo_31`: one prompt, one generation. The estimate returns the number you need for `max_credits`.
```bash title="Terminal"
curl -s -X POST https://app.riffads.com/api/v1/estimates \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "veo_31",
"config": { "prompt": "A matte black water bottle on wet slate, morning light" }
}' | jq '.credits'
```
### Submit [#submit]
```bash title="Terminal"
curl -s -X POST https://app.riffads.com/api/v1/generations \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"capability_id": "veo_31",
"config": { "prompt": "A matte black water bottle on wet slate, morning light" },
"max_credits": 440
}' | jq -r '.generation_id'
```
`max_credits` is the spend cap for this job. Send at least `ceil(estimate * 1.1)`. The bare estimate is refused with `402 max_credits_exceeded`. The `440` above is only an example.
* Success is `201` with a `generation_id` (`gen_...`). **Save it.** No endpoint lists your generations.
* Body is strict: `maxCredits` or any unknown key is a `400`.
### Wait, then download [#wait-then-download]
Each call blocks up to 20 seconds. Call again while `still_running` is `true`.
```bash title="Terminal"
GEN=gen_... # from the submit
while :; do
BODY=$(curl -s "https://app.riffads.com/api/v1/generations/$GEN/wait" \
-H "Authorization: Bearer $RIFFADS_API_KEY")
[ "$(echo "$BODY" | jq -r '.still_running')" = "false" ] && break
done
echo "$BODY" | jq -r '.generation.status'
echo "$BODY" | jq -r '.generation.outputs[0].url' | xargs curl -L -o ad.mp4
```
* `completed`: `outputs[0].url` is a signed link that **expires in 600 seconds**. Download it, don't store it. Expired? Call wait again for a fresh one.
* `failed`: no file. Read `generation.error`.
* Branch on `still_running`, not on `status`.
## If it fails [#if-it-fails]
| Code | Fix |
| ---------------------- | ----------------------------------------------------- |
| `max_credits_exceeded` | Send at least `ceil(estimate * 1.1)`. |
| `insufficient_scope` | Make a key with Generate ticked. |
| `invalid_config` | Read `GET /capabilities/veo_31` and fix `config`. |
| `submission_in_flight` | One submit in flight per key. Wait for the first job. |
| `insufficient_credits` | Top up in the app. |
| `spend_limit_exceeded` | A workspace cap blocked it. Ask an owner. |
| `moderation_blocked` | Change the wording. Don't resend the same text. |
Every error has `code`, `message` and `retryable`. All codes: [Errors](/reference/errors).
## Next [#next]
# Quickstart: CLI (/quickstart/cli)
One video from your terminal in three commands. The CLI calls the REST API for you.
You need: Node 20.10.0 or newer, and an API key with **Generate** ticked from [app.riffads.com/api-keys](https://app.riffads.com/api-keys) (owners and admins create keys).
## Install and log in [#install-and-log-in]
```bash title="Terminal"
npm install -g riffads
riffads login --base-url https://app.riffads.com
riffads whoami
```
* `login` asks for the key and checks it before saving. A bad key exits `3` and saves nothing.
* `whoami` shows which host and key it will use.
The default base URL is `https://riffads.com`, which has no API. Pass `--base-url https://app.riffads.com` or set `RIFFADS_BASE_URL`. Symptom: `did not answer as a RiffAds API endpoint`.
CI: skip `login`. Set `RIFFADS_API_KEY` and `RIFFADS_BASE_URL`.
## Make your first video [#make-your-first-video]
`veo_31` is text-to-video: one prompt, one generation.
```bash title="Terminal"
CONFIG='{"prompt":"A matte black water bottle on wet slate, morning light"}'
# the estimate prints a bare number on stdout
EST=$(riffads estimate --capability veo_31 --config "$CONFIG")
MAX=$(( (EST * 11 + 9) / 10 )) # ceil(EST * 1.1)
riffads generate \
--capability veo_31 \
--config "$CONFIG" \
--max-credits "$MAX" \
--output ./out
```
`--max-credits` is required. Send at least `ceil(estimate * 1.1)`. The bare estimate is refused with `max_credits_exceeded` (exit `4`).
* `generate` blocks until the job ends, then saves the file to `./out` (for example `./out/gen_8c31fa-0.mp4`).
* stdout prints the generation id first, then file paths. Keep the id.
* Ctrl+C stops waiting, not the render. Download it later:
```bash title="Terminal"
riffads download gen_8c31fa --output ./out
```
Signed links expire in 10 minutes, so `download` takes an id, never a URL.
## Exit codes [#exit-codes]
| Code | Meaning |
| ---- | ---------------------------------------------------------------- |
| 0 | Worked |
| 2 | Fix the request (bad flag, bad config, blocked content) |
| 3 | Fix the key, scope, plan or workspace |
| 4 | Not enough credits (top up in the app) or a spend cap blocked it |
| 5 | Refused for now. Send the same call again later |
| 6 | The render failed |
| 7 | Only part of the output arrived |
Full list and flags: [Commands](/cli/commands).
## Next [#next]
# Quickstart: MCP (/quickstart/mcp)
Connect your AI agent to RiffAds and make one video. No API key: you sign in with OAuth in the browser.
You need: a RiffAds workspace on a paid plan and any MCP client that speaks streamable HTTP.
## Connect [#connect]
### Add the server [#add-the-server]
```text title="Server URL"
https://mcp.riffads.com
```
```bash title="Terminal"
claude mcp add --transport http riffads https://mcp.riffads.com
```
Settings, Connectors, Add custom connector. Paste the URL, save.
```json title="Cursor MCP config"
{ "type": "http", "url": "https://mcp.riffads.com" }
```
```json title="VS Code MCP config"
{ "name": "riffads", "type": "http", "url": "https://mcp.riffads.com" }
```
Settings, Connectors, paste the URL.
More clients and raw config: [Connect](/mcp/connect).
### Sign in and allow spending [#sign-in-and-allow-spending]
The consent screen asks for a workspace and whether the agent may spend. Pick spend, or the agent can browse but not generate.
Several workspaces? Until you pick one at [app.riffads.com/connections](https://app.riffads.com/connections), every tool returns `connection_not_configured`.
### Check it [#check-it]
Ask your agent to run `riffads_ping`. Look for `"can_spend_credits": true`. `false` means a read-only connection.
## Make your first video [#make-your-first-video]
Just ask: "Make an 8 second RiffAds video of a barista sliding a flat white across a marble counter."
The agent should call these tools in order. `veo_31` is text-to-video: one prompt, one generation.
```json title="1. estimate_generation"
{
"capability_id": "veo_31",
"config": { "prompt": "A barista slides a flat white across a marble counter." }
}
```
The result's `credits` is the number to base `max_credits` on.
```json title="2. submit_generation"
{
"capability_id": "veo_31",
"config": { "prompt": "A barista slides a flat white across a marble counter." },
"max_credits": 264
}
```
Send at least `ceil(estimate * 1.1)`, or the submit is refused with `max_credits_exceeded`. `264` is only an example.
```json title="3. wait_for_generation"
{ "generation_id": "gen_9f2c8d1a4b6e" }
```
* Each call blocks up to 20 seconds. Call again while `still_running` is `true`.
* Done: `generation.outputs[0].url` is a signed link, also sent as a `resource_link`.
* **Links last 600 seconds.** Read the generation again for a fresh one.
Not sure of a field? `get_capability_schema` returns the JSON Schema and an `example_config`. Unknown keys are refused.
## If a tool refuses [#if-a-tool-refuses]
| Code | Fix |
| --------------------------- | ---------------------------------------------------------------- |
| `connection_not_configured` | Pick a workspace at `/connections`. |
| `read_only_connection` | Reconnect with spending allowed. Owners and admins can widen it. |
| `max_credits_exceeded` | Send at least `ceil(estimate * 1.1)`. |
| `spend_limit_exceeded` | A workspace cap blocked it. Ask an owner. |
| `insufficient_credits` | Top up in the app. |
| `submission_in_flight` | One job at a time. Wait for the first. |
All codes: [Errors](/reference/errors).
## Next [#next]
# Quickstart: Agent Skills (/quickstart/skills)
Install one plugin in Claude Code, then ask for a video in plain words. The skill prices it, asks you, then makes it.
You need: a RiffAds workspace on a paid plan and Claude Code. Other clients get the same tools through [MCP](/quickstart/mcp), without the skill.
## Install [#install]
### Add the plugin [#add-the-plugin]
```bash title="Claude Code"
/plugin marketplace add riffads-ai/skills
/plugin install riffads@riffads
```
### Approve the connection [#approve-the-connection]
The plugin brings the MCP server `https://mcp.riffads.com`. Claude asks to connect. Sign in in the browser and allow spending.
No prompt? Add it by hand:
```bash title="Terminal"
claude mcp add --transport http riffads https://mcp.riffads.com
```
### Pick a workspace [#pick-a-workspace]
Only if you have several. Pick one at [app.riffads.com/connections](https://app.riffads.com/connections). Until then every call returns `connection_not_configured`.
## Make your first video [#make-your-first-video]
Nothing to invoke. Ask:
```text title="Claude Code"
Make an 8 second RiffAds video of a matte black water bottle on wet slate, morning light.
```
The skill then:
1. Checks the connection with `riffads_ping`.
2. Picks a capability (text-to-video, like `veo_31`) and reads its schema.
3. Runs `estimate_generation` and quotes it to you.
4. Waits for your yes, then calls `submit_generation` with `max_credits` set from the quote.
5. Calls `wait_for_generation` until it's done and hands you the link.
Links expire in about 10 minutes. Ask again for a fresh one.
Other things it understands:
* "give me four variants of this image" (up to 4)
* "the same script with three different actors"
* "what would this cost in RiffAds"
## If it stops [#if-it-stops]
The tools are missing, so the skill says RiffAds isn't connected. Approve the connection, then ask again.
Pick a workspace at [/connections](https://app.riffads.com/connections). Retrying changes nothing.
The connection can't spend. Reconnect with spending allowed. Owners and admins can widen it.
A workspace cap blocked it (ask an owner), or you're out of credits (top up in the app).
## Next [#next]
# Error codes (/reference/errors)
Your call was refused. Find the `code` below. Do what the row says.
`code` is a closed set of 25. `message` is one sentence you can show a person. Its wording can change.
## The error body [#the-error-body]
Same object on REST, MCP and the CLI.
```json title="Response: 404"
{
"error": {
"ok": false,
"code": "not_found",
"message": "No such generation. Check the id returned by submit_generation.",
"retryable": false,
"credits_charged": 0
}
}
```
| Field | Type | What it is |
| ----------------- | ------- | ----------------------------------------------- |
| `ok` | `false` | Always `false` on a refusal. |
| `code` | string | One of the 25 codes below. |
| `message` | string | One safe sentence. Show it, don't parse it. |
| `retryable` | boolean | May you send the identical request again. |
| `credits_charged` | number | What this refused call cost. Almost always `0`. |
Extra fields appear only on some codes. They are left out when not set, never `null`.
| Field | On | Use |
| --------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `retry_after_seconds` | `rate_limited`, transient `request_blocked`, `submission_in_flight` (race) | Seconds to wait. |
| `in_flight` | `submission_in_flight` (one per key) | `generation_id` and `status` of the job already running. Wait on it. |
| `blocked_reason` | `request_blocked` | The code that got this request blocked. |
| `capability_status` | `capability_unavailable`, `required_plan` | `coming_soon`, `retired`, `requires_plan` or `unknown`. |
| `required_plan` | `required_plan` | The plan that unlocks it, when known. |
| `shortfall` | `insufficient_credits` | Credits missing. |
| `limit` | `max_credits_exceeded`, `spend_limit_exceeded` | Which cap refused. `limit.bound_by` names it. |
| `connect_url` | `connection_not_configured` (MCP only) | Page where a person picks the workspace. |
### Where it arrives [#where-it-arrives]
| Surface | Shape |
| ------- | --------------------------------------------------------------------------------------------------------------------- |
| REST | Under `error`, with the HTTP status from the table. `Retry-After` header on a 429 that has `retry_after_seconds`. |
| MCP | The whole object (no `error` wrapper) as JSON in one text block, with `isError: true`. |
| CLI | Sentence and code on stderr. `--json` prints `{"error": {...}}` on stdout. Exit codes: [CLI commands](/cli/commands). |
## All codes [#all-codes]
| Code | HTTP | Retryable | What to do |
| --------------------------- | ---- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `not_authorized` | 401 | no | Key missing, malformed, unknown, revoked or expired (MCP: connection not authorized). Fix the header or create a new key. |
| `no_workspace` | 403 | no | The account has no workspace. Create one in the app, then reconnect. |
| `connection_not_configured` | 403 | no | MCP only. The account has several workspaces. Send the person to `connect_url` to pick one. |
| `workspace_unavailable` | 410 | no | The key's workspace is gone. This credential will never work again. Use a key from a live workspace. |
| `required_plan` | 403 | no | The workspace plan does not include the agent API or this capability. Pick another capability, or a person upgrades. |
| `read_only_connection` | 403 | no | A read-only connection tried to start work. Reconnect with spend access. |
| `insufficient_scope` | 403 | no | REST only. The key lacks the scope named in the message. Create a key with it. |
| `invalid_config` | 400 | no | Body is not JSON, not an object, fails the schema, or has an unknown key. The message lists up to 5 issues. Read `config_schema` from [capabilities](/api/capabilities). |
| `capability_unavailable` | 404 | no | Capability id unknown, retired or not live yet. Check `capability_status`, then list capabilities. |
| `not_found` | 404 | no | Id does not exist or belongs to another workspace. Never says which. Also returned for an unknown `/api/v1` path. |
| `input_not_ready` | 409 | **yes** | An upload is still in its safety scan. Wait a moment, send again. |
| `insufficient_credits` | 402 | no | Not enough credits. A person tops up in the app. |
| `max_credits_exceeded` | 402 | no | Your `max_credits` is too low. Send at least `ceil(estimate * 1.1)`, or ask for something cheaper. |
| `spend_limit_exceeded` | 402 | no | A workspace or key spend cap blocked it. Ask a workspace owner or admin. |
| `estimate_changed` | 409 | **yes** | The price moved. Estimate again, raise `max_credits` if needed, resend. |
| `not_priced` | 409 | no | This capability cannot be priced yet. Use another capability. |
| `pricing_unavailable` | 503 | **yes** | Pricing failed for a moment. Retry shortly. |
| `moderation_blocked` | 422 | no | Refused by content policy. Change the wording or the file. Resending unchanged gets blocked. [Content policy](/policy/content). |
| `moderation_unavailable` | 503 | **yes** | Content checks are down, so nothing can start. Reads still work. Retry later. |
| `rate_limited` | 429 | **yes** | Too many requests, uploads or renders at once. Wait `retry_after_seconds`, else back off. [Limits](/reference/limits). |
| `quota_exceeded` | 429 | no | The key used up its request allowance, or too many uploads are still processing. Waiting a few seconds won't fix it. |
| `submission_in_flight` | 409 | **yes** | Something is already running. Wait on it, don't resend. See below. |
| `request_blocked` | 429 | no | This exact request is blocked after repeated failures. Send a different request. See below. |
| `provider_unavailable` | 502 | **yes** | A provider or dependency failed. Back off, retry. |
| `internal_error` | 500 | **yes** | Our fault. Retry once or twice, then stop. |
8 codes are retryable. 17 are not.
`rate_limited`, `quota_exceeded` and `request_blocked` are all 429. Only `rate_limited` is retryable. Read `retryable`.
## Codes with more than one cause [#codes-with-more-than-one-cause]
**`submission_in_flight`**
| Cause | Extra field | Do |
| ------------------------------------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------- |
| This API key (or MCP person) already has a generation running | `in_flight` | Wait on `in_flight.generation_id` with [`GET /generations/{id}/wait`](/api/generations). |
| Someone in the workspace sent the identical request first | `retry_after_seconds: 30` | Wait for theirs, or change the request. Nothing was charged. |
| The workflow already has a run in flight | none | Wait for that run to finish. |
**`request_blocked`**
| Kind | Armed after | Lasts | `retry_after_seconds` |
| --------- | ------------------------------------------------------------------------------------------------- | ------------ | --------------------- |
| Transient | 3 failures in a row (`provider_unavailable`, `internal_error`, or a content refusal from a model) | about 1 hour | yes |
| Permanent | 2 content refusals from the rule list | 30 days | no |
Both are `retryable: false`. Change the script or inputs. Adding junk fields does not make it a new request: unknown keys are refused first.
**`rate_limited`** from too many renders at once has **no** `retry_after_seconds`. Wait for a job to finish, then back off.
**`moderation_blocked`** on a file names the asset id (`ast_...`). That file can never be used. Upload a different one.
## Retry rules [#retry-rules]
* **`retryable: false`:** never send that request again. Not after a wait, not with a new key. Change it or stop.
* **`retryable: true`:** at most two more attempts. Honor `retry_after_seconds`, else back off. Then stop.
* **Lost the response?** Don't resubmit to find out. A second submit can be a second charge. Read the generation instead.
```ts
async function submit(body: unknown, attempt = 0): Promise {
const res = await fetch("https://app.riffads.com/api/v1/generations", {
method: "POST",
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
body: JSON.stringify(body),
});
const json = await res.json();
if (res.ok) return json;
const e = json.error;
if (!e.retryable) throw new Refused(e.code, e.message);
if (e.code === "submission_in_flight" && e.in_flight) {
return waitFor(e.in_flight.generation_id);
}
if (attempt >= 2) throw new Refused(e.code, e.message);
await sleep((e.retry_after_seconds ?? 2 ** attempt) * 1000);
return submit(body, attempt + 1);
}
```
## A refusal is not a failed generation [#a-refusal-is-not-a-failed-generation]
* **Refusal:** the submit did not happen. Nothing started.
* **Failed generation:** the submit was accepted (201), then the job failed. You see it on a read: `status: "failed"` and `error: { code, message }`. That `code` can be `null`. [Results](/guides/results).
## The one 429 with a different shape [#the-one-429-with-a-different-shape]
The MCP host has a per-IP brake that answers before auth. It is OAuth shaped, with `Retry-After`. `/api/v1` is not behind it.
```json title="Response: 429"
{
"error": "too_many_requests",
"error_description": "Too many requests from this address. Please wait and try again."
}
```
## Codes that don't exist [#codes-that-dont-exist]
No `bad_request`, `conflict`, `forbidden`, `unauthorized`, `server_error`, `timeout`, `payload_too_large`, `unsupported_media_type`. Another workspace's id answers `not_found`, never `forbidden`.
# Limits (/reference/limits)
Every number RiffAds enforces on the API, MCP and the CLI. All are fixed. No plan or header changes them unless the row says so.
## Rate limits [#rate-limits]
REST gets both brakes below. MCP has no key, so it gets only the per-action buckets. Over either one: `rate_limited` with `retry_after_seconds`.
### Per key [#per-key]
* **120 requests a minute** per API key, 60 second window.
* Copied onto the key when it is created.
### Per action [#per-action]
Fixed 60 second windows. Counted per API key on REST, per person on MCP.
| Bucket | Per minute | Spent by |
| ---------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ |
| `agent_read` | **120** | Capability list and detail, actors, voices, generation reads, the wait, batch reads, templates, workflow run reads |
| `agent_estimate` | **60** | `POST /estimates`, `estimate_generation`, `riffads estimate` |
| `agent_submit` | **20** | `POST /generations`, `submit_generation`, `riffads generate` |
| `agent_upload` | **30** | Reserving **and** finalizing an upload |
| `workflow_run` | **20** | Both workflow invoke routes and `run_workflow` |
* One wait call costs one `agent_read` token, however long it blocks.
* No `X-RateLimit-*` headers. Only `Retry-After`, and only on a 429 that has `retry_after_seconds`.
* `quota_exceeded` (the key's request allowance is spent) is also 429, but not retryable. [Error codes](/reference/errors).
### MCP host brake [#mcp-host-brake]
Per IP, before any token check, on `https://mcp.riffads.com` only.
| Rule | Limit | Window |
| --------------------------- | ----- | ---------- |
| Dynamic client registration | 10 | 10 minutes |
| Every other MCP path | 120 | 60 seconds |
This 429 is OAuth shaped (`{"error": "too_many_requests", ...}`), not the error envelope.
## Concurrency [#concurrency]
| Rule | Value |
| ---------------------------------------- | ----------------- |
| Agent submissions running, REST | **1 per API key** |
| Agent submissions running, MCP | **1 per person** |
| A stuck job stops holding the slot after | **600 seconds** |
| Renders at once, per workspace | Set by plan |
* Slot busy: `submission_in_flight` with `in_flight.generation_id`. Wait on that job.
* Render cap hit: `rate_limited` with **no** `retry_after_seconds`. Wait for a job to finish.
* One submit with several variants takes one slot.
## Variants [#variants]
| Rule | Value |
| ------------------- | ----------------------------------------- |
| Variants per submit | max **4** |
| Real ceiling | the smaller of the capability's max and 4 |
| Over the ceiling | **refused, never clamped** |
Every variant is a full generation and is charged.
## Uploads [#uploads]
| Kind | Accepted types | Max size |
| ----- | -------------------------------------------------------------------------------- | ---------- |
| Image | `image/jpeg`, `image/png`, `image/webp`, `image/gif` | **20 MB** |
| Video | `video/mp4`, `video/webm`, `video/quicktime` | **100 MB** |
| Audio | `audio/mpeg`, `audio/mp3`, `audio/wav`, `audio/x-wav`, `audio/webm`, `audio/ogg` | **25 MB** |
| Rule | Value |
| ------------------------------------- | ------------------------------------------ |
| Pixels per image | max **40,000,000** |
| Longest image side | max **16,384** px |
| `filename` | **1 to 255** characters |
| Signed PUT link | **300 seconds**, one use, exact byte count |
| Unfinished reservations per workspace | max **50** |
| Abandoned reservation clears after | **15 minutes** |
| Reservations per workspace | **30 a minute** |
* Other file types are refused at reserve, before any bytes move.
* A capability can set stricter file rules. The refusal names its number.
[Uploads](/api/uploads).
## Page sizes [#page-sizes]
| List | Default | Max |
| ------------------- | ------- | --- |
| `GET /capabilities` | 25 | 50 |
| `GET /actors` | 25 | 50 |
| `GET /voices` | 25 | 50 |
* `limit` above the max is clamped, not refused. Junk values (`abc`, `0`, `-1`, `2.5`) read as omitted.
* Cursors are opaque. `next_cursor` is `null` on the last page. A stale cursor restarts from the top.
## Waits and timeouts [#waits-and-timeouts]
| Thing | Value |
| ---------------------------- | -------------- |
| One wait call blocks at most | **20 seconds** |
| Wait route wall clock | **60 seconds** |
| MCP route wall clock | **60 seconds** |
| Image inspection on finalize | **5 seconds** |
* Wait budget used up: **200** with `still_running: true`. Never 408 or 504. Call again.
* No `timeout` parameter.
There is no published render time. Loop the wait until `still_running` is `false`. Don't build a deadline from the wait budget.
### CLI [#cli]
| Thing | Value |
| ------------------------------------------------------------------------------- | ---------------------------------- |
| Request timeout | 30 seconds |
| Wait call timeout | 90 seconds |
| `riffads generate`, `riffads status --wait` give up after | 30 minutes (`--timeout `) |
| `riffads workflows invoke --wait`, `riffads workflows run --wait` give up after | 60 minutes (`--timeout `) |
| Retryable refusals in a row before it stops | 5 |
Giving up is not cancelling. The job keeps running. [CLI commands](/cli/commands).
## Link lifetimes [#link-lifetimes]
| Link | Lifetime |
| ---------------------------- | ------------------------------------------------------ |
| Output link (REST, MCP, CLI) | **600 seconds** (`output_urls_expire_in_seconds: 600`) |
| Signed upload PUT link | **300 seconds** |
Download right away. If it fails, read the generation again for a fresh link. Don't retry the old URL. [Results](/guides/results).
## Blocks [#blocks]
| Window | Value |
| -------------------------------------- | ---------------- |
| Failures in a row that pause a request | **3** |
| Pause lasts | about **1 hour** |
| Content refusals that block a request | **2** |
| Block lasts | **30 days** |
Only `moderation_blocked`, `provider_unavailable` and `internal_error` count. [Error codes](/reference/errors).
## Request bodies [#request-bodies]
* Every write body is strict. **Unknown keys are refused**, including inside `config`. `maxCredits` is a 400.
* Workflow invoke: max **60** input entries.
* Validation refusals list max **5** issues, then `Plus N more.`
* No published body size limit. Send files through uploads.
## Spend caps [#spend-caps]
Agent spend is capped by `max_credits` on each request, plus caps a workspace owner or admin sets. Over one: `max_credits_exceeded` or `spend_limit_exceeded`.
# Statuses and ids (/reference/statuses-and-ids)
What each status means and each id prefix names. Each status list belongs to one kind of object. Don't mix them.
## Id prefixes [#id-prefixes]
Prefix, underscore, 32 lowercase hex characters.
```text
gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033
ast_0193c8f0a1b24e7f9d3c5a6b7e8f0011
```
| Prefix | Names | Where you see it |
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------ |
| `gen_` | One generation | `generation_id` on every submit and read |
| `bg_` | A batch: the generations from one submit with `variants` | `batch_group_id`, `GET /batches/{id}` |
| `ast_` | An asset: upload, library file or output | `asset_id`, file fields in `config` |
| `act_` | An actor | `actor_id` |
| `voc_` | A voice | `voice_id` |
| `wf_` | A saved workflow | `workflow_id`, the invoke path |
| `wfr_` | A workflow run | `workflow_run_id`, `Location` header on invoke |
| `sk_live_` | An API key | Shown once when created in the app. The API never returns it |
Not prefixed:
* **Capability id:** a slug, like `actor_ultra`, `tts`, `veo_31`. [Capabilities](/capabilities).
* **Template key:** a slug, like `ugc_face_cam`. Invoke takes the key.
* **Node id:** set by the workflow author, like `f_brand`. Send back exactly what the API gave you.
### Id rules [#id-rules]
* Pass ids back exactly. Don't lowercase, trim, re-prefix or parse them.
* Never read an id out of a download URL. Use the body field.
* Another workspace's id answers `not_found`, never `forbidden`.
* Asset ids must match `^ast_[0-9a-f]{32}$`. Malformed in `config`: `invalid_config`.
## Generation status [#generation-status]
| Status | Means | Still running |
| ----------------- | --------------------------------- | ------------- |
| `queued` | Accepted, not picked up yet | yes |
| `rendering` | Being made | yes |
| `post_processing` | Output being copied to storage | yes |
| `completed` | Every output delivered and stored | no |
| `failed` | Did not deliver | no |
* There is no `canceled` status. A canceled job reads `failed`.
* A `failed` generation carries `error: { code, message }`. `code` can be `null`.
`GET /generations/{id}/wait` returns `still_running`. Loop until it is `false`. Looping on `status` can wait forever on a job stuck in `post_processing`. [Results](/guides/results).
The final cost is in `credits.credits_charged`. It is `null` until `credits.terminal` is `true`. `null` is not zero.
## Batch status [#batch-status]
Rolled up from every generation under one `bg_` id.
| Status | When |
| ----------- | --------------------------------------------- |
| `running` | Any generation is not `completed` or `failed` |
| `completed` | All `completed` |
| `failed` | All `failed` |
| `partial` | All finished, some completed, some failed |
## Workflow run status [#workflow-run-status]
No wait endpoint for runs. Poll about every 30 seconds.
| Status | Means | Finished |
| ----------- | ----------------------------------------------------- | -------- |
| `queued` | Run created, nothing started | no |
| `running` | A step is working | no |
| `completed` | Every planned step finished | yes |
| `partial` | Some steps finished, some failed, skipped or canceled | yes |
| `failed` | Nothing finished, something broke | yes |
| `canceled` | Canceled before any step completed | yes |
* `still_running` is `false` on every finished status.
* `partial` is normal. Finished steps are charged. Broken ones cost nothing.
### Workflow node status [#workflow-node-status]
| Status | Means |
| ------------- | -------------------------------- |
| `pending` | Waiting on an earlier step |
| `ready` | Inputs filled, starts next |
| `dispatching` | Being submitted |
| `running` | Generations working |
| `completed` | Delivered |
| `failed` | Did not deliver |
| `skipped` | An input failed, so it never ran |
| `canceled` | Run canceled before it started |
A node `error` is `{ code, message }`. `code` is one of `provider_error`, `moderation`, `timeout`, `canceled`, `insufficient_credits`, `invalid_input`, `unknown`.
## Upload scan status [#upload-scan-status]
Returned by `POST /uploads/{assetId}/finalize`.
| `scan_status` | `usable` | Do |
| ------------- | -------- | --------------------------------- |
| `clean` | `true` | Use the `ast_` id |
| `flagged` | `false` | Never usable. Upload another file |
| `pending` | `false` | Call finalize again shortly |
Branch on `usable`. Finalize is safe to repeat. [Uploads](/api/uploads).
## Capability status [#capability-status]
| Status | Means |
| --------------- | ----------------------------------------- |
| `available` | Submit now |
| `requires_plan` | Not in your plan. Carries `required_plan` |
| `coming_soon` | Not live. Submits are refused |
| `retired` | Direct lookup only. Dropped from the list |
| `unknown` | Direct lookup only. Id is not public |
A capability can disappear from `GET /capabilities`. That is normal.
# Agent Skills (/skills/overview)
The `riffads` plugin gives Claude Code the RiffAds MCP tools plus a written procedure: look up, estimate, quote, submit, follow, hand over the file. No code, no new capability.
## Install [#install]
```bash title="Inside Claude Code"
/plugin marketplace add riffads-ai/skills
/plugin install riffads@riffads
```
* `riffads@riffads` is `@`. The repo is its own marketplace.
* Claude Code only. Other clients get the same tools, without the procedure: [connect a client](/mcp/connect).
* No API key. The plugin adds the MCP server, and you sign in in a browser.
The plugin ships this `.mcp.json`:
```json title=".mcp.json"
{
"mcpServers": {
"riffads": {
"type": "http",
"url": "https://mcp.riffads.com"
}
}
}
```
Not live for you yet? Add the server by hand. You get all 17 tools, just not the procedure:
```bash title="Terminal"
claude mcp add --transport http riffads https://mcp.riffads.com
```
## First run [#first-run]
### Sign in [#sign-in]
The first tool call opens a browser. Sign in and approve.
### Pick a workspace (only if you have several) [#pick-a-workspace-only-if-you-have-several]
Until you pick, every call refuses with `connection_not_configured`. Pick at [app.riffads.com/connections](https://app.riffads.com/connections).
### Just ask [#just-ask]
No command to type. A skill loads when your request matches:
* "make me an ad"
* "what would this cost in RiffAds"
* "give me four variants of this image"
* "the same script with three different actors"
Check it worked: ask "What are you connected to?". The agent calls `riffads_ping` and names the workspace.
## The three skills [#the-three-skills]
| Skill | Use it for | Loads on |
| ----------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `cost-aware-generation` | Any single generation, including talking actor ads. The other two build on it. | "make me an ad", "generate a talking actor video", "what would this cost in RiffAds", "check on the RiffAds generation from earlier" |
| `batch-image-variants` | Up to 4 takes of one image or video config, read back with `get_batch`. | "give me four variants of this image", "some options for this product shot" |
| `batch-actor-variants` | One script, several actors, through a workflow template. | "the same script with three different actors", "which actor reads this script best", "run a RiffAds workflow" |
All three can start paid work. Only the short descriptions load each session. The full procedure loads when a skill runs.
## How the agent behaves [#how-the-agent-behaves]
**Free calls first.** Never submits to find out a price.
| # | Tool | Why |
| - | ----------------------- | ------------------------------------------------------------ |
| 1 | `riffads_ping` | Which workspace, whether it may spend. Once per conversation |
| 2 | `get_credit_balance` | Stops early if nothing can be spent |
| 3 | `list_capabilities` | Only if the capability id is unknown |
| 4 | `get_capability_schema` | Builds a valid config |
| 5 | `estimate_generation` | The number to quote |
| 6 | `submit_generation` | Only after you agree to the quote |
**Quotes before spending.** Tells you the number and waits for a yes. Sends `max_credits` of at least `ceil(estimate * 1.1)`, never a big round number.
**Follows the job itself.** Calls `wait_for_generation` until `still_running` is `false`. You do not have to ask "is it done?".
**Hands over the file fast.** Links last about 10 minutes. Expired links get refreshed with `get_generation`.
**Respects `retryable`.** `false`: never resends, explains or changes the request. `true`: at most 2 more tries, honouring `retry_after_seconds`. `moderation_blocked` is never reworded around.
**Talking actor = two generations.** `tts` first, then `generate_talking_actor` with `approved_voice_generation_id`. Quotes both up front.
**Several actors = a workflow.** Starts with `list_templates` (node ids cannot be guessed), sets `max_credits` for every step, polls `get_workflow_run` about every 30 seconds.
**Uploads honestly.** `create_upload`, one `PUT`, `finalize_upload`, and uses the asset only after `usable: true`. If it cannot make the `PUT`, it says so.
**Never says** posted, published or shared. Never says finished before the job ends. Never invents an actor id, voice id, capability id or number.
## Guidance, not enforcement [#guidance-not-enforcement]
The server enforces every rule on every call, plugin or not: moderation, `max_credits`, workspace spend caps, one agent submission at a time. An agent that skips the procedure gets refused, not overcharged.
Every tool and argument: [MCP tools](/mcp/tools). Every refusal code: [errors](/reference/errors).