# Unity Quickstart
Source: https://docs.viggle.ai/lplugin/unity-quickstart
Install Viggle Splat Engine, run the sample, and load characters and animations.
Viggle Splat Engine is a Unity runtime plugin for loading and rendering VFCHARB1 `.bin` Gaussian Splat characters in real time. It drives characters with Humanoid or Generic animations. Characters and animations are generated by Viggle services; the plugin does not generate characters itself.
ZIP package · 11 MB
## Recommended environment
| Item | Configuration |
| - | - |
| Unity | Unity 6.3 LTS `6000.3.20f1` |
| GPU API | Metal, DirectX 12, or Vulkan |
| Render pipeline | Universal Render Pipeline (URP) |
| Plugin | Viggle Splat Engine `0.1.10` |
| Character format | VFCHARB1 `.bin` |
Unity 6.5 is not recommended. Older plugin versions may produce `CS0619` or `Failed to resolve assembly: GaussianSplattingEditor`. If you encounter these compilation errors, create a new project with Unity 6.3 LTS.
## 1. Create a Unity project
1. In Unity Hub, install Unity 6.3 LTS `6000.3.20f1`.
2. Create a project with the **Universal 3D** or **URP** template.
3. Wait for the initial Shader, Burst, and asset imports to finish.
No additional platform modules are required to run the sample. Install a platform build module only when you are ready to create a standalone application for that platform.
## 2. Install the plugin
1. Download and extract the ZIP package above.
2. In Unity, open **Window → Package Management → Package Manager**.
3. Click **+** in the upper-left corner and select **Add package from disk…**.
4. Open the extracted `com.viggle.splat-engine` folder and select `package.json`.
5. Wait for Unity to import and compile the package.
After installation, Package Manager displays **Viggle Splat Engine** with the Technical Name `com.viggle.splat-engine`.
## 3. Import and run the sample
1. Select **Viggle Splat Engine** in Package Manager.
2. Open **Samples**, locate **Examples**, and click **Import**.
3. Open `Assets/Samples/Viggle Splat Engine/0.1.10/Examples/CharacterRender/SplatCharacterExample.unity`.
Before entering Play mode, add the rendering feature to the active URP Renderer Data:
1. Stop Play mode and locate the active Renderer Data in the Project panel, such as `Assets/Settings/PC/PC_High_Renderer`.
2. In the Inspector, find **Renderer Features** and click **Add Renderer Feature**.
3. Select **Gaussian Splat URP Feature**.
4. Keep Render Graph enabled. Do not add `URP_COMPATIBILITY_MODE`.
5. Enter Play mode. You can continue if the Console contains no red errors.
If the `.bin` file is parsed and a Gaussian Splat Renderer is created but the Game view remains empty, the active Renderer Data is usually missing **Gaussian Splat URP Feature**.
When the configuration is correct, the loaded character appears in the Game view:
## 4. Load your own character
1. Stop Play mode and import a VFCHARB1 `.bin` file into `Assets/MyCharacters`. Its asset type should appear as **Splat Bin Asset**.
2. Create an empty GameObject named `MyCharacter`. Set Position to `0, 0, 0`, Rotation to `0, 90, 0`, and Scale to `1, 1, 1`.
3. Add **Splat Character Viewer** and drag the `.bin` asset into **Bin File**.
4. Enter Play mode. The Viewer automatically adds **Gaussian Splat Renderer**.
Select `MyCharacter` in the Hierarchy and confirm that the `.bin` assets are visible in the Project panel:
The Inspector should contain **Splat Character Viewer**, an assigned **Bin File**, and the generated **Gaussian Splat Renderer**:
During testing, disable the sample characters and FBX preview objects so that only the Camera, Light, and `MyCharacter` remain active. This prevents a sample character from being mistaken for your imported character.
If changing the `.bin` file does not update the character, stop Play mode, disable the Skinner, disable and re-enable the Viewer, re-enable the Skinner, and enter Play mode again.
## 5. Understand the animation assets
An animation FBX can contain a model root, an Armature, preview meshes, and one or more Animation Clips. Expand the FBX in the Project panel to see its sub-assets.
| FBX sub-asset | Purpose |
| - | - |
| Model root | The complete FBX asset and its hierarchy |
| Armature | The source skeleton that the animation curves target |
| Proxy or SkeletonMesh | Preview model nodes and their mesh data |
| Animation Clip | The motion that belongs in an Animator Controller |
The Animation Clip has a triangular animation icon. Drag this clip into the Animator window. Do not drag the complete FBX, Armature, Proxy, or SkeletonMesh into the Animator Controller, and do not add the preview FBX to the scene unless you need it for comparison.
## 6. Import and configure an animation FBX
1. Create a folder such as `Assets/MyCharacters/Animations` and copy the animation FBX into it.
2. Select the FBX in the Project panel and open the **Rig** tab in the Inspector.
3. Choose the animation type:
| Rig type | When to use it | Splat Character Skinner setting |
| - | - | - |
| Humanoid | Mixamo, marketplace, motion-capture, or other general humanoid animations | `Humanoid` |
| Generic | An animation authored on the exact Viggle canonical skeleton with matching bone names and hierarchy | `Generic` |
For Humanoid animations, set **Avatar Definition** to **Create From This Model**, click **Apply**, and use **Configure** if Unity reports an invalid bone mapping.
The source FBX type and the Skinner Rig Mode must match. A Mixamo animation uses paths such as `mixamorig:Hips`, while the Viggle Generic skeleton uses paths such as `Armature/root/pelvis`. Playing the former as Generic can make the Splat character shake, twist, or move its joints incorrectly even when the FBX preview looks normal.
## 7. Create an Animator Controller
1. Stop Play mode.
2. In `Assets/MyCharacters/Animations`, right-click and select **Create → Animator Controller**.
3. Give the controller a descriptive name, such as `Chatting_AC`.
4. Double-click the controller to open the Animator window. A new controller initially contains only **Any State** and **Entry**.
Store custom controllers outside `Assets/Samples`. Reimporting or deleting the package samples can replace files inside the Samples folder.
## 8. Add an Animation Clip to the controller
1. Expand the animation FBX using the arrow beside its name.
2. Drag the Animation Clip—not the complete FBX—onto the Animator grid.
3. Select the new state and confirm that its **Motion** field references the correct clip.
4. The default state is orange and receives the arrow from **Entry**. If the state is gray, right-click it and select **Set as Layer Default State**.
The included samples use these pairings:
| Animation | Animator Controller | Rig Mode |
| - | - | - |
| Texting and walking | `test_AC` | Humanoid |
| Slap | `generic` | Generic |
## 9. Assign the controller to a Splat character
1. Select `MyCharacter` or `SuomiCharacter` in the Hierarchy. Do not select the source FBX.
2. Add **Splat Character Skinner** if the character does not already have it.
3. Assign your controller to **Animator Controller**.
4. Set **Rig Mode** to the same type used by the animation FBX.
5. For initial testing, set **Pose Every N Frames** and **Sort Every N Frames** to `1`.
6. Enter Play mode and test in the Game view.
## 10. Loop an animation
1. Select the animation FBX in the Project panel.
2. Open the **Animation** tab in the Inspector.
3. Select the clip in the **Clips** list.
4. Enable **Loop Time** and, for a smooth pose transition, **Loop Pose**.
5. Click **Apply**.
6. Keep this clip's state as the orange default state and do not create a transition from it to **Exit**.
If the character jumps when the loop restarts, enable **Bake Into Pose** for **Root Transform Rotation**, **Root Transform Position (Y)**, and **Root Transform Position (XZ)**. Adjust **Cycle Offset** only if the first and last poses still do not align.
## 11. Add multiple controllable actions
Drag additional clips into the same Animator Controller and create parameters such as:
| Parameter | Type | Example use |
| - | - | - |
| `Speed` | Float | Switch between Idle and Walk |
| `Slap` | Trigger | Play a one-shot slap from Any State |
Create transitions with conditions, for example **Idle → Walk** when `Speed > 0.1`, **Walk → Idle** when `Speed < 0.1`, and **Any State → Slap** when the `Slap` trigger is set. Enable **Has Exit Time** on **Slap → Idle** so the one-shot action can finish.
The plugin creates the Animator on its runtime rig. After the character has initialized, gameplay code can access it and set parameters:
```csharp theme={null}
Animator animator = GetComponentInChildren();
animator.SetFloat("Speed", 1f);
animator.SetTrigger("Slap");
```
## Troubleshooting
| Problem | Solution |
| - | - |
| Play mode is blocked by compilation errors | Inspect the first red error. For `CS0619` or assembly resolution errors, use Unity 6.3 LTS. |
| The Renderer exists, but the character is invisible | Check Gaussian Splat URP Feature, object enabled states, the Camera Culling Mask, and the character position. |
| The character faces the wrong direction | Try Rotation Y values of `0`, `90`, `-90`, and `180`. |
| The static character looks correct but deforms during animation | Match the animation to the Rig Mode: general-purpose animations use Humanoid; canonical skeleton animations use Generic. |
| The FBX preview is correct, but the Splat character shakes or twists | A Generic clip is targeting a different skeleton. Import Mixamo or general humanoid FBX files as Humanoid and set the Skinner Rig Mode to Humanoid. |
| The animation stops after one play | Enable Loop Time on the FBX clip and remove any transition from the state to Exit. |
| The character jumps when the loop restarts | Enable Loop Pose and Bake Into Pose for the root transform channels. |
| You are unsure whether the asset loaded | Change the Viewer Render Mode from Splats to DebugPoints. Visible points confirm that the data loaded. |
The icons in the upper-right corner of the Console show Log, Warning, and Error counts. The example below is a valid test state with `0 Error`; the Metal diagnostic warning does not block playback.
## Performance and limitations
* In scenes with multiple characters, set the Pose and Sort intervals of background characters to `2–4` and use Splat Character Lod.
* If performance is limited, begin testing with Splat Fraction `0.25–0.5` and Render Scale `0.5–0.75`.
* Splats are not affected by Unity lights, shadows, reflection probes, or lightmaps, and they do not write to the Z Buffer.
* The plugin does not support MSAA. Use Metal, DirectX 12, or Vulkan.
Before finishing, confirm that the Console has no red errors, your character is visible, the animation uses the correct Rig Mode, and the scene is saved outside Play mode.
# Create Character (from Image)
Source: https://docs.viggle.ai/v1/api-reference/characters/create
openapi.yaml POST /v1/characters
Create a reusable Character from an image, optionally extracting a 3D vsplat.
Creates a reusable Character asset. The request is asynchronous; poll the Character until `status` is `ready` before using its ID in a Render.
`type` selects what gets produced from the source image: `render` (default) creates only the render-ready 2D Character, `vsplat` creates only the 3D Gaussian splat, and `all` produces both and is billed for both. Once the vsplat extraction is `ready`, fetch its download URL from [Export 3D Character (for 3D/game engines)](/v1/api-reference/characters/export).
**Pricing** (1 credit = \$0.01): `type=render` costs **1 credit** flat. `type=vsplat` costs **30 credits** flat by default, or **25 credits** flat with `enhance=false` — enhance only affects the vsplat extraction and replaces the 25-credit price rather than adding to it. `type=all` is billed for both: **31 credits**, or **26 credits** with `enhance=false`. See [Pricing and retention](/v1/pricing).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `image` | file | One of | — | Character source image uploaded directly. PNG, JPEG, and WebP are accepted; supply exactly one source field. |
| `image_url` | string | One of | — | Publicly reachable HTTP(S) URL of the character source image. Supply this instead of `image` and keep it accessible while ingestion starts. |
| `ref_url` | string | One of | — | Backward-compatible alias for `image_url`. Do not send both URL fields. |
| `name` | string | No | `""` | Optional display label returned by detail and list operations. It helps identify the Character but does not affect generation. |
| `model` | string | No | Service default | 2D preprocessing model used by `type=render` or `all`: `V3`, `v3`, `V3_Preview`, `V4`, `v4`, `V4_Preview`, or `V4_BaseSG`. It does not select the 3D extraction model. |
| `type` | string | No | `render` | Outputs to create: `render` produces the reusable 2D Character (**1 credit**), `vsplat` produces only the 3D Gaussian-splat asset (**30 credits**, or **25** with `enhance=false`), and `all` produces both and is billed for both (**31 or 26 credits**). |
| `enhance` | boolean | No | `true` | Whether to run the higher-fidelity vsplat extraction pass. Only takes effect when `type` is `vsplat` or `all` — it has no effect and isn't billed when `type=render`. Enabled by default at **30 credits flat**; set to `false` for the cheaper standard extraction at **25 credits**. |
Provide exactly one of `image`, `image_url`, or `ref_url`. Prefer `image_url` in new integrations.
The following fields apply only when `type` is `vsplat` or `all`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `variant` | string | No | `optimized` | VSPLAT encoding variant: `optimized` or `original`. `quality` applies only to `optimized`. |
| `model_precision` | number | No | — | Optional model-level extraction precision in `(0, 1]`. Applies upstream, at the pkl extraction stage, before encoding. |
| `quality` | number | No | `0.70` | Target fraction of Gaussian splats retained after pruning, in `(0, 1]`. |
| `filter_low_quality` | boolean | No | `true` | Master switch for importance-ranked Gaussian pruning. `prune_preset` and `prune_budget` are used only when enabled. |
| `prune_preset` | string | No | `balanced` | Gaussian-splat pruning strategy: `geometric`, `draft`, `balanced`, `quality`, `compact`, or `compact_hq`. |
| `prune_budget` | number | No | — | Optional maximum Gaussian count used as an extract-time pruning budget; must be greater than 0. |
| `joint_set` | string | No | `body` | Rig the vsplat is skinned to: `full`, `expression`, or `body`. Doesn't affect price. |
| `use_sparse_W` | boolean | No | `false` | Store skinning weights as a sparse matrix in the extracted asset. |
| `scale_parts` | JSON string | No | — | JSON object of body-part or bone scale factors, for example `{"legs":1.1}`. Requires `joint_set=full`. |
| `render_thumbnail` | boolean | No | `false` | Whether to generate the standard Character preview thumbnail with the vsplat extraction. The service selects the render source and pose internally. |
| `task_id` | string | No | — | Caller-generated idempotency ID for `type=vsplat`. Use a unique, stable value when retrying the same work; for `type=all`, the service keys the companion extraction with the created Character ID. |
**`joint_set` rigs.** `full` keeps all 441 joints, including the complete facial rig, so the Character can play back facial-expression animation. `expression` keeps a 119-joint body-plus-facial-expression rig. `body` keeps the 86-joint body-only rig, dropping every facial joint — the Character can't animate its face at all. Reducing the joint set only changes the joint list and the columns of the skinning matrix `W` — the splat count itself is untouched, so a `body` character still has a full-detail face, it just can't animate it. If you omit `joint_set`, the V1 API resolves it to `body` (the extraction service's own internal default is `full`, but V1 always fills in `body` first). This parameter belongs only to Character extraction; Motion creation has no `joint_set` field.
Motion export is a separate choice: `metahuman_url` carries the 441-joint rig and `metahuman_body_url` the 86-joint body rig. The API does not currently return a dedicated 119-joint Motion export for `expression`. `mixamo_url` is a generic 50-joint rig intended for retargeting onto another character mesh. See [Export 3D Motion](/v1/api-reference/motions/export).
This is the complete public request contract. Workflow storage paths, output
URIs, named multiview inputs, body-shape prediction, and thumbnail rendering
details are selected or generated by the service.
## Response parameters
Returns `200 OK` with a Character object.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public Character ID, for example `char_550e8400-e29b-41d4-a716-446655440000`. Store it exactly as returned. |
| `status` | string | Yes | Initial status is normally `queued`. Terminal values are `ready` and `failed`. On an `all` Character, `status` only reaches `ready` once both the 2D render and the vsplat extraction have; if either fails, `status` is `failed` while `vsplat.status` shows which one it was. |
| `name` | string | Yes | The supplied label, or an empty string. |
| `progress` | integer or null | Yes | Progress from 0 to 100 when available. |
| `capabilities` | string\[] | Yes | Empty until ready; ready Characters include `video_render`. |
| `created_at` | string or null | Yes | ISO 8601 timestamp with a UTC offset, for example `2026-07-31T09:15:22+00:00`. |
| `completed_at` | string or null | Yes | ISO 8601 completion timestamp; `null` before completion. |
| `error` | object or null | Yes | Structured error when `status` is `failed`; otherwise `null`. |
| `type` | string | Yes | Echoes the requested `type`: `render`, `vsplat`, or `all`. |
| `vsplat` | object or null | Yes | `null` for a `render`-only Character. Otherwise `{status, error}` — the vsplat extraction's own status. Never carries a download URL; use [Export 3D Character (for 3D/game engines)](/v1/api-reference/characters/export) for that. |
| `glb` | null | Yes | Always `null` for a Character. |
```json theme={null}
{
"id": "char_550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"name": "Presenter",
"progress": 0,
"capabilities": [],
"created_at": "2026-07-31T09:15:22+00:00",
"completed_at": null,
"error": null,
"type": "render",
"vsplat": null,
"glb": null
}
```
## Examples
```go Go theme={null}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("image", "character.png")
file, _ := os.Open("character.png")
defer file.Close()
io.Copy(part, file)
writer.WriteField("name", "Presenter")
writer.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/characters", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY"))
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("image", imageFile);
form.append("name", "Presenter");
const response = await fetch("https://apis.viggle.ai/v1/characters", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const character = await response.json();
```
```python Python theme={null}
import os
import requests
with open("character.png", "rb") as image:
response = requests.post(
"https://apis.viggle.ai/v1/characters",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
files={"image": image},
data={"name": "Presenter"},
)
response.raise_for_status()
character = response.json()
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image=@character.png" \
-F "name=Presenter"
```
To also extract a 3D vsplat, add `-F "type=vsplat"` (or `all` to keep the render too):
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image=@character.png" \
-F "type=all" \
-F "model_precision=0.8" \
-F "prune_preset=quality" \
-F "render_thumbnail=true"
```
The vsplat extraction runs the higher-fidelity `enhance` pass by default (30 credits flat). To use the cheaper standard extraction instead, add `-F "enhance=false"` alongside `type=vsplat` or `type=all` (25 credits flat):
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image=@character.png" \
-F "type=vsplat" \
-F "enhance=false"
```
The default `joint_set` is `body`. To keep facial-expression joints (or the full 441-joint rig), add `-F "joint_set=expression"` (or `full`):
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image=@character.png" \
-F "type=vsplat" \
-F "joint_set=expression"
```
## Next step
Use [Get Character](/v1/api-reference/characters/get) to poll the returned ID until it is ready, then [Export 3D Character (for 3D/game engines)](/v1/api-reference/characters/export) to download the vsplat if requested.
# Delete Character
Source: https://docs.viggle.ai/v1/api-reference/characters/delete
openapi.yaml DELETE /v1/characters/{character_id}
Remove a Character from the current account.
Deletes a Character owned by the current account. The endpoint returns `404` when the Character does not exist or belongs to another account — the two cases are deliberately indistinguishable.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `character_id` | string | Yes | Complete public Character ID returned by Create or List Characters, normally beginning with `char_`. It must identify a Character owned by the caller. |
## Response parameters
Returns `200 OK`.
| Field | Type | Required | Description |
| - | - | :-: | - |
| `status` | string | Yes | Always `deleted` after a successful deletion. |
## Examples
```go Go theme={null}
req, _ := http.NewRequest("DELETE", "https://apis.viggle.ai/v1/characters/"+characterID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, err := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const response = await fetch(`https://apis.viggle.ai/v1/characters/${characterId}`, {method:"DELETE", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}});
```
```python Python theme={null}
response = requests.delete(f"https://apis.viggle.ai/v1/characters/{character_id}", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
```
```bash cURL theme={null}
curl -X DELETE "https://apis.viggle.ai/v1/characters/$CHARACTER_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
# Export 3D Character (for 3D/game engines)
Source: https://docs.viggle.ai/v1/api-reference/characters/export
openapi.yaml GET /v1/characters/{character_id}/export
Get a signed download URL for a Character's vsplat extraction.
Only meaningful for a Character created with `type` `vsplat` or `all`; a `render`-only Character has nothing to export. This replaces the old, now-removed `GET /v1/avatars/{avatar_id}`.
`vsplat_url` and `thumbnail_url` are populated on different conditions, not both gated by `download_type`:
* `vsplat_url` is populated only when the request carries `download_type=vsplat`, and is re-signed on every call.
* `thumbnail_url` is unconditional — it's a default output of the extraction, populated whenever one was rendered (that is, the Character was created with `render_thumbnail=true`; see [Create Character](/v1/api-reference/characters/create)), independent of `download_type` and even if it's omitted entirely. It's not something you opt into via a request parameter.
There is no `glb` output for a Character export. `download_type` only ever accepts `vsplat` — `glb` is a Motion-only extraction; see [Export 3D Motion (for 3D/game engines)](/v1/api-reference/motions/export) for that.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `character_id` | string | Yes | Complete public Character ID returned by Create or List Characters. The Character must have been created with `type=vsplat` or `all` to expose an export. |
| `download_type` | string | No | Selects the vsplat download URL: only `vsplat` is accepted. Any other value — including the old `thumbnail`, or `glb` (a Motion-only extraction) — is rejected with `400`. Omit to retrieve status (and `thumbnail_url`, if one exists) only. |
## Response parameters
Returns `200 OK`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Requested public Character ID. |
| `status` | string | Yes | `queued`, `processing`, `ready`, or `failed`. |
| `download_type` | string or null | Yes | Echoes the requested `download_type` (always `vsplat` or `null`; never `thumbnail`). |
| `vsplat_url` | string or null | Yes | Populated only when `download_type=vsplat` and the extraction is ready. |
| `thumbnail_url` | string or null | Yes | Populated whenever the extraction rendered a thumbnail and it's ready — regardless of `download_type`. `null` if the Character was created with `render_thumbnail=false` (the default) or the thumbnail isn't ready yet. |
| `created_at` | string or null | Yes | ISO 8601 timestamp with a UTC offset. May be an empty string on a task that has not recorded one. |
| `updated_at` | string or null | Yes | ISO 8601 timestamp of the most recent update. |
| `error` | object or null | Yes | Structured failure details when `status` is `failed`. |
Requested with `download_type=vsplat` on a Character that was also created with `render_thumbnail=true` — both URLs come back together:
```json theme={null}
{
"id": "char_550e8400-e29b-41d4-a716-446655440000",
"status": "ready",
"download_type": "vsplat",
"vsplat_url": "https://assets.viggle.ai/results/character.vsplat",
"thumbnail_url": "https://assets.viggle.ai/results/character_thumb.png",
"created_at": "2026-07-31T09:15:22+00:00",
"updated_at": "2026-07-31T09:16:40+00:00",
"error": null
}
```
The same Character requested with no `download_type` at all — `vsplat_url` withholds, `thumbnail_url` still doesn't:
```json theme={null}
{
"id": "char_550e8400-e29b-41d4-a716-446655440000",
"status": "ready",
"download_type": null,
"vsplat_url": null,
"thumbnail_url": "https://assets.viggle.ai/results/character_thumb.png",
"created_at": "2026-07-31T09:15:22+00:00",
"updated_at": "2026-07-31T09:16:40+00:00",
"error": null
}
```
## Examples
```go Go theme={null}
req,_:=http.NewRequest("GET","https://apis.viggle.ai/v1/characters/"+characterID+"/export?download_type=vsplat",nil); req.Header.Set("Authorization","Bearer "+os.Getenv("VIGGLE_API_KEY")); resp,err:=http.DefaultClient.Do(req); if err!=nil {panic(err)}; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response=await fetch(`https://apis.viggle.ai/v1/characters/${characterId}/export?download_type=vsplat`,{headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const exported=await response.json();
```
```python Python theme={null}
import os, requests
response=requests.get(f"https://apis.viggle.ai/v1/characters/{character_id}/export",params={"download_type":"vsplat"},headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); exported=response.json()
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/characters/$CHARACTER_ID/export?download_type=vsplat" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
Download URLs are short-lived and re-signed on every read — request `download_type` again rather than caching the URL.
## Next step
The 2D render side of this same Character (`capabilities: ["video_render"]`) is independent of the vsplat export above — use [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create) with `character_id` once it's ready.
# Get Character
Source: https://docs.viggle.ai/v1/api-reference/characters/get
openapi.yaml GET /v1/characters/{character_id}
Get the status and capabilities of one Character.
Poll this endpoint after creating a Character. Use the Character in a Render only after `status` is `ready` and `capabilities` includes `video_render`.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `character_id` | string | Yes | Complete public Character ID returned by Create or List Characters, for example `char_550e8400-e29b-41d4-a716-446655440000`. Poll with the same value until the requested outputs are ready or failed. |
## Response parameters
Returns `200 OK` with the Character fields documented in [Create Character](/v1/api-reference/characters/create), including `type` and `vsplat`. A failed Character still returns `200`; inspect `status` and `error`. `error` is always `null` here — read `status` to detect failure (on a `vsplat`/`all` Character, check `vsplat.error` for extraction-specific failure detail).
## Examples
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/characters/"+characterID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, err := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const response = await fetch(`https://apis.viggle.ai/v1/characters/${characterId}`, {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}});
```
```python Python theme={null}
response = requests.get(f"https://apis.viggle.ai/v1/characters/{character_id}", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/characters/$CHARACTER_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
## Next step
Once a `vsplat`/`all` Character's `vsplat.status` is `ready`, use [Export 3D Character (for 3D/game engines)](/v1/api-reference/characters/export) to get its download URL.
# List Characters
Source: https://docs.viggle.ai/v1/api-reference/characters/list
openapi.yaml GET /v1/characters
List reusable Characters available to the current account.
Returns the most recently created active Characters owned by the current account, newest first. Use the returned `id` in `POST /v1/renders` after the Character is ready.
## Request parameters
`GET /v1/characters` has no path, query, or request-body parameters. Send `Authorization: Bearer YOUR_API_KEY`.
## Response parameters
Returns `200 OK`. The page is capped at 100 items and carries no cursor — there is no way to reach older Characters through this endpoint.
| Field | Type | Required | Description |
| - | - | :-: | - |
| `data` | array | Yes | Array of Character objects, newest first. It can be empty. |
| `data[].id` | string | Yes | Public `char_...` ID. |
| `data[].status` | string | Yes | `queued`, `processing`, `ready`, or `failed`. |
| `data[].name` | string | Yes | Character label, or an empty string. |
| `data[].progress` | null | Yes | Always `null` in list responses. |
| `data[].capabilities` | string\[] | Yes | `video_render` once ready. |
| `data[].type` | string | Yes | `render`, `vsplat`, or `all`. |
| `data[].vsplat` | object or null | Yes | `null` unless `type` requested a vsplat extraction. |
| `data[].created_at` / `completed_at` | string or null | Yes | ISO 8601 timestamps. |
| `data[].error` | object or null | Yes | Always `null`, even for a `failed` Character — read `status` to detect failure. |
```json theme={null}
{
"data": [
{
"id": "char_550e8400-e29b-41d4-a716-446655440000",
"status": "ready",
"name": "Presenter",
"progress": null,
"capabilities": ["video_render"],
"created_at": "2026-07-31T09:15:22+00:00",
"completed_at": "2026-07-31T09:16:05+00:00",
"error": null,
"type": "render",
"vsplat": null,
"glb": null
}
]
}
```
## Examples
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/characters", nil); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/characters", {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const result = await response.json();
```
```python Python theme={null}
import os, requests
response = requests.get("https://apis.viggle.ai/v1/characters", headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); result = response.json()
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/characters" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
# Code examples
Source: https://docs.viggle.ai/v1/api-reference/code-examples
Go, JavaScript, Python, and cURL examples for every V1 endpoint.
Set `VIGGLE_API_KEY` before using these examples. Replace all sample IDs with the complete IDs returned by your account.
## Credits — `GET /v1/credits`
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/credits", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch("https://apis.viggle.ai/v1/credits", {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}});
```
```python Python theme={null}
resp = requests.get("https://apis.viggle.ai/v1/credits", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/credits" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
## Create Character — `POST /v1/characters`
```go Go theme={null}
body := &bytes.Buffer{}; w := multipart.NewWriter(body); f, _ := w.CreateFormFile("image", "character.png"); file, _ := os.Open("character.png"); io.Copy(f, file); w.WriteField("name", "Presenter"); w.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/characters", body); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", w.FormDataContentType()); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const form = new FormData(); form.append("image", imageFile); form.append("name", "Presenter");
const resp = await fetch("https://apis.viggle.ai/v1/characters", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}, body:form});
```
```python Python theme={null}
with open("character.png", "rb") as image:
resp = requests.post("https://apis.viggle.ai/v1/characters", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}, files={"image": image}, data={"name": "Presenter"})
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" -H "Authorization: Bearer $VIGGLE_API_KEY" -F "image=@character.png" -F "name=Presenter"
```
Add `-F "type=vsplat"` to also extract a 3D vsplat (`all` keeps the render too):
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" -H "Authorization: Bearer $VIGGLE_API_KEY" -F "image=@character.png" -F "type=vsplat"
```
## List, get, delete, and export Characters
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/characters/char_550e8400-e29b-41d4-a716-446655440000", nil); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`https://apis.viggle.ai/v1/characters/${characterId}`, {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}});
```
```python Python theme={null}
resp = requests.get(f"https://apis.viggle.ai/v1/characters/{character_id}", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/characters" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl "https://apis.viggle.ai/v1/characters/char_550e8400-e29b-41d4-a716-446655440000" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl -X DELETE "https://apis.viggle.ai/v1/characters/char_550e8400-e29b-41d4-a716-446655440000" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl "https://apis.viggle.ai/v1/characters/char_550e8400-e29b-41d4-a716-446655440000/export?download_type=vsplat" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
For list, use `GET /v1/characters`; for deletion, change the request method to `DELETE`. See [Characters](/v1/api-reference/characters) for every request and response field.
## Create Motion (from Video) — `POST /v1/motions`
```go Go theme={null}
body := &bytes.Buffer{}; w := multipart.NewWriter(body); f, _ := w.CreateFormFile("motion_video", "dance.mp4"); file, _ := os.Open("dance.mp4"); io.Copy(f, file); w.WriteField("name", "Dance"); w.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/motions", body); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", w.FormDataContentType()); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const form = new FormData(); form.append("motion_video", videoFile); form.append("name", "Dance");
const resp = await fetch("https://apis.viggle.ai/v1/motions", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}, body:form});
```
```python Python theme={null}
with open("dance.mp4", "rb") as video:
resp = requests.post("https://apis.viggle.ai/v1/motions", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}, files={"motion_video": video}, data={"name": "Dance"})
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions" -H "Authorization: Bearer $VIGGLE_API_KEY" -F "motion_video=@dance.mp4" -F "name=Dance"
```
Add `-F "type=glb"` to also extract a 3D animation.
## Create Motion (from Text) — `POST /v1/motions`
The endpoint is the same, but text-to-motion uses `application/json` instead of multipart form data.
```javascript JavaScript theme={null}
const resp = await fetch("https://apis.viggle.ai/v1/motions", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`, "Content-Type":"application/json"}, body:JSON.stringify({text:"a person doing a cartwheel", duration_seconds:5})});
```
```python Python theme={null}
resp = requests.post("https://apis.viggle.ai/v1/motions", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}, json={"text":"a person doing a cartwheel", "duration_seconds":5})
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions" -H "Authorization: Bearer $VIGGLE_API_KEY" -H "Content-Type: application/json" -d '{"text":"a person doing a cartwheel","duration_seconds":5}'
```
## Import Motion — `POST /v1/motions/import`
```go Go theme={null}
body := strings.NewReader(`{"template_id":"YOUR_TEMPLATE_ID","name":"Campaign dance"}`); req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/motions/import", body); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", "application/json"); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch("https://apis.viggle.ai/v1/motions/import", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`, "Content-Type":"application/json"}, body:JSON.stringify({template_id:"YOUR_TEMPLATE_ID", name:"Campaign dance"})});
```
```python Python theme={null}
resp = requests.post("https://apis.viggle.ai/v1/motions/import", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}, json={"template_id":"YOUR_TEMPLATE_ID", "name":"Campaign dance"})
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions/import" -H "Authorization: Bearer $VIGGLE_API_KEY" -H "Content-Type: application/json" -d '{"template_id":"YOUR_TEMPLATE_ID","name":"Campaign dance"}'
```
## List, get, delete, and export Motions
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/motions/mot_550e8400-e29b-41d4-a716-446655440000", nil); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`https://apis.viggle.ai/v1/motions/${motionId}`, {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}});
```
```python Python theme={null}
resp = requests.get(f"https://apis.viggle.ai/v1/motions/{motion_id}", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/motions" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl "https://apis.viggle.ai/v1/motions/mot_550e8400-e29b-41d4-a716-446655440000" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl -X DELETE "https://apis.viggle.ai/v1/motions/mot_550e8400-e29b-41d4-a716-446655440000" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl "https://apis.viggle.ai/v1/motions/mot_550e8400-e29b-41d4-a716-446655440000/export?download_type=glb" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
## Create, list, get, and download a Render
```go Go theme={null}
body := &bytes.Buffer{}; w := multipart.NewWriter(body); w.WriteField("character_id", "char_550e8400-e29b-41d4-a716-446655440000"); w.WriteField("motion_id", "mot_550e8400-e29b-41d4-a716-446655440000"); w.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/renders", body); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", w.FormDataContentType()); resp, _ := http.DefaultClient.Do(req)
```
```javascript JavaScript theme={null}
const form = new FormData(); form.append("character_id", characterId); form.append("motion_id", motionId);
const resp = await fetch("https://apis.viggle.ai/v1/renders", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}, body:form});
```
```python Python theme={null}
resp = requests.post("https://apis.viggle.ai/v1/renders", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}, data={"character_id": character_id, "motion_id": motion_id})
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders" -H "Authorization: Bearer $VIGGLE_API_KEY" -F "character_id=char_550e8400-e29b-41d4-a716-446655440000" -F "motion_id=mot_550e8400-e29b-41d4-a716-446655440000"
curl "https://apis.viggle.ai/v1/videos?status=ready&limit=20" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl "https://apis.viggle.ai/v1/videos/render_8f50b2c2c2b84ae0" -H "Authorization: Bearer $VIGGLE_API_KEY"
curl -L "https://apis.viggle.ai/v1/renders/render_8f50b2c2c2b84ae0/download" -H "Authorization: Bearer $VIGGLE_API_KEY" -o output.mp4
```
Listing and status checks go through `GET /v1/videos` / `GET /v1/videos/{video_id}` — see [List Videos](/v1/api-reference/videos/list) and [Get Video](/v1/api-reference/videos/get). The equivalent `GET /v1/renders` and `GET /v1/renders/{render_id}` are retired (`405`).
There is no cancel endpoint. See [Renders](/v1/api-reference/renders) for parameter combinations and response fields.
## Create a Render from a prepared draft
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders/prepare" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"character":{"kind":"asset","asset_id":"char_550e8400-e29b-41d4-a716-446655440000"},"motion":{"kind":"asset","asset_id":"mot_550e8400-e29b-41d4-a716-446655440000"}}'
curl -X POST "https://apis.viggle.ai/v1/renders" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"draft_id":"draft_abc123"}'
```
```python Python theme={null}
import os, uuid, requests
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
draft = requests.post(
"https://apis.viggle.ai/v1/renders/prepare",
headers=headers,
json={
"character": {"kind": "asset", "asset_id": "char_550e8400-e29b-41d4-a716-446655440000"},
"motion": {"kind": "asset", "asset_id": "mot_550e8400-e29b-41d4-a716-446655440000"},
},
).json()
render = requests.post(
"https://apis.viggle.ai/v1/renders",
headers={**headers, "Idempotency-Key": str(uuid.uuid4())},
json={"draft_id": draft["draft_id"]},
)
render.raise_for_status()
```
When either source is `kind: "upload"`, `PUT` the bytes to each `uploads[].url` from the prepare response before calling `POST /v1/renders`. See [Prepare Render](/v1/api-reference/renders/prepare) for the full flow.
## Watch a Render over Server-Sent Events
```bash cURL theme={null}
curl -N "https://apis.viggle.ai/v1/renders/render_8f50b2c2c2b84ae0/events" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Accept: text/event-stream"
```
See [Watch Render](/v1/api-reference/renders/events) for the event vocabulary and reconnect semantics.
## Generate a Video — `POST /v1/videos`
Text-to-video, first-frame-to-video, first+last-frame-to-video, reference-video/image generation, and character-animation generation all share this endpoint — which mode runs depends on which fields you send.
```bash cURL (text-to-video) theme={null}
curl "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="a paper airplane gliding through a sunlit office" \
-F quality=low \
-F duration_s=5
```
```bash cURL (first-frame-to-video) theme={null}
curl "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera slowly zooms out" \
-F quality=high \
-F first_frame_image_url=https://example.test/first.png
```
```bash cURL (first+last-frame-to-video) theme={null}
curl "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera pans across the room" \
-F quality=low \
-F first_frame_image_url=https://example.test/first.png \
-F last_frame_image_url=https://example.test/last.png
```
```bash cURL (reference-video/image) theme={null}
curl "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="keep the same outfit and walk forward" \
-F quality=high \
-F reference_video_url=https://example.test/reference.mp4 \
-F reference_image_url=https://example.test/ref1.png
```
```bash cURL (character-animation) theme={null}
curl "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F driving_video_url=https://example.test/driving.mp4 \
-F character_image_url=https://example.test/char1.png
```
```python Python theme={null}
import os, requests
resp = requests.post(
"https://apis.viggle.ai/v1/videos",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
data={"prompt": "camera slowly zooms out", "quality": "high"},
files={"first_frame_image": open("first.png", "rb")},
)
resp.raise_for_status()
video = resp.json()
```
All five modes answer `200` with the same `{id, status, progress, created_at}` acceptance shape. See [Generate Video (from Text)](/v1/api-reference/videos/create-from-text), [Generate Video (from First Frame and/or Text)](/v1/api-reference/videos/create-from-first-frame), [Generate Video (from First-Last Frames and/or Text)](/v1/api-reference/videos/create-from-first-last-frame), [Generate Video (from Reference Video/Image)](/v1/api-reference/videos/create-from-reference-video), and [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation) for the full field reference and common validation errors.
## Endpoint-by-endpoint request examples
The following compact examples show the exact HTTP method, path, and request shape for every endpoint. In the Go snippets, `client` is an `http.Client`; in JavaScript, `apiKey` is your API key; in Python, `headers` is `{"Authorization": "Bearer ..."}`.
### `GET /v1/characters`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/characters", nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/characters`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/characters", headers=headers)
```
```bash cURL theme={null}
curl "$BASE/characters" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/characters/{character_id}`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/characters/"+characterID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/characters/${characterId}`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/characters/{character_id}", headers=headers)
```
```bash cURL theme={null}
curl "$BASE/characters/$CHARACTER_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `DELETE /v1/characters/{character_id}`
```go Go theme={null}
req, _ := http.NewRequest("DELETE", base+"/characters/"+characterID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/characters/${characterId}`, {method:"DELETE", headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.delete(f"{base}/characters/{character_id}", headers=headers)
```
```bash cURL theme={null}
curl -X DELETE "$BASE/characters/$CHARACTER_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/characters/{character_id}/export`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/characters/"+characterID+"/export?download_type=vsplat", nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/characters/${characterId}/export?download_type=vsplat`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/characters/{character_id}/export", params={"download_type": "vsplat"}, headers=headers)
```
```bash cURL theme={null}
curl "$BASE/characters/$CHARACTER_ID/export?download_type=vsplat" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/motions`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/motions", nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/motions`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/motions", headers=headers)
```
```bash cURL theme={null}
curl "$BASE/motions" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/motions/{motion_id}`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/motions/"+motionID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/motions/${motionId}`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/motions/{motion_id}", headers=headers)
```
```bash cURL theme={null}
curl "$BASE/motions/$MOTION_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `DELETE /v1/motions/{motion_id}`
```go Go theme={null}
req, _ := http.NewRequest("DELETE", base+"/motions/"+motionID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/motions/${motionId}`, {method:"DELETE", headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.delete(f"{base}/motions/{motion_id}", headers=headers)
```
```bash cURL theme={null}
curl -X DELETE "$BASE/motions/$MOTION_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/motions/{motion_id}/export`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/motions/"+motionID+"/export?download_type=glb", nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/motions/${motionId}/export?download_type=glb`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/motions/{motion_id}/export", params={"download_type": "glb"}, headers=headers)
```
```bash cURL theme={null}
curl "$BASE/motions/$MOTION_ID/export?download_type=glb" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/videos`
Replaces the retired `GET /v1/renders` (now `405 Method Not Allowed`). Lists Renders, H3 generations, and character-animation generations merged into one feed.
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/videos?status=ready&limit=20", nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/videos?status=ready&limit=20`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/videos", params={"status": "ready", "limit": 20}, headers=headers)
```
```bash cURL theme={null}
curl "$BASE/videos?status=ready&limit=20" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/videos/{video_id}`
Replaces the retired `GET /v1/renders/{render_id}` (now `405 Method Not Allowed`). Accepts a `render_` or `vid_` prefixed ID.
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/videos/"+videoID, nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/videos/${videoId}`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/videos/{video_id}", headers=headers)
```
```bash cURL theme={null}
curl "$BASE/videos/$VIDEO_ID" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
### `GET /v1/renders/{render_id}/events`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/renders/"+renderID+"/events", nil); req.Header.Set("Authorization", "Bearer "+apiKey); req.Header.Set("Accept", "text/event-stream"); resp, _ := client.Do(req)
```
```bash cURL theme={null}
curl -N "$BASE/renders/$RENDER_ID/events" -H "Authorization: Bearer $VIGGLE_API_KEY" -H "Accept: text/event-stream"
```
### `GET /v1/renders/{render_id}/download`
```go Go theme={null}
req, _ := http.NewRequest("GET", base+"/renders/"+renderID+"/download", nil); req.Header.Set("Authorization", "Bearer "+apiKey); resp, _ := client.Do(req)
```
```javascript JavaScript theme={null}
const resp = await fetch(`${base}/renders/${renderId}/download`, {headers:{Authorization:`Bearer ${apiKey}`}});
```
```python Python theme={null}
resp = requests.get(f"{base}/renders/{render_id}/download", headers=headers, allow_redirects=True)
```
```bash cURL theme={null}
curl -L "$BASE/renders/$RENDER_ID/download" -H "Authorization: Bearer $VIGGLE_API_KEY" -o output.mp4
```
# Credits
Source: https://docs.viggle.ai/v1/api-reference/credits
openapi.yaml GET /v1/credits
Read the current account credit balance.
## Get credit balance
`GET /v1/credits`
Returns the current balance for the API key's account or organization. This endpoint has no parameters and requires `Authorization: Bearer YOUR_API_KEY`.
```bash theme={null}
curl "https://apis.viggle.ai/v1/credits" \
-H "Authorization: Bearer $VIGGLE_API_KEY"
```
```json theme={null}
{
"balance": 124.5
}
```
| Field | Type | Description |
| - | - | - |
| `balance` | number | Credits currently available to the caller. |
A missing or expired API key returns `401` with the standard `ErrorBody` — see [Errors and recovery](/v1/production/errors).
# Generate Image
Source: https://docs.viggle.ai/v1/api-reference/images/create
openapi.yaml POST /v1/images
Generate 1–6 PNG images from a text prompt, optionally guided by up to 8 reference images.
Send `multipart/form-data` with a `prompt` and, optionally, reference images. The request is asynchronous: it answers immediately with a `queued` [Image](/v1/api-reference/images/get) object whose ID starts with `img_`, and you poll [Get Image](/v1/api-reference/images/get) until `status` is `ready` or `failed`. Without reference images this is text-to-image; with at least one, the model is guided by them.
**Pricing** (1 credit = $0.01): **1 credit ($0.01) per image\*\* at `1K`, **2 credits (\$0.02) per image** at `2K`. A job costs `n` × the per-image price — for example, `2K` with `n=3` is 6 credits. The whole amount is reserved when the job is created and refunded in full if it fails. `aspect_ratio`, `seed`, and reference images don't affect price. See [Pricing and retention](/v1/pricing#viggle-image).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `prompt` | string | Yes | — | Text description of the images. Up to 4000 characters, counted as Unicode code points (one CJK character counts as one). Must not be blank. |
| `model` | string | No | `qwen` | Image model. Only `qwen` is available. |
| `aspect_ratio` | string | No | `1:1` | `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, or `2:3`. Together with `resolution`, selects the exact output size — see [Output sizes](#output-sizes). |
| `resolution` | string | No | `1K` | `1K` or `2K`, case-insensitive. Sets the output size tier and the per-image price: 1 credit at `1K`, 2 credits at `2K`. |
| `n` | integer | No | `1` | Number of images to generate, 1–6. |
| `seed` | integer | No | random | 0–2147483647. Image `i` (counting from 0) uses `(seed + i) mod 2147483648`. Omitted, the server picks a random seed. Every image reports the seed it actually used. |
| `reference_image` | file, repeatable | No | — | Reference image upload, up to 24 MiB each. Repeat the field to send more than one. |
| `reference_image_url` | string, repeatable | No | — | Publicly reachable `http(s)` image URL, under the same limits as `reference_image`. Repeat the field to send more than one. |
Up to **8 reference images** in total, as any mix of `reference_image` and `reference_image_url`. The model receives the uploaded files first, then the URLs, each in the order you sent them. Supplying any reference image moves `2K` to a smaller size — see the next section.
### Reference image limits
* Up to 24 MiB each, with a long-to-short side ratio of at most 8.
* JPEG, PNG, WebP, GIF, BMP, TIFF, or HEIC.
* An image with a long side of 3840 px or more, or a short side of 2160 px or more, is first downscaled, keeping its aspect ratio, to fit within 1920 × 1080 (1080 × 1920 for a portrait image).
* A `reference_image_url` must be publicly reachable; it is downloaded while the create request is being processed.
## Output sizes
`aspect_ratio` and `resolution` together select one of the model's fixed sizes — you get exactly the size listed. Width × height, in pixels:
| `aspect_ratio` | `1K` | `2K` (text only) | `2K` (with reference images) |
| - | - | - | - |
| `1:1` | 1024 × 1024 | 2048 × 2048 | 1536 × 1536 |
| `16:9` | 1376 × 768 | 2720 × 1536 | 2048 × 1152 |
| `9:16` | 768 × 1376 | 1536 × 2720 | 1152 × 2048 |
| `4:3` | 1184 × 896 | 2368 × 1760 | 1760 × 1344 |
| `3:4` | 896 × 1184 | 1760 × 2368 | 1344 × 1760 |
| `3:2` | 1248 × 832 | 2496 × 1664 | 1888 × 1248 |
| `2:3` | 832 × 1248 | 1664 × 2496 | 1248 × 1888 |
`1K` is the same with or without reference images. `2K` is about 2048² pixels of area for text-to-image and about 1536² with reference images; the price is the same either way. Each image's `width` and `height` in the response are its actual size.
## Response parameters
Returns `200 OK` with an Image object in `queued` state — the same shape [Get Image](/v1/api-reference/images/get) returns. `progress` is `0`, `images` is empty, and `completed_at` and `error` are `null`.
```json theme={null}
{
"id": "img_b16a190d-2d44-41a0-8056-ef68183c3b9e",
"status": "queued",
"model": "qwen",
"progress": 0,
"images": [],
"created_at": "2026-10-12T08:20:00Z",
"completed_at": null,
"error": null
}
```
## Common errors
| Error | Cause |
| - | - |
| `400 INVALID_REQUEST` | A parameter is invalid — `message` names which one — or the body isn't `multipart/form-data`. No job is created and no file is uploaded. |
| `401 UNAUTHENTICATED` | Missing or invalid API key. |
| `402 INSUFFICIENT_CREDITS` | The balance can't cover `n` × the per-image price; `message` gives the credits required. No generation starts. |
| `429 RATE_LIMITED` | Too many concurrent create requests on the same key. Retry later. |
| `500 INTERNAL_ERROR` | Internal server error. Retryable. |
| `503 SERVICE_BUSY` | Service temporarily unavailable. Retryable. |
HTTP errors use the standard envelope — `{"error": {"code", "message", "retryable", "request_id", "details", "remediation"}}`. See [Errors and recovery](/v1/production/errors). A job that is accepted and later fails reports its failure in the Image object's `error` field instead — see [Get Image](/v1/api-reference/images/get#status-and-errors).
Send your own `User-Agent` header. Python `urllib`'s default `User-Agent` has been observed to be blocked in front of `apis.viggle.ai`, which answers with a non-JSON error page instead of an API error.
The body must be `multipart/form-data`, even without reference images — an `application/x-www-form-urlencoded` body gets `400`. With Python `requests`, pass the fields through `files=` (as below), not `data=` alone.
## Examples
```bash cURL theme={null}
curl -X POST https://apis.viggle.ai/v1/images \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="A watercolor fox reading a book under a maple tree" \
-F aspect_ratio=16:9 \
-F resolution=1K \
-F n=2
```
```python Python theme={null}
import os, requests
fields = {"prompt": "A watercolor fox reading a book under a maple tree", "aspect_ratio": "16:9", "resolution": "1K", "n": "2"}
response = requests.post(
"https://apis.viggle.ai/v1/images",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
files={name: (None, value) for name, value in fields.items()}, # forces multipart/form-data
)
response.raise_for_status()
image = response.json()
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("prompt", "A watercolor fox reading a book under a maple tree");
form.append("aspect_ratio", "16:9");
form.append("resolution", "1K");
form.append("n", "2");
const response = await fetch("https://apis.viggle.ai/v1/images", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const image = await response.json();
```
```go Go theme={null}
body := &bytes.Buffer{}; w := multipart.NewWriter(body)
w.WriteField("prompt", "A watercolor fox reading a book under a maple tree"); w.WriteField("aspect_ratio", "16:9"); w.WriteField("resolution", "1K"); w.WriteField("n", "2"); w.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/images", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
### With reference images
Uploads and URLs can be mixed, and both fields can be repeated:
```bash cURL theme={null}
curl -X POST https://apis.viggle.ai/v1/images \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="The same fox, now in winter, snow falling" \
-F aspect_ratio=1:1 \
-F resolution=2K \
-F reference_image=@fox.png \
-F reference_image_url=https://example.com/style.jpg
```
```python Python theme={null}
import os, requests
with open("fox.png", "rb") as fox:
response = requests.post(
"https://apis.viggle.ai/v1/images",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
data={
"prompt": "The same fox, now in winter, snow falling",
"aspect_ratio": "1:1",
"resolution": "2K",
"reference_image_url": ["https://example.com/style.jpg"],
},
files=[("reference_image", ("fox.png", fox, "image/png"))],
)
response.raise_for_status()
image = response.json()
```
```javascript JavaScript theme={null}
import { openAsBlob } from "node:fs";
const form = new FormData();
form.append("prompt", "The same fox, now in winter, snow falling");
form.append("aspect_ratio", "1:1");
form.append("resolution", "2K");
form.append("reference_image", await openAsBlob("fox.png"), "fox.png");
form.append("reference_image_url", "https://example.com/style.jpg");
const response = await fetch("https://apis.viggle.ai/v1/images", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const image = await response.json();
```
### `@viggle/sdk`
Requires an `@viggle/sdk` version that includes the `images` resource.
```javascript theme={null}
import { Viggle } from "@viggle/sdk";
const viggle = new Viggle({ apiKey: process.env.VIGGLE_API_KEY! });
const image = await viggle.images.create({
prompt: "A watercolor fox reading a book under a maple tree",
aspectRatio: "16:9",
resolution: "1K",
n: 2,
});
// image.id, image.status ("queued"); poll with viggle.images.get(image.id)
```
## Next step
Use [Get Image](/v1/api-reference/images/get) with the returned `id` to poll for the result.
Follow the complete create, poll, and download flow.
Read the job's status, image URLs, sizes, and seeds.
# Get Image
Source: https://docs.viggle.ai/v1/api-reference/images/get
openapi.yaml GET /v1/images/{image_id}
Retrieve the current state of an image generation job, and its PNG download URLs once ready.
`GET /v1/images/{image_id}` returns the current state of a job created by [Generate Image](/v1/api-reference/images/create) — the same Image object the create request answered with. Poll every 2–3 seconds until `status` is `ready` or `failed`, then download each PNG from `images[].url`.
You can only read jobs created with the same API key. An unknown ID, or one created by a different key, answers `404 NOT_FOUND` — the two cases are deliberately indistinguishable.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `image_id` | string | Yes | Full image job ID returned by Generate Image, `img_` prefix. |
## Response parameters
Returns `200 OK` with an Image object.
| Field | Type | Description |
| - | - | - |
| `id` | string | Image job ID, `img_` prefix. |
| `status` | string | `queued`, `processing`, `ready`, or `failed` — see [Status and errors](#status-and-errors). |
| `model` | string | Model used; currently `qwen`. |
| `progress` | integer or null | Share of the requested images completed: `0` while `queued`, 0–99 while `processing`, `100` when `ready`, `null` when `failed`. |
| `images` | array | Always empty before `ready`. Once `ready`, all `n` images in request order. |
| `images[].url` | string or null | Signed download URL of the PNG, valid for **1 hour**. Fetch the job again for a fresh one. `null` once the file has been deleted at the end of the [retention period](#data-retention). |
| `images[].width` / `images[].height` | integer | Actual size in pixels — see [Output sizes](/v1/api-reference/images/create#output-sizes). |
| `images[].seed` | integer | Seed this image was actually generated with. |
| `created_at` | string or null | Creation time, RFC 3339. |
| `completed_at` | string or null | End time; set once the job is `ready` or `failed`. |
| `error` | object or null | Failure details, only when `status` is `failed`. |
### Ready example
Signature query parameters shortened:
```json theme={null}
{
"id": "img_b16a190d-2d44-41a0-8056-ef68183c3b9e",
"status": "ready",
"model": "qwen",
"progress": 100,
"images": [
{"url": "https://storage.googleapis.com/viggle/image-generation/b16a190d-2d44-41a0-8056-ef68183c3b9e/0.png?X-Goog-Signature=...", "width": 1376, "height": 768, "seed": 0},
{"url": "https://storage.googleapis.com/viggle/image-generation/b16a190d-2d44-41a0-8056-ef68183c3b9e/1.png?X-Goog-Signature=...", "width": 1376, "height": 768, "seed": 1}
],
"created_at": "2026-10-12T08:20:00Z",
"completed_at": "2026-10-12T08:20:22Z",
"error": null
}
```
### Failed example
```json theme={null}
{
"id": "img_b16a190d-2d44-41a0-8056-ef68183c3b9e",
"status": "failed",
"model": "qwen",
"progress": null,
"images": [],
"created_at": "2026-10-12T08:20:00Z",
"completed_at": "2026-10-12T08:35:00Z",
"error": {
"code": "SERVICE_BUSY",
"message": "The image service is busy; try again later.",
"retryable": true,
"request_id": null,
"details": {},
"remediation": { "action": "retry", "retry_after_ms": null }
}
}
```
## Status and errors
A job only moves forward: `queued` → `processing` → `ready` or `failed`. It is never `cancelled`. A job either produces all `n` images or fails as a whole — it never returns a partial set.
| `status` | Meaning |
| - | - |
| `queued` | Accepted and waiting for a GPU. At peak times a job can stay here for a while. |
| `processing` | Generating, one image at a time; `progress` rises as each image finishes. |
| `ready` | All images are done and `images` is filled in. |
| `failed` | Failed; see `error`. |
`error.code` on a failed job:
| `code` | `retryable` | When | What to do |
| - | :-: | - | - |
| `SERVICE_BUSY` | `true` | The generation service is busy or temporarily unavailable, or the job didn't finish within 15 minutes. | Create a new job with the same parameters later. |
| `TASK_FAILED` | `true` | Any other generation or upload failure. | Create a new job. If it keeps happening, contact us with the job `id`. |
| `INVALID_REQUEST` | `false` | The model rejected the request — for example, a reference image it can't use. | Change the parameters or reference images, then try again. |
A failed job is refunded in full, even if some images had already been generated before it failed. Retrying means creating a new job, which reserves its own credits.
HTTP errors from this endpoint:
| HTTP | `code` | Meaning |
| - | - | - |
| `401` | `UNAUTHENTICATED` | Missing or invalid API key. |
| `404` | `NOT_FOUND` | The job doesn't exist, or belongs to a different key. |
| `429` | `RATE_LIMITED` | Too many requests; retry later. |
| `500` | `INTERNAL_ERROR` | Internal server error. Retryable. |
| `503` | `SERVICE_BUSY` | Service temporarily unavailable. Retryable. |
These use the same error object as a failed job's `error`, wrapped as `{"error": {...}}`. See [Errors and recovery](/v1/production/errors).
## Timing
Measured in production on 2026-10-12: two `1K` images took about 17–22 seconds; one `2K` image with a reference image took about 37 seconds. Images are generated one at a time, so a larger `n` takes longer, and at peak times a job first waits in `queued`. A job that hasn't finished within 15 minutes fails with `SERVICE_BUSY`.
## Data retention
* Generated images are deleted **24 hours after the job finishes** — download them before then. Afterwards this endpoint still returns the job, still `ready`, but every `images[].url` is `null`.
* Reference images you upload, or that Viggle downloads from a `reference_image_url`, are deleted within 24 hours.
* Customer data is never used to train models.
## Examples
```bash cURL theme={null}
curl https://apis.viggle.ai/v1/images/img_b16a190d-2d44-41a0-8056-ef68183c3b9e \
-H "Authorization: Bearer $VIGGLE_API_KEY"
```
```python Python theme={null}
import os, requests
response = requests.get(
"https://apis.viggle.ai/v1/images/img_b16a190d-2d44-41a0-8056-ef68183c3b9e",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
)
response.raise_for_status()
image = response.json()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/images/img_b16a190d-2d44-41a0-8056-ef68183c3b9e", {
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
});
const image = await response.json();
```
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/images/img_b16a190d-2d44-41a0-8056-ef68183c3b9e", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
```
```javascript @viggle/sdk theme={null}
const image = await viggle.images.get("img_b16a190d-2d44-41a0-8056-ef68183c3b9e");
console.log(image.status, image.images.map((generated) => generated.url));
```
Follow the complete create, poll, and download flow.
# Create Motion (from Video)
Source: https://docs.viggle.ai/v1/api-reference/motions/create
openapi.yaml POST /v1/motions
Create a reusable Motion from a driving video.
Send `multipart/form-data` with a driving video uploaded as a file or supplied by URL. `type` selects what gets produced: `render` (default) creates only the render-ready 2D Motion, `glb` creates only the 3D skeletal animation, and `all` produces both and is billed for both.
This uses the same `POST /v1/motions` endpoint as [Create Motion (from Text)](/v1/api-reference/motions/create-from-text), but the content type and request fields are different.
**Pricing** (1 credit = \$0.01): `type=render` has **no separate preprocessing charge**. `type=glb` costs **5 credits per second** of source video, rounded up. `type=all` is billed for both. See [Pricing and retention](/v1/pricing).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `motion_video` | file | One of | — | Driving video uploaded directly as the motion source. Supply exactly one source field; visible body movement is extracted for reuse. |
| `motion_video_url` | string | One of | — | Publicly reachable HTTP(S) URL of the driving video. Supply this instead of `motion_video` and keep it accessible while ingestion starts. |
| `video_url` | string | One of | — | Backward-compatible alias for `motion_video_url`. Do not send both URL fields. |
| `name` | string | No | `""` | Optional label for `render` or `all`. A `glb`-only Motion currently returns an empty name. |
| `type` | string | No | `render` | Outputs to create: `render` produces the reusable 2D Motion (**no charge**), `glb` produces only the 3D animation (**5 credits per second of source video, rounded up**), and `all` produces both and is billed for both. |
Provide exactly one of `motion_video`, `motion_video_url`, or `video_url`. Prefer `motion_video_url` in new integrations.
The following compatibility fields apply to the reusable 2D Motion created by `type=render` or `all`; they have no effect on a `glb`-only request.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `model` | string | No | Service default | 2D preprocessing model: `V3`, `v3`, `V3_Preview`, `V4`, `v4`, `V4_Preview`, or `V4_BaseSG`. This is unrelated to 3D GLB extraction. |
| `thumbnail_path` | string | No | Generated internally | Existing thumbnail URI/path for the reusable Motion. Normally omit it and let the service generate the thumbnail. |
| `thumbnail_url` | string | No | Generated internally | Multipart-only backward-compatible alias for `thumbnail_path`. Do not send both fields. |
The following fields apply only when `type` is `glb` or `all`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `enable_smoothing` | boolean | No | `false` | Whether to smooth extracted joint motion to reduce frame-to-frame jitter. Applies only to `type=glb` or `all` and may slightly soften abrupt movements. |
| `target_fps` | number | No | `25` | Target extraction and GLB timeline frame rate. Must be greater than `0`; omit to use the extraction service's 25 FPS default. |
| `smooth_velocity_threshold` | number | No | `0.12` | Non-negative velocity threshold used by temporal smoothing. Applies only when `enable_smoothing=true`. |
| `apply_std_joints` | boolean | No | `true` | Whether dynamic extraction applies standard-joint normalization. |
| `task_id` | string | No | — | Caller-generated idempotency ID for `type=glb`. Use a unique, stable value when retrying the same work; for `type=all`, the service keys the companion extraction with the created Motion ID. |
Worker payload fields such as extraction/output paths, templates, character
PKLs, and tracking masks remain service-managed and are not accepted as
public request parameters.
## Response parameters
Returns `200 OK` with a Motion object.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public Motion ID beginning with `mot_`. Store it exactly as returned. |
| `status` | string | Yes | Normally `queued`; terminal values are `ready` and `failed`. |
| `name` | string | Yes | Supplied label, or an empty string. |
| `progress` | integer or null | Yes | Progress from 0 to 100 when available. |
| `capabilities` | string\[] | Yes | Empty until ready; ready render-capable Motions include `video_render`. |
| `created_at` / `completed_at` | string or null | Yes | ISO 8601 timestamps. |
| `error` | object or null | Yes | Structured failure details when status is `failed`. |
| `type` | string | Yes | Echoes the requested `type`: `render`, `glb`, or `all`. |
| `glb` | object or null | Yes | `null` for a `render`-only Motion. Otherwise `{status, skeletons, error}` — `skeletons` is empty until ready, then `[mixamo, metahuman]` (both variants are always generated together). |
| `vsplat` | null | Yes | Always `null` for a Motion. |
```json theme={null}
{
"id": "mot_550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"name": "Dance loop",
"progress": 0,
"capabilities": [],
"created_at": "2026-07-21T10:00:00+00:00",
"completed_at": null,
"error": null,
"type": "render",
"vsplat": null,
"glb": null
}
```
## Examples
```go Go theme={null}
file, _ := os.Open("dance.mp4"); defer file.Close()
body := &bytes.Buffer{}; writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("motion_video", "dance.mp4"); io.Copy(part, file)
writer.WriteField("name", "Dance loop"); writer.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/motions", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const form = new FormData(); form.append("motion_video", videoFile); form.append("name", "Dance loop");
const response = await fetch("https://apis.viggle.ai/v1/motions", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}, body:form});
const motion = await response.json();
```
```python Python theme={null}
import os, requests
with open("dance.mp4", "rb") as video:
response = requests.post("https://apis.viggle.ai/v1/motions", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}, files={"motion_video": video}, data={"name": "Dance loop"})
response.raise_for_status(); motion = response.json()
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions" -H "Authorization: Bearer $VIGGLE_API_KEY" -F "motion_video=@dance.mp4" -F "name=Dance loop"
```
To also extract a 3D glb animation, add `type=glb` (or `all` to keep the render too):
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "motion_video=@dance.mp4" \
-F "type=all" \
-F "enable_smoothing=true" \
-F "smooth_velocity_threshold=0.12" \
-F "apply_std_joints=true" \
-F "target_fps=25"
```
## Next step
Use [Get Motion](/v1/api-reference/motions/get) to poll the ID, then [Export 3D Motion (for 3D/game engines)](/v1/api-reference/motions/export) once its `glb.status` is `ready`.
Generate a 3D Motion from a text prompt using the same endpoint with JSON.
# Create Motion (from Text)
Source: https://docs.viggle.ai/v1/api-reference/motions/create-from-text
openapi.yaml POST /v1/motions
Generate a reusable 3D Motion from a text prompt.
Send `application/json` to generate a 3D skeletal animation from a text prompt. Text-generated Motions always use `type: glb` because they do not have a render-ready 2D video counterpart.
This uses the same `POST /v1/motions` endpoint as [Create Motion (from Video)](/v1/api-reference/motions/create), but the content type and request fields are different. It replaces the old, now-removed `POST /v1/animations/generate` endpoint.
**Pricing** (1 credit = \$0.01): a flat **10 credits** per request, independent of `duration_seconds`. This is a different rate from the per-second `glb` extraction on [Create Motion (from Video)](/v1/api-reference/motions/create). See [Pricing and retention](/v1/pricing).
## Request parameters
Send `application/json`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `text` | string (1–400 characters) | Yes | — | Natural-language description of the movement to generate. Include the action, direction, pace, and relevant body parts for a more specific result. |
| `duration_seconds` | number | No | `5` | Requested animation length in seconds. It is rounded to the nearest frame at 30 FPS; values outside `[3, 60]` return `400` instead of being clamped. Does not affect the flat 10-credit price. |
| `guidance_scale` | number | No | `5` | Controls how strongly generation follows the text prompt. Values must be between `0` and `30`; out-of-range input returns `400`. |
| `smooth` | boolean | No | `true` | Whether to smooth the generated animation to reduce abrupt frame-to-frame joint changes. |
| `name` | string | No | `""` | Optional display label returned by detail and list operations. It does not affect motion generation. |
| `task_id` | string | No | — | Optional caller-generated idempotency ID for safe retries. Use the same stable value for the same intended generation and a new value for separate work. |
## Response parameters
Returns `200 OK` with a queued Motion object.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public Motion ID beginning with `mot_`. Store it exactly as returned. |
| `status` | string | Yes | Normally `queued`; terminal values are `ready` and `failed`. |
| `name` | string | Yes | Supplied label, or an empty string. |
| `progress` | integer or null | Yes | Progress from 0 to 100 when available. |
| `capabilities` | string\[] | Yes | Empty for a text-generated Motion because it has no 2D render capability. |
| `created_at` / `completed_at` | string or null | Yes | ISO 8601 timestamps. |
| `error` | object or null | Yes | Structured failure details when `status` is `failed`. |
| `type` | string | Yes | Always `glb`. |
| `glb` | object or null | Yes | Contains `{status, skeletons, error}` while generating and after completion. Ready Motions provide both `mixamo` and `metahuman` skeletons. |
| `vsplat` | null | Yes | Always `null` for a Motion. |
```json theme={null}
{
"id": "mot_550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"name": "Cartwheel",
"progress": 0,
"capabilities": [],
"created_at": "2026-07-21T10:00:00+00:00",
"completed_at": null,
"error": null,
"type": "glb",
"vsplat": null,
"glb": {
"status": "queued",
"skeletons": [],
"error": null
}
}
```
## Examples
```go Go theme={null}
payload := strings.NewReader(`{"text":"a person doing a cartwheel","duration_seconds":5,"guidance_scale":7.5,"smooth":true,"name":"Cartwheel","task_id":"motion-cartwheel-001"}`)
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/motions", payload)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/motions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VIGGLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "a person doing a cartwheel",
duration_seconds: 5,
guidance_scale: 7.5,
smooth: true,
name: "Cartwheel",
task_id: "motion-cartwheel-001",
}),
});
const motion = await response.json();
```
```python Python theme={null}
import os
import requests
response = requests.post(
"https://apis.viggle.ai/v1/motions",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
json={
"text": "a person doing a cartwheel",
"duration_seconds": 5,
"guidance_scale": 7.5,
"smooth": True,
"name": "Cartwheel",
"task_id": "motion-cartwheel-001",
},
)
response.raise_for_status()
motion = response.json()
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"a person doing a cartwheel","duration_seconds":5,"guidance_scale":7.5,"smooth":true,"name":"Cartwheel","task_id":"motion-cartwheel-001"}'
```
## Next step
Use [Get Motion](/v1/api-reference/motions/get) to poll the returned ID until `status` is `ready`, then [Export 3D Motion (for 3D/game engines)](/v1/api-reference/motions/export) to retrieve the `mixamo` or `metahuman` glb.
Follow the complete create, poll, and export flow.
# Delete Motion
Source: https://docs.viggle.ai/v1/api-reference/motions/delete
openapi.yaml DELETE /v1/motions/{motion_id}
Delete one Motion owned by the current account.
`DELETE /v1/motions/{motion_id}` permanently deletes a Motion. It can no longer be used in a Render. Returns `404` when the Motion does not exist or belongs to another account — the two cases are deliberately indistinguishable.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `motion_id` | string | Yes | Complete Motion ID returned by Create, Import, or List Motions, normally beginning with `mot_`. It must identify a Motion owned by the caller. |
## Response parameters
Returns `200 OK`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `status` | string | Yes | `deleted` after the Motion is removed. |
```json theme={null}
{"status":"deleted"}
```
## Examples
```go Go theme={null}
req, _ := http.NewRequest("DELETE", "https://apis.viggle.ai/v1/motions/mot_456def", nil); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/motions/mot_456def", {method:"DELETE",headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const result=await response.json();
```
```python Python theme={null}
import os, requests
response=requests.delete("https://apis.viggle.ai/v1/motions/mot_456def",headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); result=response.json()
```
```bash cURL theme={null}
curl -X DELETE "https://apis.viggle.ai/v1/motions/mot_456def" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
# Export 3D Motion (for 3D/game engines)
Source: https://docs.viggle.ai/v1/api-reference/motions/export
openapi.yaml GET /v1/motions/{motion_id}/export
Get signed download URLs for a Motion's 3D animation extraction or generation.
Only meaningful for a Motion created with `type` `glb` or `all`, or generated from text via `POST /v1/motions` with `application/json`; a `render`-only Motion has nothing to export. This replaces the old, now-removed `GET /v1/animations/{animation_id}`.
`download_type` selects the **file format** — `glb` or `fbx` — not a skeleton. Every request resolves **all three rig variants at once** for that format: `mixamo_url`, `metahuman_url`, and `metahuman_body_url`. All three rigs, in both formats, are always generated up front from the same pass, so switching `download_type` on the same `motion_id` never triggers new work or waits on a regeneration. GLB works with **Mixamo**, **MetaHuman**, and other **Unity-compatible** animation workflows; FBX is the same rigs/animation baked as FBX files, for pipelines (Unreal, Blender, Maya, etc.) that expect that format instead.
**Choosing a rig.** `mixamo_url` is a general-purpose, 50-joint rig used across Mixamo, Unity, Unreal, and most DCC tools; it normally needs retargeting onto your own character mesh. For a Viggle vsplat Character, `metahuman_url` is the 441-joint full rig corresponding to `joint_set=full`, while `metahuman_body_url` is the 86-joint body-only rig corresponding to `joint_set=body`. Motion export currently has no dedicated 119-joint output for `joint_set=expression`.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `motion_id` | string | Yes | Complete Motion ID returned by Create, Generate, Import, or List Motions. The Motion must contain a requested 3D animation output to expose an export. |
| `download_type` | string | No | Selects the pre-generated file format to download: `glb` or `fbx`. All three rig variants for that format come back together. Omit to retrieve status only. |
## Response parameters
Returns `200 OK`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Requested public Motion ID. |
| `status` | string | Yes | `queued`, `processing`, `ready`, or `failed`. |
| `download_type` | string or null | Yes | Echoes the requested `download_type` — the file format shared by the three rig URLs below. `null` if omitted. |
| `mixamo_url` | string or null | Yes | Signed URL for the Mixamo rig in the requested `download_type`'s format, once ready; otherwise `null`. Always present, regardless of which format (or none) was requested. |
| `metahuman_url` | string or null | Yes | Signed URL for the 441-joint MetaHuman rig (full facial) in the requested format, once ready; otherwise `null`. Always present. |
| `metahuman_body_url` | string or null | Yes | Signed URL for the 86-joint body-only MetaHuman rig in the requested format, once ready; otherwise `null`. Always present. |
| `thumbnail_url` | null | Yes | Always `null` — animations have no thumbnail. |
| `created_at` | string or null | Yes | ISO 8601 timestamp. May be an empty string on a task that has not recorded one. |
| `updated_at` | string or null | Yes | ISO 8601 timestamp of the most recent update. |
| `error` | object or null | Yes | Structured failure details when `status` is `failed`. |
All three URL fields are always present in the response — unlike a design where each rig has its own `download_type` value, there's nothing here that's conditionally omitted. A rig that isn't ready yet (or doesn't apply) simply stays `null` rather than causing an error; it's never dropped from the response.
```json theme={null}
{
"id": "mot_456def",
"status": "ready",
"download_type": "glb",
"mixamo_url": "https://assets.viggle.ai/results/mixamo.glb",
"metahuman_url": "https://assets.viggle.ai/results/metahuman.glb",
"metahuman_body_url": "https://assets.viggle.ai/results/metahuman_body.glb",
"thumbnail_url": null,
"created_at": "2026-07-21T10:00:00+00:00",
"updated_at": "2026-07-21T10:01:00+00:00",
"error": null
}
```
Request `fbx` for the same Motion instead — no regeneration needed, and the same three fields carry FBX URLs this time:
```json theme={null}
{
"id": "mot_456def",
"status": "ready",
"download_type": "fbx",
"mixamo_url": "https://assets.viggle.ai/results/mixamo.fbx",
"metahuman_url": "https://assets.viggle.ai/results/metahuman.fbx",
"metahuman_body_url": "https://assets.viggle.ai/results/metahuman_body.fbx",
"thumbnail_url": null,
"created_at": "2026-07-21T10:00:00+00:00",
"updated_at": "2026-07-21T10:01:00+00:00",
"error": null
}
```
Omitting `download_type` (status check only) returns all three URL fields as `null`:
```json theme={null}
{
"id": "mot_456def",
"status": "ready",
"download_type": null,
"mixamo_url": null,
"metahuman_url": null,
"metahuman_body_url": null,
"thumbnail_url": null,
"created_at": "2026-07-21T10:00:00+00:00",
"updated_at": "2026-07-21T10:01:00+00:00",
"error": null
}
```
## Examples
```go Go theme={null}
req,_:=http.NewRequest("GET","https://apis.viggle.ai/v1/motions/mot_456def/export?download_type=glb",nil); req.Header.Set("Authorization","Bearer "+os.Getenv("VIGGLE_API_KEY")); resp,err:=http.DefaultClient.Do(req); if err!=nil {panic(err)}; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response=await fetch("https://apis.viggle.ai/v1/motions/mot_456def/export?download_type=glb",{headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const exported=await response.json();
```
```python Python theme={null}
import os, requests
response=requests.get("https://apis.viggle.ai/v1/motions/mot_456def/export",params={"download_type":"glb"},headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); exported=response.json()
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/motions/mot_456def/export?download_type=glb" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
Download URLs are short-lived and re-signed on every read — request `download_type` again rather than caching the URL.
`download_type` values other than `glb` or `fbx` answer `400 INVALID_REQUEST` — "invalid download\_type".
## Next step
A text-generated Motion has no 2D render counterpart — only this export applies. A video-sourced `glb`/`all` Motion also keeps its `video_render` capability; use [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create) with `motion_id` for that side once it's ready.
# Get Motion
Source: https://docs.viggle.ai/v1/api-reference/motions/get
openapi.yaml GET /v1/motions/{motion_id}
Retrieve the current state of one Motion.
`GET /v1/motions/{motion_id}` retrieves a Motion. Poll while its status is `queued` or `processing`.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `motion_id` | string | Yes | Complete Motion ID returned by Create, Import, or List Motions, normally beginning with `mot_`. Poll with the same value until every requested output is ready or failed. |
## Response parameters
Returns `200 OK` with the Motion fields documented in [Create Motion (from Video)](/v1/api-reference/motions/create) and [Create Motion (from Text)](/v1/api-reference/motions/create-from-text), including `type` and `glb`. `error` is always `null` here — read `status` to detect failure (on a `glb`/`all` Motion, check `glb.error` for extraction-specific failure detail).
```json theme={null}
{"id":"mot_456def","status":"ready","name":"Dance loop","progress":100,"capabilities":["video_render"],"created_at":"2026-07-21T10:00:00+00:00","completed_at":"2026-07-21T10:00:20+00:00","error":null,"type":"render","vsplat":null,"glb":null}
```
## Examples
```go Go theme={null}
id := "mot_456def"; req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/motions/"+id, nil); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/motions/mot_456def", {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const motion = await response.json();
```
```python Python theme={null}
import os, requests
response=requests.get("https://apis.viggle.ai/v1/motions/mot_456def",headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); motion=response.json()
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/motions/mot_456def" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
## Next step
Once a `glb`/`all` Motion's `glb.status` is `ready`, use [Export 3D Motion (for 3D/game engines)](/v1/api-reference/motions/export) to get its download URL.
# Import Motion
Source: https://docs.viggle.ai/v1/api-reference/motions/import
openapi.yaml POST /v1/motions/import
Create a reusable Motion from an official Viggle motion template.
Copies the official template identified by `template_id` into a Motion asset owned by the calling principal.
**Pricing** (1 credit = \$0.01): **1 credit per second** of the source template's duration, with a minimum of **1 credit**. See [Pricing and retention](/v1/pricing#import-motion).
## Request parameters
Send a JSON body.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `template_id` | string | One of | — | ID of the official Viggle motion template to copy into the caller's assets. Preferred for new integrations; supply either this field or `template_uuid`. |
| `template_uuid` | string | One of | — | Backward-compatible alias for `template_id`. Supply one template identifier rather than sending both fields. |
| `name` | string | No | `Imported: ` + first 8 characters of the template ID | Optional display label for the imported Motion. When omitted, the service derives the default from the selected template ID. |
| `model` | string | No | `V3_Preview` | Render model associated with the imported motion: `V3_Preview` or `V4_BaseSG`; `V4_Preview` is accepted as a backward-compatible alias for `V4_BaseSG`. |
Send `template_id` in new integrations; unknown properties are ignored.
## Response parameters
Returns `200 OK`. Only `id` and `status` carry information here; `name` and `created_at` are populated from [Get Motion](/v1/api-reference/motions/get). See [Create Motion](/v1/api-reference/motions/create) for the field-by-field definition of the full Motion object.
```json theme={null}
{"id":"mot_550e8400-e29b-41d4-a716-446655440000","status":"queued","name":"","progress":0,"capabilities":[],"created_at":null,"completed_at":null,"error":null,"type":"render","vsplat":null,"glb":null}
```
## Examples
```go Go theme={null}
payload := strings.NewReader(`{"template_id":"YOUR_TEMPLATE_ID","name":"Campaign dance"}`)
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/motions/import", payload)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/motions/import", {method:"POST", headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`,"Content-Type":"application/json"}, body:JSON.stringify({template_id:"YOUR_TEMPLATE_ID",name:"Campaign dance"})});
const motion = await response.json();
```
```python Python theme={null}
import os, requests
response = requests.post("https://apis.viggle.ai/v1/motions/import", headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}, json={"template_id":"YOUR_TEMPLATE_ID","name":"Campaign dance"})
response.raise_for_status(); motion = response.json()
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions/import" -H "Authorization: Bearer $VIGGLE_API_KEY" -H "Content-Type: application/json" -d '{"template_id":"YOUR_TEMPLATE_ID","name":"Campaign dance"}'
```
# List Motions
Source: https://docs.viggle.ai/v1/api-reference/motions/list
openapi.yaml GET /v1/motions
Retrieve the most recently created Motions owned by the current account.
`GET /v1/motions` returns the most recently created active Motions, newest first. The page is capped at 100 items and carries no cursor.
## Request parameters
This endpoint has no path, query, or request-body parameters.
## Response parameters
Returns `200 OK` with an object containing `data`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `data` | Motion\[] | Yes | Every Motion owned by the authenticated account, newest first. |
| `data[].id` | string | Yes | Public Motion ID beginning with `mot_`. |
| `data[].status` | string | Yes | `queued`, `processing`, `ready`, or `failed`. |
| `data[].name` | string | Yes | Motion label, or an empty string. |
| `data[].progress` | null | Yes | Always `null` in list responses. |
| `data[].capabilities` | string\[] | Yes | `video_render` when ready. |
| `data[].type` | string | Yes | `render`, `glb`, or `all`. |
| `data[].glb` | object or null | Yes | `null` unless `type` requested a glb extraction/generation. |
| `data[].created_at` / `completed_at` | string or null | Yes | ISO 8601 timestamps. |
| `data[].error` | object or null | Yes | Always `null` — read `status` to detect failure. |
```json theme={null}
{"data":[{"id":"mot_456def","status":"ready","name":"Dance loop","progress":null,"capabilities":["video_render"],"created_at":"2026-07-21T10:00:00+00:00","completed_at":"2026-07-21T10:00:20+00:00","error":null,"type":"render","vsplat":null,"glb":null}]}
```
## Examples
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/motions", nil); req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY"))
resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/motions", {headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const result = await response.json();
```
```python Python theme={null}
import os, requests
response = requests.get("https://apis.viggle.ai/v1/motions", headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); result = response.json()
```
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/motions" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
# V1 API reference
Source: https://docs.viggle.ai/v1/api-reference/overview
Resource-oriented reference for the Viggle V1 API.
Base URL:
```text theme={null}
https://apis.viggle.ai/v1
```
All endpoints require `Authorization: Bearer YOUR_API_KEY`.
## Request and response conventions
| Item | V1 convention |
| - | - |
| Base URL | `https://apis.viggle.ai/v1` |
| Authentication | `Authorization: Bearer YOUR_API_KEY` on every endpoint, including status and download requests. |
| Media upload | Use `multipart/form-data` — for `POST /v1/renders` direct mode, `POST /v1/videos`'s frame images, and `POST /v1/images` (required there even without reference images) — or the draft-based render flow's direct-upload `PUT` URLs. Do not set a `Content-Type` header manually when using `FormData`; the client adds the required boundary. |
| JSON request | Motion template import, text-to-motion, `renders/prepare`, and the draft form of `POST /v1/renders` all use `application/json`. |
| Request tracing | Every response includes `X-Request-Id`. You may send your own value to correlate your own logs. |
| Source tracking | Send `X-Viggle-Source` to label the calling channel or integration. |
| Idempotency | `Idempotency-Key` is required by the JSON draft form of `POST /v1/renders` and ignored everywhere else. |
| IDs | Public IDs are typed by prefix: `char_`, `mot_`, `render_`, `vid_`, `anim_`, and `img_`. Never use an ID from one resource family where another is expected. |
### Public ID formats
IDs are opaque values generated by Viggle. Preserve them exactly as returned; do not construct, shorten, or strip their prefixes. There is no separate `avatar_`/`anim_` ID family — 3D vsplat and glb extraction are now tracked on the owning Character/Motion itself (`type`, `vsplat`, `glb`) and downloaded via `/export`, not through their own resources.
| Resource | Prefix | Example |
| - | - | - |
| Character | `char_` | `char_550e8400-e29b-41d4-a716-446655440000` |
| Motion | `mot_` | `mot_550e8400-e29b-41d4-a716-446655440000` |
| Render | `render_` | `render_8f50b2c2c2b84ae0` |
| Video (H3 generation) | `vid_` | `vid_3f2a9c1b7e0d4f6a` |
| Video (character animation) | `anim_` | `anim_7c1e4b9a2d0f6e3c` |
| Image generation | `img_` | `img_b16a190d-2d44-41a0-8056-ef68183c3b9e` |
`video_id` on the [Videos](/v1/api-reference/videos/list) endpoints accepts `render_`, `vid_`, and `anim_` prefixed IDs — a Render, an H3 generation, and a character-animation generation are all read back as the same unified video resource. An `img_` image generation is not a video; read it back with [Get Image](/v1/api-reference/images/get).
### Success and error responses
Creation endpoints return `200 OK` (or, for the draft-based render flow, `202 Accepted`) with an asynchronous resource in `queued` state. A resource status request can return `200 OK` with `status: "failed"`; always inspect both the HTTP status and the response body.
```json theme={null}
{
"error": {
"code": "INVALID_REQUEST",
"message": "Request failed validation",
"retryable": false,
"request_id": "req_123abc",
"details": {},
"remediation": { "action": "fix_request", "retry_after_ms": null }
}
}
```
See [Errors and recovery](/v1/production/errors) for the full `code` vocabulary and how to use `retryable`/`remediation`.
## Resources
| Resource | Purpose |
| - | - |
| [Characters](/v1/api-reference/characters) | Reusable characters from images, with optional 3D vsplat extraction. |
| [Motions](/v1/api-reference/motions) | Reusable driving motions from videos or text prompts, with optional 3D glb extraction. |
| [Renders](/v1/api-reference/renders) | Asynchronous character+motion video generation — direct or draft-based. |
| [Videos](/v1/api-reference/videos/list) | H3 Video Generation — Viggle-optimized MiniMax H3 text-to-video, first-frame, first-last-frame, and reference-video/image generation, plus character-animation generation on a separate backend — and unified state for finished Renders and every generation type. |
| [Viggle Image](/v1/api-reference/images/create) | Generate 1–6 PNG images from a text prompt with the `qwen` model, optionally guided by up to 8 reference images. **$0.01 per image** at `1K`, **$0.02** at `2K`. |
| [Credits](/v1/api-reference/credits) | Current account balance. |
Go, JavaScript, Python, and cURL requests for the current V1 endpoints.
**H3 Video Generation** (`POST /v1/videos`) generates a Viggle-optimized MiniMax H3 video with **native audio** on every clip. `quality=low` generates faster, for quicker iteration; `quality=high` produces higher-fidelity output — both tiers are billed at the same **$0.01 per generated second** at `480p`/`768p`/`1080p`/`2k`, rising to **$0.05 per generated second** at `4k`. See [Generate Video (from Text)](/v1/api-reference/videos/create-from-text) and [Pricing and retention](/v1/pricing#h3-video). The same endpoint's **character-animation mode** dispatches to a separate backend instead — one to four character images, or up to five with an optional driving video, billed at **$0.01 per second (1 credit/second)** of the driving video's duration, rounded up (5–16 credits), or a flat **$0.11** without a driving video. See [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation).
## Resource model
V1 separates reusable content from work performed on that content:
| Layer | Current V1 examples | Meaning |
| - | - | - |
| Asset | Character, Motion | A reusable item with a stable public ID. It can be referenced by multiple Render requests. `type` selects whether a 3D extraction (`vsplat` on Characters, `glb` on Motions) also runs against the same source. |
| Operation | Render, H3 generation, character-animation generation, image generation | An asynchronous request with status, inputs, error details, and output. It is not a reusable input asset. Video operations are read back through the same [Video](/v1/api-reference/videos/list) resource; image generations through [Get Image](/v1/api-reference/images/get). |
| Artifact | `video_url`, `alpha_url`, `vsplat_url`, `thumbnail_url`, `mixamo_url`/`metahuman_url`/`metahuman_body_url`, `images[].url` | A downloadable result produced by an operation or export. Artifact URLs are short-lived and re-signed on every read. |
Direct `image` and `motion_video` inputs on `POST /v1/renders` are one-time inputs. They do not create a Character or Motion that appears in an asset list. Create an asset explicitly when it needs to be reused.
## Endpoint index
| Method | Endpoint | Description |
| - | - | - |
| `POST` | `/v1/renders/prepare` | Prepare a render and receive direct-upload plans. |
| `POST` | `/v1/renders` | Create a Render directly (multipart) or from a prepared draft (JSON). |
| `GET` | `/v1/renders/{render_id}/events` | Watch Render state over Server-Sent Events. |
| `GET` | `/v1/renders/{render_id}/download` | Download a ready Render. |
| `POST` | `/v1/videos` | Generate an H3 video from a prompt, a first frame, a first and last frame, or a reference video/image — or a character-animation video from a driving video and/or character images. |
| `GET` | `/v1/videos` | List the current principal's videos — Renders, H3 generations, and character-animation generations merged (cursor-paginated). Replaces the retired `GET /v1/renders`. |
| `GET` | `/v1/videos/{video_id}` | Get video status and output URLs, by `render_` or `vid_` ID. Replaces the retired `GET /v1/renders/{render_id}`. |
| `POST` | `/v1/images` | Generate 1–6 PNG images from a prompt, optionally with up to 8 reference images. |
| `GET` | `/v1/images/{image_id}` | Get an image generation's status and PNG download URLs, by `img_` ID. |
| `GET` | `/v1/credits` | Get the current account balance. |
| `POST` | `/v1/characters` | Create a reusable Character, optionally with a 3D vsplat. |
| `GET` | `/v1/characters` | List Characters. |
| `GET` | `/v1/characters/{character_id}` | Get one Character. |
| `DELETE` | `/v1/characters/{character_id}` | Delete one Character. |
| `GET` | `/v1/characters/{character_id}/export` | Download a Character's vsplat extraction. |
| `POST` | `/v1/motions` | Create a reusable Motion from video, or generate one from text (JSON), optionally with a 3D glb. |
| `POST` | `/v1/motions/import` | Import a Motion from a Viggle template. |
| `GET` | `/v1/motions` | List Motions. |
| `GET` | `/v1/motions/{motion_id}` | Get one Motion. |
| `DELETE` | `/v1/motions/{motion_id}` | Delete one Motion. |
| `GET` | `/v1/motions/{motion_id}/export` | Download a Motion's 3D animation. |
## Conventions
* Requests that upload media use `multipart/form-data`, or the draft-based flow's direct-upload `PUT` URLs.
* IDs are resource-prefixed, such as `char_...` and `render_...`.
* Characters and Motions use `queued`, `processing`, `ready`, and `failed`; Renders may also reach `cancelled` — but a client cannot request cancellation, only observe it.
* Error responses use `{ "error": { "code", "message", "retryable", "request_id", "details", "remediation" } }`. Internal worker and pipeline codes are never exposed.
# Render Video (from Character and/or Motion)
Source: https://docs.viggle.ai/v1/api-reference/renders/create
openapi.yaml POST /v1/renders
Create a render directly, or finalize one prepared with Prepare Render.
The request body carries one of two shapes, selected by `Content-Type`.
`multipart/form-data` creates a render directly from character and motion inputs, each supplied as an uploaded file, a URL, or the ID of a saved asset. This form answers `200` and ignores `Idempotency-Key`.
`application/json` creates a render from a draft prepared by [Prepare Render](/v1/api-reference/renders/prepare). This form answers `202`, **requires** `Idempotency-Key`, and is idempotent within the authenticated project or OAuth principal: reusing the same key with the same normalized input returns the original render, while reusing it with different input returns `IDEMPOTENCY_KEY_REUSED`.
**Pricing** (1 credit = $0.01): **$0.01 per character in the render, per second\*\* of the finished video. Only one character is supported per render today, so this is **$0.01/second** in practice — a 10-second render costs **$0.10 (10 credits)**. There is no per-render minimum charge; the amount reserved when the render starts is an estimate based on the requested inputs and is finalized against the actual completed duration once the render finishes. See [Pricing and retention](/v1/pricing#video-remix).
## Request parameters — `multipart/form-data`
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `character_id` | string | One character source | — | ID of a `ready` Character owned by the caller, normally beginning with `char_`. Use instead of uploading or linking a new image. |
| `image` | file | One character source | — | Character source image uploaded directly. Use exactly one character source among `image`, `image_url`, and `character_id`. |
| `image_url` | string | One character source | — | Publicly reachable URL of a character image that the service can fetch. Use instead of `image` or `character_id`. |
| `ref_url` | string | One character source | — | Backward-compatible alias for `image_url`. Do not combine character URL aliases. |
| `ref_image_url` | string | One character source | — | Backward-compatible alias for `image_url`. Do not combine character URL aliases. |
| `motion_id` | string | One motion source | — | ID of a `ready` Motion owned by the caller, normally beginning with `mot_`. Use instead of uploading or linking a new driving video. |
| `motion_video` | file | One motion source | — | Driving video uploaded directly. Use exactly one motion source among `motion_video`, `motion_video_url`, and `motion_id`. |
| `motion_video_url` | string | One motion source | — | Publicly reachable URL of a driving video that the service can fetch. Use instead of `motion_video` or `motion_id`. |
| `video_url` | string | One motion source | — | Backward-compatible alias for `motion_video_url`. Do not combine motion URL aliases. |
| `driving_video_url` | string | One motion source | — | Backward-compatible alias for `motion_video_url`. Do not combine motion URL aliases. |
| `scene_id` | string | One motion source | — | Backward-compatible alias for `motion_id`. Do not send both IDs. |
| `background_mode` | string | No | `original` | Background treatment: `original` preserves or reconstructs the source background, `solid` uses `bg_color`, `transparent` requests alpha output, and `inpaint` is accepted as an alias of `original`. |
| `bg_color` | string | With `solid` | — | Solid background colour as `"R,G,B"`, for example `"0,177,64"`. Valid only with `background_mode=solid`. |
| `background_url` | string | No | — | Optional custom background image URL forwarded to the render pipeline. Normally omitted unless the selected pipeline mode supports it. |
| `model` | string | No | Service default | Render model: `V3`, `v3`, `V3_Preview`, `V4`, `v4`, `V4_Preview`, or `V4_BaseSG`. The service otherwise infers it from saved assets or uses its configured default. |
| `mode` | string | No | Inferred | Advanced pipeline mode: `full_pipeline`, `preprocessed`, `character_only`, or `scene_preprocess`. Omit to infer it from the supplied source types. |
| `character_mapping` | JSON string | No | — | Advanced mapping from motion segment IDs to Character IDs. In multipart requests, encode the JSON object as a string. |
If neither the character group nor the motion group is supplied at all, the server substitutes its own preconfigured default `character_id`/`motion_id` and renders that instead of rejecting the request. Supplying either group *partially* — for example a character source with no motion source — is still an error. `bg_color` is only valid with `background_mode=solid`.
The default `character_id`/`motion_id` are configured per project/environment, not fixed across all of Viggle — two API keys can send an all-empty request and get different results. Don't rely on this fallback for anything beyond a quick smoke test; always supply an explicit character and motion source in production traffic.
## Request parameters — `application/json` (draft-based)
| Header | Required | Description |
| - | :-: | - |
| `Idempotency-Key` | Yes | Caller-generated stable key of 1–255 characters. Reusing it with the same normalized draft returns the original Render; reusing it with different input returns `IDEMPOTENCY_KEY_REUSED`. |
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `draft_id` | string | Yes | Opaque draft ID returned by [Prepare Render](/v1/api-reference/renders/prepare). Pass it unchanged after completing every required direct upload; a draft can be consumed only once. |
| `upload_completions` | array | With uploads | One entry for every upload plan returned by Prepare Render. Required when the draft's `state` was `awaiting_uploads`; omit or send an empty array when no upload was required. |
| `upload_completions[].upload_handle` | string | Yes per entry | Opaque handle from the matching upload plan. It identifies which character or motion upload was completed. |
| `upload_completions[].etag` | string | No | Optional ETag returned by the object-store `PUT` response. Preserve surrounding quotes when the storage service includes them. |
## Response parameters
`multipart/form-data` returns `200 OK`; the `application/json` draft form returns `202 Accepted`. Both return a Render object, though the `200` response from a direct multipart request only carries information in `id`, `status`, `progress`, and `created_at`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public Render ID beginning with `render_`. |
| `status` | string | Yes | `queued`, `processing`, `ready`, `failed`, or `cancelled`. |
| `stage` | string or null | Yes | Coarse progress hint when available. `analyzing`/`rendering`/`finishing` come from the multipart pipeline; other values come from the draft-based pipeline. |
| `progress` | integer or null | Yes | Progress from 0 to 100 when available. |
| `video_url` | string or null | Yes | Finished video URL when ready. |
| `alpha_url` | string or null | Yes | Alpha video URL for transparent output, when available. |
| `created_at` / `completed_at` | string or null | Yes | ISO 8601 timestamps. |
| `error` | object or null | Yes | Failure details when the Render fails. |
| `links` | object | No | `{self, events, download}` — absent on renders served by the legacy migration proxy. |
```json theme={null}
{"id":"render_789ghi","status":"queued","stage":null,"progress":0,"video_url":null,"alpha_url":null,"created_at":"2026-07-21T10:00:00+00:00","completed_at":null,"error":null,"links":{"self":"/v1/renders/render_789ghi","events":"/v1/renders/render_789ghi/events","download":"/v1/renders/render_789ghi/download"}}
```
`links.self` mirrors the retired `GET /v1/renders/{render_id}` and no longer resolves — poll [Get Video](/v1/api-reference/videos/get) with the same `id` instead. `links.events` and `links.download` are unaffected.
## Examples — direct `multipart/form-data`
```go Go theme={null}
body:=&bytes.Buffer{}; w:=multipart.NewWriter(body); image,_:=os.Open("character.png"); defer image.Close(); video,_:=os.Open("dance.mp4"); defer video.Close(); p,_:=w.CreateFormFile("image","character.png"); io.Copy(p,image); p,_=w.CreateFormFile("motion_video","dance.mp4"); io.Copy(p,video); w.WriteField("background_mode","original"); w.Close(); req,_:=http.NewRequest("POST","https://apis.viggle.ai/v1/renders",body); req.Header.Set("Authorization","Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type",w.FormDataContentType()); resp,err:=http.DefaultClient.Do(req); if err!=nil { panic(err) }; defer resp.Body.Close()
```
```javascript JavaScript theme={null}
const form=new FormData(); form.append("image",imageFile); form.append("motion_video",videoFile); form.append("background_mode","original"); const response=await fetch("https://apis.viggle.ai/v1/renders",{method:"POST",headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`},body:form}); const render=await response.json();
```
```python Python theme={null}
import os, requests
with open("character.png","rb") as image, open("dance.mp4","rb") as video: response=requests.post("https://apis.viggle.ai/v1/renders",headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"},files={"image":image,"motion_video":video},data={"background_mode":"original"})
response.raise_for_status(); render=response.json()
```
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders" -H "Authorization: Bearer $VIGGLE_API_KEY" -F "image=@character.png" -F "motion_video=@dance.mp4" -F "background_mode=original"
```
## Examples — draft-based `application/json`
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"draft_id":"draft_abc123","upload_completions":[{"upload_handle":"up_1","etag":"\"9f...\""}]}'
```
```python Python theme={null}
import os, uuid, requests
response = requests.post(
"https://apis.viggle.ai/v1/renders",
headers={
"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={"draft_id": "draft_abc123", "upload_completions": [{"upload_handle": "up_1", "etag": "\"9f...\""}]},
)
response.raise_for_status()
render = response.json()
```
See [Prepare Render](/v1/api-reference/renders/prepare) for how to obtain `draft_id` and upload the character/motion media before calling this endpoint in JSON mode.
# Download Render
Source: https://docs.viggle.ai/v1/api-reference/renders/download
openapi.yaml GET /v1/renders/{render_id}/download
Download the finished video of a ready Render.
`GET /v1/renders/{render_id}/download` redirects to the finished output. The Render must be `ready`.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `render_id` | string | Yes | Full public Render ID returned by Render Video (from Character and/or Motion) or List Videos. The Render must belong to the caller and have `status=ready`; otherwise the endpoint returns an error instead of redirecting. |
## Response parameters
Returns `302 Found` with a `Location` header pointing to the video. Follow redirects to receive the video bytes.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `Location` | string | Yes on success | Redirect URL for the completed video. |
## Examples
```go Go theme={null}
req,_:=http.NewRequest("GET","https://apis.viggle.ai/v1/renders/render_789ghi/download",nil); req.Header.Set("Authorization","Bearer "+os.Getenv("VIGGLE_API_KEY")); resp,err:=http.DefaultClient.Do(req); if err!=nil { panic(err) }; defer resp.Body.Close(); output,_:=os.Create("output.mp4"); defer output.Close(); io.Copy(output,resp.Body)
```
```javascript JavaScript theme={null}
const response=await fetch("https://apis.viggle.ai/v1/renders/render_789ghi/download",{headers:{Authorization:`Bearer ${process.env.VIGGLE_API_KEY}`}}); const video=await response.blob();
```
```python Python theme={null}
import os, requests
response=requests.get("https://apis.viggle.ai/v1/renders/render_789ghi/download",headers={"Authorization":f"Bearer {os.environ['VIGGLE_API_KEY']}"}); response.raise_for_status(); open("output.mp4","wb").write(response.content)
```
```bash cURL theme={null}
curl -L "https://apis.viggle.ai/v1/renders/render_789ghi/download" -H "Authorization: Bearer $VIGGLE_API_KEY" -o output.mp4
```
# Watch Render
Source: https://docs.viggle.ai/v1/api-reference/renders/events
openapi.yaml GET /v1/renders/{render_id}/events
Watch render state through Server-Sent Events instead of polling.
The server sends a snapshot first, emits a heartbeat every 10 to 15 seconds, and closes the stream after `ready`, `failed`, or `cancelled`. Reconnect with `Last-Event-ID` to resume after a snapshot. If the event history has expired, call [Get Video](/v1/api-reference/videos/get) (`GET /v1/videos/{render_id}`) and reconnect from the current state.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `render_id` | string | Yes | Full public Render ID returned by Render Video (from Character and/or Motion) or List Videos, normally beginning with `render_`. The stream reports events only for this Render. |
| `Last-Event-ID` | string | No | Request header containing the last SSE event ID processed completely. Send it when reconnecting so retained history resumes after that event; omit it for a fresh stream. |
## Response
Returns `200 OK` with a `text/event-stream` body. Business events carry a JSON `data` payload; heartbeats may omit `data`.
| Event | `data` payload | Description |
| - | - | - |
| `snapshot` | Render event data | Sent immediately on connect. |
| `queued` | Render event data | Render accepted, not yet processing. |
| `processing` | Render event data | Processing has started. |
| `progress` | Render event data | `progress` and/or `stage` changed. |
| `ready` | Render event data | Terminal — `video_url`/`alpha_url` populated. Stream closes. |
| `failed` | Render event data | Terminal — `error` populated. Stream closes. |
| `cancelled` | Render event data | Terminal. Stream closes. |
| `heartbeat` | none | Keep-alive; no business data. |
Each Render event's `data` field:
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `event_id` | string | Yes | Pass as `Last-Event-ID` to resume after this event. |
| `sequence` | integer | Yes | Monotonically increasing per render. |
| `id` | string | Yes | Render ID. |
| `status` | string | Yes | `queued`, `processing`, `ready`, `failed`, or `cancelled`. |
| `stage` | string or null | Yes | Coarse progress hint when available. |
| `progress` | integer or null | Yes | Progress from 0 to 100 when available. |
| `video_url` / `alpha_url` | string or null | Yes | Populated on `ready`. |
| `occurred_at` | string | Yes | ISO 8601 timestamp of the event. |
| `completed_at` | string or null | Yes | ISO 8601 completion timestamp. |
| `error` | object or null | Yes | Populated on `failed`. |
## Examples
```bash cURL theme={null}
curl -N "https://apis.viggle.ai/v1/renders/render_789ghi/events" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Accept: text/event-stream"
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/renders/render_789ghi/events", {
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}`, Accept: "text/event-stream" },
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Split on blank lines to get whole SSE frames, then parse `event:`/`data:`.
}
```
```python Python theme={null}
import os, requests
with requests.get(
"https://apis.viggle.ai/v1/renders/render_789ghi/events",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}", "Accept": "text/event-stream"},
stream=True,
) as response:
response.raise_for_status()
for line in response.iter_lines(decode_unicode=True):
if line:
print(line)
```
Prefer this endpoint over polling [Get Video](/v1/api-reference/videos/get) when you can hold a long-lived connection — it removes polling latency and load without changing how you interpret render state.
## Next step
Once a `ready` event arrives, use [Download Render](/v1/api-reference/renders/download) or the event's own `video_url`/`alpha_url` to fetch the result.
# Prepare Render
Source: https://docs.viggle.ai/v1/api-reference/renders/prepare
openapi.yaml POST /v1/renders/prepare
Prepare a render and receive direct-upload plans for any media that needs uploading.
Starts the draft-based render flow. Instead of embedding media bytes in a JSON request, `prepare` returns short-lived direct-upload URLs; upload the character/motion bytes straight to those URLs, then call the `application/json` form of [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create) with the returned `draft_id`.
## Request parameters
Send `application/json`.
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `character` | object | Yes | Character source descriptor. Choose exactly one form: direct-upload metadata, a public HTTPS URL, or the ID of a ready reusable Character. |
| `motion` | object | Yes | Motion source descriptor. Choose direct-upload metadata, a public HTTPS URL, a ready reusable Motion ID, or an official Viggle motion template ID. |
| `character.kind` / `motion.kind` | string | Yes | Source discriminator: `upload`, `url`, or `asset`; the motion source also accepts `official_motion`. The selected value determines which sibling fields are required. |
| `character.filename` / `motion.filename` | string | With `kind=upload` | Original filename including its extension. It is used to identify and validate the media in the subsequent direct upload. |
| `character.content_type` / `motion.content_type` | string | With `kind=upload` | MIME type such as `image/png` or `video/mp4`. Send the same value in the direct-upload request's `Content-Type` header. |
| `character.bytes` / `motion.bytes` | integer | With `kind=upload` | Exact file size in bytes, greater than zero. The uploaded object's size is checked against this declaration. |
| `character.sha256` / `motion.sha256` | string | No | Optional SHA-256 digest of the exact upload bytes, encoded as 64 lowercase hexadecimal characters. When supplied, creation verifies the uploaded object against it. |
| `character.url` / `motion.url` | string | With `kind=url` | Publicly reachable HTTPS URL of the source media. It must remain accessible while the draft is prepared and consumed. |
| `character.asset_id` / `motion.asset_id` | string | With `kind=asset` | ID of a ready reusable asset owned by the caller. Use a Character ID in `character` and a Motion ID in `motion`. |
| `motion.motion_id` | string | With `kind=official_motion` | Identifier of the official Viggle motion template to use as the driving motion. This form is available only for the motion slot. |
| `output` | object | No | Optional output configuration. Omit it to use the pipeline's default background treatment and source aspect ratio. |
| `output.background_mode` | string | No | `original`, `green`, `white`, or `transparent`. `green`/`white` are translated to the pipeline's solid mode with the matching colour. |
| `output.aspect_ratio` | string | No | Only `source` is served today; `16:9`, `9:16`, and `1:1` are accepted values but currently rejected rather than silently ignored, since the render pipeline does not reframe yet. |
`kind: "upload"` declares an upload slot without sending bytes yet — supply the file's `filename`, `content_type`, and `bytes` size, optionally include `sha256` for integrity verification, then `PUT` the actual bytes to the URL returned below. Omitting `output` entirely uses the pipeline's default background and aspect ratio, same as omitting it on the direct `multipart/form-data` form of [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create).
## Response parameters
Returns `200 OK`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `draft_id` | string | Yes | Pass this to the JSON form of [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create). |
| `state` | string | Yes | `awaiting_uploads` if any `kind: "upload"` source was declared, otherwise `ready_to_create`. |
| `uploads` | array | Yes | One entry per declared upload slot. Empty when every source was `url`/`asset`/`official_motion`. |
| `uploads[].slot` | string | Yes | `character` or `motion`. |
| `uploads[].upload_handle` | string | Yes | Echo this in `upload_completions` when creating the render. |
| `uploads[].method` | string | Yes | Always `PUT`. |
| `uploads[].url` | string | Yes | Short-lived direct-upload URL. |
| `uploads[].required_headers` | object | Yes | Headers to send on the `PUT` request, as `{name: value}`. |
| `uploads[].expires_at` | string | Yes | ISO 8601 expiry of the upload URL. |
```json theme={null}
{
"draft_id": "draft_abc123",
"state": "awaiting_uploads",
"uploads": [
{
"slot": "character",
"upload_handle": "up_1",
"method": "PUT",
"url": "https://uploads.viggle.ai/draft_abc123/character?sig=...",
"required_headers": { "Content-Type": "image/png" },
"expires_at": "2026-07-31T09:30:00+00:00"
}
]
}
```
## Examples
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders/prepare" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"character": {"kind":"upload","filename":"character.png","content_type":"image/png","bytes":204800},
"motion": {"kind":"asset","asset_id":"mot_456def"}
}'
```
```python Python theme={null}
import os, requests
response = requests.post(
"https://apis.viggle.ai/v1/renders/prepare",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
json={
"character": {"kind": "upload", "filename": "character.png", "content_type": "image/png", "bytes": 204800},
"motion": {"kind": "asset", "asset_id": "mot_456def"},
},
)
response.raise_for_status()
draft = response.json()
for upload in draft["uploads"]:
with open("character.png", "rb") as f:
put = requests.put(upload["url"], data=f, headers=upload["required_headers"])
put.raise_for_status()
```
## Next step
`PUT` the raw bytes of each declared source to its `uploads[].url` with `uploads[].required_headers`, before `expires_at`. Then call [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create) with `Content-Type: application/json`, the returned `draft_id`, and an `upload_completions` entry per uploaded slot.
# Using Viggle-Animate
Source: https://docs.viggle.ai/v1/api-reference/videos/create-from-character-animation
openapi.yaml POST /v1/videos
Generate a character-animation video from character images — up to four on their own, or up to five with a driving video.
Send `multipart/form-data` — or a plain `application/json` body — with one or more character images (`character_image`/`character_image_url`/`character_image_urls`) and, optionally, a driving video (`driving_video`/`driving_video_url`). Supplying any of these fields selects the Viggle-Animate character-animation mode of `POST /v1/videos`.
A driving video is optional. Without one, the request may contain up to four character images, all references of the same character. With a driving video, it may contain up to five — each image replaces one person in the driving video. This is also the only mode that accepts a plain JSON body as an alternative to `multipart/form-data`.
This uses the same `POST /v1/videos` endpoint as [Generate Video (from Text)](/v1/api-reference/videos/create-from-text), [Generate Video (from First Frame and/or Text)](/v1/api-reference/videos/create-from-first-frame), [Generate Video (from First-Last Frames and/or Text)](/v1/api-reference/videos/create-from-first-last-frame), and [Generate Video (from Reference Video/Image)](/v1/api-reference/videos/create-from-reference-video) — which mode runs is selected by which fields you send, not by a separate parameter. Character-animation fields cannot be combined with `quality` or any frame/reference field from the other four modes; doing so answers `400 INVALID_REQUEST` with "driving\_video\_url/character\_image\_url cannot be combined with quality".
Character-animation mode is billed at **1 credit ($0.01) per second of the driving video**, rounded up (5–16 credits), or a fixed **11 credits ($0.11)** without a driving video. There's no caller-supplied `duration_s` for this mode, and no `quality`, `resolution`, `aspect_ratio`, or `seed`. See [Pricing](#pricing).
## Request parameters
Send `multipart/form-data`, or JSON (see below).
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `driving_video` | file | No | — | Optional driving video uploaded directly. Mutually exclusive with `driving_video_url`. When present, up to five character images are allowed, one per person to replace. Must be 5–15 seconds, at least 64x64, with even dimensions. |
| `driving_video_url` | string (URI) | No | — | Optional `https://` driving-video URL. Mutually exclusive with `driving_video`. When present, up to five character images are allowed, one per person to replace. |
| `character_image` | file (repeatable) | One of | — | Uploaded character reference images: one to four without a driving video, one to five with one. Mutually exclusive with URL-form character images. |
| `character_image_url` | string (URI), repeatable | One of | — | Character image URLs: one to four without a driving video, one to five with one. Mutually exclusive with `character_image_urls` and uploaded images. |
| `character_image_urls` | array of string (URI) | One of | — | Same as `character_image_url`, supplied as one JSON-array field. One to four entries without a driving video; one to five with it. |
| `prompt` | string | No | — | Optional style/content guidance. Unlike every other mode, not required and not enforced non-empty. |
| `watermark` | boolean | No | `false` | Whether to burn in a Viggle watermark. Same field and convention as the other four modes. |
| `priority` | integer | No | `1000` | Admission-queue priority tier, shared with H3 modes. Must be one of the server's configured tiers (currently `1000` or `0`); an unsupported value answers `400 INVALID_REQUEST` — "unsupported priority", and a non-integer answers "priority must be an integer". Most callers never need to set this. |
A driving video is optional. At least one character image is always required. Without a driving video, up to four character images are accepted; with one, up to five. Going over answers `400 INVALID_REQUEST` — "at most 4 character images are allowed" (or "at most 5 ..." with a driving video).
### Driving video vs. character image
* **Driving video** (`driving_video` or `driving_video_url`): optional. If supplied, use exactly one of the file or URL form. It must be 5–15 seconds long (inclusive, truncated to whole seconds first), at least 64x64, with even width and height.
* **Character images** (`character_image`, `character_image_url`, or `character_image_urls`): at least one is required. Supply uploads or URLs, not both; the two URL spellings are mutually exclusive. Without a driving video, up to four images are accepted, all references of one character. With a driving video, up to five are accepted, and each image replaces one person in the video.
Every URL — driving video or character image — must be `https://`; anything else (a bare `http://` or a `gs://` path, for example) answers `400 INVALID_REQUEST` with "each character\_image\_url must be an https\:// URL" (or the `driving_video_url` equivalent). An uploaded `character_image` that isn't a valid, decodable image answers `400 INVALID_REQUEST` — "character\_image must be a valid image".
## JSON request body
Character-animation mode alone also accepts a plain JSON body (`Content-Type: application/json`) as an alternative to `multipart/form-data`, native arrays included. JSON requests can only carry URL-form fields — `driving_video`/`character_image` uploads have no bytes to attach to a JSON body, so use `driving_video_url` and `character_image_url`/`character_image_urls` instead:
```json theme={null}
{
"driving_video_url": "https://example.test/driving.mp4",
"character_image_url": "https://example.test/char1.png",
"prompt": "keep the same outfit and background",
"watermark": false
}
```
The request body must be a single JSON object containing `character_image_url` or `character_image_urls`; `driving_video_url` is optional. An empty or malformed body answers `400 INVALID_REQUEST` — "request body must be a JSON object" (or "JSON animation requests require character images" when no character-image field is present).
Since JSON object keys cannot repeat, use `character_image_urls` when sending two to four character images in JSON.
## Response parameters
Returns `200 OK` — an acceptance acknowledgment, not the final result.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public video ID, `anim_`-prefixed. |
| `status` | string | Yes | Always `queued` on acceptance. |
| `progress` | integer or null | Yes | Always `null` on acceptance. |
| `created_at` | string | Yes | ISO 8601 creation timestamp, second precision. |
```json theme={null}
{
"id": "anim_7c1e4b",
"status": "queued",
"progress": null,
"created_at": "2026-09-09T09:12:03Z"
}
```
This response deliberately carries none of the fields `GET /v1/videos/{video_id}` returns (`video_url`, `completed_at`, `error`). See [Get Video](/v1/api-reference/videos/get) for the full shape once the video is processing or done — character-animation's response there carries no `seed` (H3-only) alongside the fields every video shares.
## Pricing
Character-animation mode is billed at **1 credit (\$0.01) per second**, rounded up. What the request reserves depends on whether it has a driving video:
* **With a driving video**, the service measures the driving video's duration at submission and reserves ⌈duration⌉ credits — **5–16 credits ($0.05–$0.16)**. The duration check truncates to whole seconds before comparing against 5–15, so a 15.9-second clip is accepted and reserves 16.
* **Without a driving video** (images only), there's nothing to measure, so every request reserves a fixed **11 credits (\$0.11)**.
Once the video is `ready`, the charge is settled against the finished video's duration at the same rate, capped at the reservation — settlement can only refund, never charge more than was reserved. A `failed` task is refunded in full. `watermark` does not affect price. See [Pricing and retention](/v1/pricing).
## Examples
```bash cURL (image-only, multiple characters) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F character_image_url=https://example.test/char1.png \
-F character_image_url=https://example.test/char2.png
```
```bash cURL (multipart, driving video + character image URLs) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F driving_video_url=https://example.test/driving.mp4 \
-F character_image_url=https://example.test/char1.png
```
```bash cURL (multipart, uploaded files) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="keep the same outfit and background" \
-F driving_video=@driving.mp4 \
-F character_image=@char1.png
```
```bash cURL (JSON body) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"driving_video_url": "https://example.test/driving.mp4",
"character_image_url": "https://example.test/char1.png"
}'
```
```python Python theme={null}
import os, requests
response = requests.post(
"https://apis.viggle.ai/v1/videos",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}", "Content-Type": "application/json"},
json={
"driving_video_url": "https://example.test/driving.mp4",
"character_image_url": "https://example.test/char1.png",
},
)
response.raise_for_status()
video = response.json()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/videos", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VIGGLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
driving_video_url: "https://example.test/driving.mp4",
character_image_url: "https://example.test/char1.png",
}),
});
const video = await response.json();
```
## Common errors
| Error | Cause |
| - | - |
| `400 INVALID_REQUEST` — "character images are required" | Neither `character_image_url` nor `character_image_urls` supplied, and no `character_image` uploaded. |
| `400 INVALID_REQUEST` — "supply only one of character\_image\_url or character\_image\_urls" | Both URL fields supplied together. |
| `400 INVALID_REQUEST` — "supply character images as uploads or as character\_image\_url/character\_image\_urls, not both" | A `character_image` upload supplied alongside `character_image_url`/`character_image_urls`. |
| `400 INVALID_REQUEST` — "at most 4 character images are allowed" | More than four character images supplied without a driving video. |
| `400 INVALID_REQUEST` — "at most 5 character images are allowed" | More than five character images supplied together with a driving video. |
| `400 INVALID_REQUEST` — "each character\_image\_url must be an https\:// URL" (or "...character\_image\_urls...", matching whichever field you used) | A character image URL is not an `https://` URL. |
| `400 INVALID_REQUEST` — "character\_image must be a valid image" | An uploaded `character_image` file couldn't be decoded as an image. |
| `400 INVALID_REQUEST` — "supply only one of driving\_video or driving\_video\_url" | Both the uploaded file and URL form of the driving video supplied together. |
| `400 INVALID_REQUEST` — "driving\_video\_url must be an https\:// URL" | `driving_video_url` is not an `https://` URL. |
| `400 INVALID_REQUEST` — "driving\_video\_url must be a single URL" | `driving_video_url` supplied more than once. |
| `400 INVALID_REQUEST` — "failed to fetch driving\_video\_url" | The driving video URL couldn't be fetched. |
| `400 INVALID_REQUEST` — "invalid or oversized driving\_video upload" | An uploaded `driving_video` was empty, unreadable, or exceeded the 100 MiB video limit. |
| `400 INVALID_REQUEST` — "driving\_video\_url duration must be between 5 and 15 seconds (inclusive)" | The driving video (uploaded or fetched from `driving_video_url`) was outside the 5–15 second window — the message names `driving_video_url` even when the video was uploaded via `driving_video`. |
| `400 INVALID_REQUEST` — "driving\_video\_url/character\_image\_url cannot be combined with quality" | `quality` (or another H3-mode field) supplied alongside a character-animation field. |
| `400 INVALID_REQUEST` — "unsupported priority" | `priority` set to a value outside the server's configured tiers. |
| `400 INVALID_REQUEST` — "priority must be an integer" | `priority` isn't parseable as an integer. |
## Next step
Use [Get Video](/v1/api-reference/videos/get) with the returned `id` to poll for the result.
Generate from a prompt alone, with no frame image or reference.
Carry a subject or style from a reference video and/or up to 4 images, on the H3 backend instead.
# Generate Video (from First Frame and/or Text)
Source: https://docs.viggle.ai/v1/api-reference/videos/create-from-first-frame
openapi.yaml POST /v1/videos
Generate a MiniMax H3 video anchored on a starting frame, uploaded directly or by URL.
Send `multipart/form-data` with a first frame — as an uploaded file *or* a URL, never both — and no last frame. Supplying a first frame with no last frame is what selects this mode of `POST /v1/videos`, generating a Viggle-optimized MiniMax H3 video with native audio.
This uses the same `POST /v1/videos` endpoint as [Generate Video (from Text)](/v1/api-reference/videos/create-from-text), [Generate Video (from First-Last Frames and/or Text)](/v1/api-reference/videos/create-from-first-last-frame), [Generate Video (from Reference Video/Image)](/v1/api-reference/videos/create-from-reference-video), and [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation) — which mode runs is selected by which fields you send, not by a separate parameter. A first frame cannot be combined with any reference or character-animation field.
Every generated video includes **native audio** — there's no separate audio flag or field. **Pricing** (1 credit = $0.01): ⌈`duration_s`⌉ × the per-second rate for `resolution` — **$0.01/sec (1 credit/second)\*\* at `480p`/`768p`/`1080p`/`2k` for both `low` and `high`, rising to **\$0.05/sec (5 credits/second)** at `4k`. Image-conditioned generation costs the same as text-only for the same quality, resolution, and duration; `aspect_ratio` and `enhance` don't affect price. See [Pricing and retention](/v1/pricing).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `prompt` | string | Yes | — | Non-empty text description of the desired video. Still required even with a first frame supplied. |
| `quality` | string | Yes | — | `low` for faster generation and quicker iteration, or `high` for higher-fidelity output. Both tiers are billed at the same per-second rate for a given `resolution`. |
| `first_frame_image` | file | One of | — | First-frame image uploaded directly. Supply exactly one of `first_frame_image` or `first_frame_image_url`. |
| `first_frame_image_url` | string (URI) | One of | — | Publicly reachable URL of the first-frame image; the service fetches and re-hosts it. Supply instead of `first_frame_image`. |
| `duration_s` | number | No | `5` | Requested video duration in seconds, 5–15 inclusive; defaults to 5. Rounded up to determine the credit charge. |
| `resolution` | string | No | `768p` | `480p`, `768p`, `1080p`, `2k`, or `4k`. `4k` is billed at **$0.05/sec (5 credits/second)**; the rest are billed at **$0.01/sec (1 credit/second)**. |
| `aspect_ratio` | string | No | `16:9` | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, or `21:9`. Does not affect price. |
| `seed` | integer | No | — | ≥ 0. Omit for a random seed. |
| `watermark` | boolean | No | `false` | Whether to burn in a Viggle watermark. |
| `enhance` | boolean | No | `true` | Whether to run a low-effort AI Planner pass that rewrites `prompt` before generation — the supplied first frame is unchanged. Pass `false` to use `prompt` exactly as given. Does not affect price. |
| `priority` | integer | No | `1000` | Admission-queue priority tier. Must be one of the server's configured tiers (currently `1000` or `0`). Most callers never need to set this. |
Provide exactly one of `first_frame_image` or `first_frame_image_url`. Supplying both answers `400 INVALID_REQUEST` — "supply only one of first\_frame\_image or first\_frame\_image\_url".
### Image limits
These apply to `first_frame_image` and `first_frame_image_url`:
* **Size:** up to **20 MiB** per uploaded image; an image fetched from a URL field may be up to **30 MiB**.
* **Format:** detected from the image bytes, not the file extension or `Content-Type` — JPEG, PNG, WebP, GIF, BMP, TIFF, or HEIC/HEIF/AVIF.
* **Dimensions:** no minimum or maximum. An image at or above 4K — long edge ≥ 3840 px or short edge ≥ 2160 px — is downscaled to fit within 1920×1080 (1080×1920 for portrait), keeping its aspect ratio.
An oversized or empty upload answers `400 INVALID_REQUEST` — "invalid or oversized first\_frame\_image upload" (naming the field), and an upload that can't be decoded answers "first\_frame\_image must be a valid image". For the URL form, an image over the limit answers "failed to fetch first\_frame\_image\_url: media exceeds size limit", and one that can't be decoded answers "first\_frame\_image\_url is not a valid image".
## Response parameters
Returns `200 OK` — an acceptance acknowledgment, not the final result.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public video ID, `vid_`-prefixed. |
| `status` | string | Yes | Always `queued` on acceptance. |
| `progress` | integer or null | Yes | Always `null` on acceptance. |
| `created_at` | string | Yes | ISO 8601 creation timestamp, second precision. |
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
This response deliberately carries none of the fields `GET /v1/videos/{video_id}` returns (`stage`, `video_url`, `alpha_url`, `completed_at`, `error`). See [Get Video](/v1/api-reference/videos/get) for the full shape once the video is processing or done.
## Examples
```bash cURL (file upload) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera slowly zooms out" \
-F quality=high \
-F first_frame_image=@first.png
```
```bash cURL (URL) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera slowly zooms out" \
-F quality=high \
-F first_frame_image_url=https://example.test/first.png
```
```python Python theme={null}
import os, requests
with open("first.png", "rb") as image:
response = requests.post(
"https://apis.viggle.ai/v1/videos",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
data={"prompt": "camera slowly zooms out", "quality": "high"},
files={"first_frame_image": image},
)
response.raise_for_status()
video = response.json()
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("prompt", "camera slowly zooms out");
form.append("quality", "high");
form.append("first_frame_image", firstFrameFile);
const response = await fetch("https://apis.viggle.ai/v1/videos", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const video = await response.json();
```
## Common errors
| Error | Cause |
| - | - |
| `400 INVALID_REQUEST` — "supply only one of first\_frame\_image or first\_frame\_image\_url" | Both the file and URL form of the first frame supplied together. |
| `400 INVALID_REQUEST` — "enhance must be a boolean" | `enhance` supplied with a value that isn't parseable as a boolean. |
| `400 INVALID_REQUEST` — "invalid render request: unsupported priority 500" | `priority` set to an integer outside the server's configured tiers (currently `1000` or `0`); the message ends with the value you sent (here `500`). |
| `400 INVALID_REQUEST` — "priority must be an integer" | `priority` isn't parseable as an integer. |
## Next step
Use [Get Video](/v1/api-reference/videos/get) with the returned `id` to poll for the result.
Follow the complete create-and-poll flow.
Generate from a prompt alone, with no frame image.
Also anchor the end of the video on a last frame.
Carry a subject or style from a reference video and/or up to 4 images.
Apply a driving video's motion and/or character images on a separate backend.
# Generate Video (from First-Last Frames and/or Text)
Source: https://docs.viggle.ai/v1/api-reference/videos/create-from-first-last-frame
openapi.yaml POST /v1/videos
Generate a MiniMax H3 video anchored on both a starting and an ending frame.
Send `multipart/form-data` with both a first frame and a last frame, each independently as an uploaded file or a URL. Supplying both is what selects this mode of `POST /v1/videos` — a last frame is only valid alongside a first frame. Generates a Viggle-optimized MiniMax H3 video with native audio.
This uses the same `POST /v1/videos` endpoint as [Generate Video (from Text)](/v1/api-reference/videos/create-from-text), [Generate Video (from First Frame and/or Text)](/v1/api-reference/videos/create-from-first-frame), [Generate Video (from Reference Video/Image)](/v1/api-reference/videos/create-from-reference-video), and [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation) — which mode runs is selected by which fields you send, not by a separate parameter. First/last frames cannot be combined with any reference or character-animation field.
Every generated video includes **native audio** — there's no separate audio flag or field. **Pricing** (1 credit = $0.01): ⌈`duration_s`⌉ × the per-second rate for `resolution` — **$0.01/sec (1 credit/second)\*\* at `480p`/`768p`/`1080p`/`2k` for both `low` and `high`, rising to **\$0.05/sec (5 credits/second)** at `4k`. Image-conditioned generation costs the same as text-only for the same quality, resolution, and duration; `aspect_ratio` and `enhance` don't affect price. See [Pricing and retention](/v1/pricing).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `prompt` | string | Yes | — | Non-empty text description of the desired video. Still required even with frame images supplied. |
| `quality` | string | Yes | — | `low` for faster generation and quicker iteration, or `high` for higher-fidelity output. Both tiers are billed at the same per-second rate for a given `resolution`. |
| `first_frame_image` | file | One of | — | First-frame image uploaded directly. Supply exactly one of `first_frame_image` or `first_frame_image_url`. |
| `first_frame_image_url` | string (URI) | One of | — | Publicly reachable URL of the first-frame image; the service fetches and re-hosts it. Supply instead of `first_frame_image`. |
| `last_frame_image` | file | One of | — | Last-frame image uploaded directly. Supply exactly one of `last_frame_image` or `last_frame_image_url`. Requires a first frame. |
| `last_frame_image_url` | string (URI) | One of | — | Publicly reachable URL of the last-frame image; the service fetches and re-hosts it. Supply instead of `last_frame_image`. Requires a first frame. |
| `duration_s` | number | No | `5` | Requested video duration in seconds, 5–15 inclusive; defaults to 5. Rounded up to determine the credit charge. |
| `resolution` | string | No | `768p` | `480p`, `768p`, `1080p`, `2k`, or `4k`. `4k` is billed at **$0.05/sec (5 credits/second)**; the rest are billed at **$0.01/sec (1 credit/second)**. |
| `aspect_ratio` | string | No | `16:9` | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, or `21:9`. Does not affect price. |
| `seed` | integer | No | — | ≥ 0. Omit for a random seed. |
| `watermark` | boolean | No | `false` | Whether to burn in a Viggle watermark. |
| `enhance` | boolean | No | `true` | Whether to run a low-effort AI Planner pass that rewrites `prompt` before generation — both supplied frames are unchanged. Pass `false` to use `prompt` exactly as given. Does not affect price. |
| `priority` | integer | No | `1000` | Admission-queue priority tier. Must be one of the server's configured tiers (currently `1000` or `0`). Most callers never need to set this. |
Provide exactly one of `first_frame_image`/`first_frame_image_url`, and exactly one of `last_frame_image`/`last_frame_image_url`. Supplying both forms of the same slot answers `400 INVALID_REQUEST` — "supply only one of first\_frame\_image or first\_frame\_image\_url" (or the `last_frame_*` equivalent). Supplying a last frame with no first frame answers `400 INVALID_REQUEST` — "a last frame requires a first frame".
### Image limits
These apply to both frame slots — `first_frame_image`/`first_frame_image_url` and `last_frame_image`/`last_frame_image_url`:
* **Size:** up to **20 MiB** per uploaded image; an image fetched from a URL field may be up to **30 MiB**.
* **Format:** detected from the image bytes, not the file extension or `Content-Type` — JPEG, PNG, WebP, GIF, BMP, TIFF, or HEIC/HEIF/AVIF.
* **Dimensions:** no minimum or maximum. An image at or above 4K — long edge ≥ 3840 px or short edge ≥ 2160 px — is downscaled to fit within 1920×1080 (1080×1920 for portrait), keeping its aspect ratio.
An oversized or empty upload answers `400 INVALID_REQUEST` — "invalid or oversized first\_frame\_image upload" (naming the field), and an upload that can't be decoded answers "first\_frame\_image must be a valid image". For the URL form, an image over the limit answers "failed to fetch first\_frame\_image\_url: media exceeds size limit", and one that can't be decoded answers "first\_frame\_image\_url is not a valid image".
## Response parameters
Returns `200 OK` — an acceptance acknowledgment, not the final result.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public video ID, `vid_`-prefixed. |
| `status` | string | Yes | Always `queued` on acceptance. |
| `progress` | integer or null | Yes | Always `null` on acceptance. |
| `created_at` | string | Yes | ISO 8601 creation timestamp, second precision. |
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
This response deliberately carries none of the fields `GET /v1/videos/{video_id}` returns (`stage`, `video_url`, `alpha_url`, `completed_at`, `error`). See [Get Video](/v1/api-reference/videos/get) for the full shape once the video is processing or done.
## Examples
```bash cURL theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera pans across the room" \
-F quality=low \
-F first_frame_image_url=https://example.test/first.png \
-F last_frame_image_url=https://example.test/last.png
```
```python Python theme={null}
import os, requests
response = requests.post(
"https://apis.viggle.ai/v1/videos",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
data={
"prompt": "camera pans across the room",
"quality": "low",
"first_frame_image_url": "https://example.test/first.png",
"last_frame_image_url": "https://example.test/last.png",
},
)
response.raise_for_status()
video = response.json()
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("prompt", "camera pans across the room");
form.append("quality", "low");
form.append("first_frame_image_url", "https://example.test/first.png");
form.append("last_frame_image_url", "https://example.test/last.png");
const response = await fetch("https://apis.viggle.ai/v1/videos", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const video = await response.json();
```
## Common errors
| Error | Cause |
| - | - |
| `400 INVALID_REQUEST` — "a last frame requires a first frame" | `last_frame_image`/`last_frame_image_url` supplied without a first frame. |
| `400 INVALID_REQUEST` — "supply only one of first\_frame\_image or first\_frame\_image\_url" | Both the file and URL form of the same frame slot supplied (the `last_frame_*` pair answers the equivalent message). |
| `400 INVALID_REQUEST` — "enhance must be a boolean" | `enhance` supplied with a value that isn't parseable as a boolean. |
| `400 INVALID_REQUEST` — "invalid render request: unsupported priority 500" | `priority` set to an integer outside the server's configured tiers (currently `1000` or `0`); the message ends with the value you sent (here `500`). |
| `400 INVALID_REQUEST` — "priority must be an integer" | `priority` isn't parseable as an integer. |
## Next step
Use [Get Video](/v1/api-reference/videos/get) with the returned `id` to poll for the result.
Follow the complete create-and-poll flow.
Generate from a prompt alone, with no frame image.
Anchor only the start of the video, with no last frame.
Carry a subject or style from a reference video and/or up to 4 images.
Apply a driving video's motion and/or character images on a separate backend.
# Generate Video (from Reference Video/Image)
Source: https://docs.viggle.ai/v1/api-reference/videos/create-from-reference-video
openapi.yaml POST /v1/videos
Generate a MiniMax H3 video that carries the subject and/or style of a reference video and up to 4 reference images (omni-reference).
Send `multipart/form-data` with at least one of `reference_video`, `reference_video_url`, `reference_image`, or `reference_image_url`. Supplying any of these four fields is what selects this mode of `POST /v1/videos` — reference-video (omni-reference) generation — and produces a Viggle-optimized MiniMax H3 video with native audio.
Upload a reference video and generate your first result with cURL, Node, or Python.
If you supply none of the four reference fields, this is **not** an error — the request silently falls back to [Generate Video (from Text)](/v1/api-reference/videos/create-from-text) instead, the same way omitting `first_frame_image`/`last_frame_image` there falls back to text-only. There is no `source`/`mode` parameter to force reference-video selection independently of these fields, so double-check your request actually includes one of them before relying on this mode.
This uses the same `POST /v1/videos` endpoint as [Generate Video (from Text)](/v1/api-reference/videos/create-from-text), [Generate Video (from First Frame and/or Text)](/v1/api-reference/videos/create-from-first-frame), [Generate Video (from First-Last Frames and/or Text)](/v1/api-reference/videos/create-from-first-last-frame), and [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation) — which mode runs is selected by which fields you send, not by a separate parameter. Reference fields cannot be combined with `first_frame_image`/`last_frame_image`.
Every generated video includes **native audio** — there's no separate audio flag or field. **Pricing** (1 credit = $0.01): ⌈`duration_s`⌉ × the per-second rate for `resolution` — **$0.01/sec (1 credit/second)\*\* at `480p`/`768p`/`1080p`/`2k` for both `low` and `high`, rising to **\$0.05/sec (5 credits/second)** at `4k`. Reference-conditioned generation costs the same as text-only for the same quality, resolution, and duration; `aspect_ratio` and `enhance` don't affect price. See [Pricing and retention](/v1/pricing).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `prompt` | string | Yes | — | Non-empty text description of the desired video. Still required with reference video/images supplied. |
| `quality` | string | Yes | — | `low` for faster generation and quicker iteration, or `high` for higher-fidelity output. Both tiers are billed at the same per-second rate for a given `resolution`. |
| `reference_video` | file | See below | — | Reference video uploaded directly. Declared as a repeatable field for forward compatibility, but at most 1 reference video is accepted today, combined with `reference_video_url`. Not required if at least one reference image is supplied instead. |
| `reference_video_url` | string (URI) | See below | — | Publicly reachable URL of the reference video; the service fetches and re-hosts it. Declared as a repeatable field for forward compatibility, but at most 1 reference video is accepted today, combined with `reference_video`. |
| `reference_image` | file, repeatable | See below | — | Reference image uploaded directly. Can be repeated, and freely mixed with `reference_image_url` — combined total across both is capped at 4. |
| `reference_image_url` | string (URI), repeatable | See below | — | Publicly reachable URL of a reference image; the service fetches and re-hosts it. Can be repeated, and freely mixed with `reference_image` — combined total across both is capped at 4. |
| `duration_s` | number | No | `5` | Requested video duration in seconds, 5–15 inclusive; defaults to 5. Rounded up to determine the credit charge. |
| `resolution` | string | No | `768p` | `480p`, `768p`, `1080p`, `2k`, or `4k`. `4k` is billed at **$0.05/sec (5 credits/second)**; the rest are billed at **$0.01/sec (1 credit/second)**. |
| `aspect_ratio` | string | No | `16:9` | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, or `21:9`. Does not affect price. |
| `seed` | integer | No | — | ≥ 0. Omit for a random seed. |
| `watermark` | boolean | No | `false` | Whether to burn in a Viggle watermark. |
| `enhance` | boolean | No | `true` | Whether to run a low-effort AI Planner pass that rewrites `prompt` before generation — the supplied reference video/images are unchanged. Pass `false` to use `prompt` exactly as given. Does not affect price. |
| `priority` | integer | No | `1000` | Admission-queue priority tier. Must be one of the server's configured tiers (currently `1000` or `0`). Most callers never need to set this. |
Supplying any of `reference_video`/`reference_video_url`/`reference_image`/`reference_image_url` is what selects this mode — there's no separate `source` parameter. Supplying none of the four does **not** error; see the warning above — the request is generated as text-to-video instead.
### Reference video vs. reference images
You can supply a reference video, one or more reference images, or both together in the same request — a reference video is not required if you supply at least one reference image, and vice versa.
* **Reference video** (`reference_video` / `reference_video_url`): both fields are declared as arrays for forward compatibility, but at most 1 reference video is accepted today across the two combined — supplying more (two files, two URLs, or one of each) answers `400 INVALID_REQUEST` — "at most 1 reference video(s) allowed". The reference video itself must be at most **100 MiB** (uploaded or fetched), contain a readable video stream (MP4, MOV, WebM, etc.), be 0.5–600 seconds long, and be at least 64x64 with even width and height — the same upload constraints enforced elsewhere in this API. An oversized upload answers "reference\_video: video exceeds 100 MiB limit"; an oversized URL answers "failed to fetch reference\_video\_url: media exceeds size limit", and any other problem with a fetched video answers "reference\_video\_url is not a valid video". This is independent of `duration_s`, which controls the duration of the *generated* output video, not the reference.
* **Reference images** (`reference_image` / `reference_image_url`): each can be repeated and the two forms can be freely mixed, but the combined count across both cannot exceed 4. Going over answers `400 INVALID_REQUEST` — "at most 4 reference images are allowed". Each uploaded image may be up to **20 MiB**, and each image fetched from `reference_image_url` up to **30 MiB**. The format is detected from the image bytes — JPEG, PNG, WebP, GIF, BMP, TIFF, or HEIC/HEIF/AVIF — with no minimum or maximum dimensions; an image at or above 4K (long edge ≥ 3840 px or short edge ≥ 2160 px) is downscaled to fit within 1920×1080, keeping its aspect ratio. An image that can't be decoded answers "reference\_image must be a valid image" (upload) or "reference\_image\_url is not a valid image" (URL).
Reference fields cannot be combined with `first_frame_image`/`last_frame_image` (file or URL form). Supplying both answers `400 INVALID_REQUEST` — "reference\_video/reference\_image cannot be combined with first\_frame\_image/last\_frame\_image".
## Response parameters
Returns `200 OK` — an acceptance acknowledgment, not the final result.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public video ID, `vid_`-prefixed. |
| `status` | string | Yes | Always `queued` on acceptance. |
| `progress` | integer or null | Yes | Always `null` on acceptance. |
| `created_at` | string | Yes | ISO 8601 creation timestamp, second precision. |
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
This response deliberately carries none of the fields `GET /v1/videos/{video_id}` returns (`stage`, `video_url`, `alpha_url`, `completed_at`, `error`). See [Get Video](/v1/api-reference/videos/get) for the full shape once the video is processing or done.
## Examples
```bash cURL (video file + one reference image URL) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="keep the same outfit and walk forward" \
-F quality=high \
-F reference_video=@./reference.mp4 \
-F reference_image_url=https://example.test/ref1.png \
-F duration_s=8
```
```bash cURL (URL only, multiple reference images) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="a dancer in the same style" \
-F quality=low \
-F reference_video_url=https://example.test/ref.mp4 \
-F reference_image_url=https://example.test/img1.png \
-F reference_image_url=https://example.test/img2.png
```
```bash cURL (reference images only, no video) theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="combine these two references" \
-F quality=low \
-F reference_image=@./img1.png \
-F reference_image=@./img2.png
```
```python Python theme={null}
import os, requests
response = requests.post(
"https://apis.viggle.ai/v1/videos",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
data={
"prompt": "keep the same outfit and walk forward",
"quality": "high",
"reference_video_url": "https://example.test/ref.mp4",
"duration_s": 8,
},
)
response.raise_for_status()
video = response.json()
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("prompt", "a dancer in the same style");
form.append("quality", "low");
form.append("reference_video_url", "https://example.test/ref.mp4");
form.append("reference_image_url", "https://example.test/img1.png");
form.append("reference_image_url", "https://example.test/img2.png");
const response = await fetch("https://apis.viggle.ai/v1/videos", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const video = await response.json();
```
### `@viggle/sdk`
```javascript theme={null}
const video = await client.videos.create({
prompt: "keep the same outfit and walk forward",
quality: "high",
referenceVideoUrl: "https://example.com/ref.mp4",
referenceImageUrl: ["https://example.com/img1.png", "https://example.com/img2.png"],
durationS: 8,
});
// video.id, video.status ("queued", ...); poll with client.videos.get(video.id)
```
## Common errors
| Error | Cause |
| - | - |
| `400 INVALID_REQUEST` — "at most 1 reference video(s) allowed" | `reference_video` and/or `reference_video_url` together supply more than 1 reference video. |
| `400 INVALID_REQUEST` — "at most 4 reference images are allowed" | `reference_image` and `reference_image_url` together exceed 4 images. |
| `400 INVALID_REQUEST` — "reference\_video/reference\_image cannot be combined with first\_frame\_image/last\_frame\_image" | A reference field supplied alongside `first_frame_image`/`last_frame_image` (file or URL form). |
| `400 INVALID_REQUEST` — "enhance must be a boolean" | `enhance` supplied with a value that isn't parseable as a boolean. |
| `400 INVALID_REQUEST` — "invalid render request: unsupported priority 500" | `priority` set to an integer outside the server's configured tiers (currently `1000` or `0`); the message ends with the value you sent (here `500`). |
| `400 INVALID_REQUEST` — "priority must be an integer" | `priority` isn't parseable as an integer. |
## Next step
Use [Get Video](/v1/api-reference/videos/get) with the returned `id` to poll for the result.
Generate from a prompt alone, with no frame image or reference.
Anchor only the start of the video, with no last frame.
Also anchor the end of the video on a last frame.
Apply a driving video's motion and/or character images on a separate backend.
# Generate Video (from Text)
Source: https://docs.viggle.ai/v1/api-reference/videos/create-from-text
openapi.yaml POST /v1/videos
Generate a MiniMax H3 video from a text prompt alone.
Send `multipart/form-data` with `prompt` and `quality` only — no frame fields. This is the plain text-to-video mode of `POST /v1/videos`, generating a Viggle-optimized MiniMax H3 video with native audio.
This uses the same `POST /v1/videos` endpoint as [Generate Video (from First Frame and/or Text)](/v1/api-reference/videos/create-from-first-frame), [Generate Video (from First-Last Frames and/or Text)](/v1/api-reference/videos/create-from-first-last-frame), [Generate Video (from Reference Video/Image)](/v1/api-reference/videos/create-from-reference-video), and [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation) — which mode runs is selected by which fields you send, not by a separate parameter. Supplying no frame fields and no reference or character-animation fields at all is what selects this mode.
Every generated video includes **native audio** — there's no separate audio flag or field. **Pricing** (1 credit = $0.01): ⌈`duration_s`⌉ × the per-second rate for `resolution` — **$0.01/sec (1 credit/second)\*\* at `480p`/`768p`/`1080p`/`2k` for both `low` and `high`, rising to **\$0.05/sec (5 credits/second)** at `4k`. `aspect_ratio` and `enhance` don't affect price. See [Pricing and retention](/v1/pricing).
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `prompt` | string | Yes | — | Non-empty text description of the desired video. |
| `quality` | string | Yes | — | `low` for faster generation and quicker iteration, or `high` for higher-fidelity output. Both tiers are billed at the same per-second rate for a given `resolution`. |
| `duration_s` | number | No | `5` | Requested video duration in seconds, 5–15 inclusive; defaults to 5. Rounded up to determine the credit charge. |
| `resolution` | string | No | `768p` | `480p`, `768p`, `1080p`, `2k`, or `4k`. `4k` is billed at **$0.05/sec (5 credits/second)**; the rest are billed at **$0.01/sec (1 credit/second)**. |
| `aspect_ratio` | string | No | `16:9` | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, or `21:9`. Does not affect price. |
| `seed` | integer | No | — | ≥ 0. Omit for a random seed. |
| `watermark` | boolean | No | `false` | Whether to burn in a Viggle watermark. |
| `enhance` | boolean | No | `true` | Whether to run a low-effort AI Planner pass that rewrites `prompt` before generation. Pass `false` to use `prompt` exactly as given. Does not affect price. |
| `priority` | integer | No | `1000` | Admission-queue priority tier. Must be one of the server's configured tiers (currently `1000` or `0`). Most callers never need to set this. |
## Response parameters
Returns `200 OK` — an acceptance acknowledgment, not the final result.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Public video ID, `vid_`-prefixed. |
| `status` | string | Yes | Always `queued` on acceptance. |
| `progress` | integer or null | Yes | Always `null` on acceptance. |
| `created_at` | string | Yes | ISO 8601 creation timestamp, second precision. |
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
This response deliberately carries none of the fields `GET /v1/videos/{video_id}` returns (`stage`, `video_url`, `alpha_url`, `completed_at`, `error`). See [Get Video](/v1/api-reference/videos/get) for the full shape once the video is processing or done.
## Common errors
| Error | Cause |
| - | - |
| `400 INVALID_REQUEST` — "enhance must be a boolean" | `enhance` supplied with a value `strconv.ParseBool` can't parse (only `true`/`false`/`1`/`0`/`t`/`f`, etc. are accepted). |
| `400 INVALID_REQUEST` — "invalid render request: unsupported priority 500" | `priority` set to an integer outside the server's configured tiers (currently `1000` or `0`); the message ends with the value you sent (here `500`). |
| `400 INVALID_REQUEST` — "priority must be an integer" | `priority` isn't parseable as an integer. |
## Examples
```bash cURL theme={null}
curl https://apis.viggle.ai/v1/videos \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="a paper airplane gliding through a sunlit office" \
-F quality=low \
-F duration_s=5
```
```python Python theme={null}
import os, requests
response = requests.post(
"https://apis.viggle.ai/v1/videos",
headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"},
data={"prompt": "a paper airplane gliding through a sunlit office", "quality": "low", "duration_s": 5},
)
response.raise_for_status()
video = response.json()
```
```javascript JavaScript theme={null}
const form = new FormData();
form.append("prompt", "a paper airplane gliding through a sunlit office");
form.append("quality", "low");
form.append("duration_s", "5");
const response = await fetch("https://apis.viggle.ai/v1/videos", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` },
body: form,
});
const video = await response.json();
```
```go Go theme={null}
body := &bytes.Buffer{}; w := multipart.NewWriter(body)
w.WriteField("prompt", "a paper airplane gliding through a sunlit office"); w.WriteField("quality", "low"); w.WriteField("duration_s", "5"); w.Close()
req, _ := http.NewRequest("POST", "https://apis.viggle.ai/v1/videos", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY")); req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req); if err != nil { panic(err) }; defer resp.Body.Close()
```
## Next step
Use [Get Video](/v1/api-reference/videos/get) with the returned `id` to poll for the result.
Follow the complete create-and-poll flow.
Anchor the video on a starting frame instead of text alone.
Anchor the video on both a starting and an ending frame.
Carry a subject or style from a reference video and/or up to 4 images.
Apply a driving video's motion and/or character images on a separate backend.
# Get Video
Source: https://docs.viggle.ai/v1/api-reference/videos/get
openapi.yaml GET /v1/videos/{video_id}
Retrieve the full state of a Render, generated video, or video upscale through one unified endpoint.
`GET /v1/videos/{video_id}` accepts a character+motion Render ID (`render_`), a MiniMax H3 generation ID (`vid_`), a character-animation generation ID (`anim_`), or a [2x video upscale](/v1/api-reference/videos/super-resolution) ID (`vsr_`), and returns its current durable state. Poll every 3–5 seconds until the status is terminal, or use [Watch Render](/v1/api-reference/renders/events) for a push-based alternative on a Render-sourced ID.
This endpoint replaces the retired `GET /v1/renders/{render_id}`.
`GET /v1/renders/{render_id}` no longer accepts `GET` — it now answers `405 Method Not Allowed`, not `404`, because other methods remain registered on that path. A router only answers `404` when a path has no method registered at all; here it still has at least one, so the retired method 405s instead. If your integration checked for a `404` to detect that the old route was gone, accept `405` too, or switch straight to this endpoint.
## Request parameters
| Parameter | Type | Required | Description |
| - | - | :-: | - |
| `video_id` | string | Yes | Full public video ID: `render_...`, `vid_...`, `anim_...`, or `vsr_...`. Poll with the same value every 3–5 seconds until the video reaches a terminal status. |
## Response parameters
Returns `200 OK` with a Video object.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `id` | string | Yes | Requested video ID. |
| `status` | string | Yes | `queued`, `processing`, `ready`, `failed`, or `cancelled`. An H3 (`vid_`) video never reports `cancelled` — a backend cancellation or timeout surfaces as `failed`. |
| `stage` | string or null | Yes | Populated only when this is a Render-sourced video and `status` is `processing`; `null` otherwise, and always `null` for an H3 video. The server returns `null` for every non-`processing` status by design — there is no `stage: "ready"`. |
| `progress` | integer or null | Yes | Progress from 0 to 100, or `null`. |
| `video_url` | string or null | Yes | Downloadable output once ready; `null` until then. |
| `alpha_url` | string or null | Yes | Render-only: populated only when a Render was created with `background_mode=transparent` and is `ready`. Always `null` for an H3 video. |
| `created_at` | string or null | Yes | Render-sourced timestamps carry nanosecond precision; H3-sourced timestamps carry second precision. |
| `completed_at` | string or null | Yes | See **Completion timing** below. |
| `error` | object or null | Yes | Structured failure details when `status` is `failed`. |
| `seed` | integer | H3 only | The seed actually used for generation — a caller-supplied value echoed back, or the value generated when [Generate Video](/v1/api-reference/videos/create-from-text) omitted one. Present only on an H3-origin video (`vid_` prefix); absent — not `null` — on Render-, character-animation-, and upscale-origin videos. Generated seeds usually exceed JavaScript's safe-integer range — see the warning below. |
| `queue_position` | integer or null | `vid_` and `anim_` | Exact number of tasks ahead while the video is `queued`. `null` once processing starts and at terminal states. Omitted entirely for `render_` and `vsr_` videos. |
| `estimated_wait_seconds` | integer or null | `vid_` and `anim_` | Rough estimate based on fleet-wide average render time and current queue/dispatch state — while `queued`, the expected time until the video finishes; while `processing`, the remaining average render time. `null` at terminal states, for queued work below the top priority tier (new top-tier work can keep preempting it), or when no bounded estimate is available. Omitted entirely for `render_` and `vsr_` videos. |
A generated `seed` is a random 63-bit integer (0 to 2^63 − 1), so it almost always exceeds `Number.MAX_SAFE_INTEGER` (2^53 − 1). JavaScript's `JSON.parse` silently rounds it — `4271960385017522688` in the example below would come back as a different number, and resubmitting that value won't reproduce the video. Read the response with a 64-bit-safe JSON parser (for example, `json-bigint`) if you need the exact value, or supply your own `seed` no larger than 2^53 − 1 when creating the video.
This response does not include `links` — unlike the Render object returned by [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create), a video fetched here carries no `self`/`events`/`download` shortcut. Use the same `video_id` for further Get Video calls, and [Download Render](/v1/api-reference/renders/download) for a Render-sourced ID.
## Completion timing
On a Render-sourced video, `completed_at` can lag `status` reaching `ready` by up to roughly 30 seconds before it backfills — it isn't a permanent gap. Decide whether a video is finished by checking `status == "ready"` or whether `video_url` is populated, not by whether `completed_at` is non-null.
### Render-sourced example
```json theme={null}
{
"id": "render_a1b2c3",
"status": "ready",
"stage": null,
"progress": 100,
"video_url": "https://assets.viggle.ai/render_a1b2c3.mp4",
"alpha_url": null,
"created_at": "2026-08-25T09:12:03.123456789Z",
"completed_at": "2026-08-25T09:13:47Z",
"error": null
}
```
### H3-sourced example
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "ready",
"stage": null,
"progress": 100,
"video_url": "https://storage.googleapis.com/...signed...",
"alpha_url": null,
"created_at": "2026-08-24T09:12:03Z",
"completed_at": "2026-08-24T09:13:47Z",
"error": null,
"seed": 4271960385017522688,
"queue_position": null,
"estimated_wait_seconds": null
}
```
### Character-animation-sourced example
```json theme={null}
{
"id": "anim_7c1e4b",
"status": "ready",
"stage": null,
"progress": 100,
"video_url": "https://storage.googleapis.com/...signed...",
"alpha_url": null,
"created_at": "2026-09-09T09:12:03Z",
"completed_at": "2026-09-09T09:13:47Z",
"error": null,
"queue_position": null,
"estimated_wait_seconds": null
}
```
No `seed` field — it's H3-only. `queue_position` and `estimated_wait_seconds` are present on both, `null` once the video is terminal.
### Failed example
```json theme={null}
{
"id": "render_a1b2c3",
"status": "failed",
"stage": null,
"progress": null,
"video_url": null,
"alpha_url": null,
"created_at": "2026-08-25T09:12:03.123456789Z",
"completed_at": "2026-08-25T09:13:50Z",
"error": {
"code": "TASK_FAILED",
"message": "The render could not be completed.",
"retryable": false,
"request_id": "req_123abc",
"details": {},
"remediation": { "action": "contact_support", "retry_after_ms": null }
}
}
```
## Examples
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/videos/render_a1b2c3" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
```python Python theme={null}
import os, requests
response = requests.get("https://apis.viggle.ai/v1/videos/render_a1b2c3", headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
response.raise_for_status()
video = response.json()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/videos/render_a1b2c3", { headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` } });
const video = await response.json();
```
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/videos/render_a1b2c3", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
```
# List Videos
Source: https://docs.viggle.ai/v1/api-reference/videos/list
openapi.yaml GET /v1/videos
List the current principal's videos — character+motion Renders, H3 video generations, and character-animation generations — with cursor pagination.
Returns the authenticated principal's own videos, newest `created_at` first, through opaque cursor pagination. A video is a character+motion Render (`render_` prefix, created with [Render Video (from Character and/or Motion)](/v1/api-reference/renders/create)), a MiniMax H3 video generation (`vid_` prefix, created with [Generate Video (from Text)](/v1/api-reference/videos/create-from-text), [First Frame](/v1/api-reference/videos/create-from-first-frame), [First-Last Frames](/v1/api-reference/videos/create-from-first-last-frame), or [Reference Video/Image](/v1/api-reference/videos/create-from-reference-video)), or a character-animation generation (`anim_` prefix, created with [Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation)); the three sources are merged into one list by `created_at`. This endpoint replaces the retired `GET /v1/renders`.
`GET /v1/renders` no longer accepts `GET` — it now answers `405 Method Not Allowed`, not `404`, because `POST /v1/renders` is still registered on the same path. If your integration checked for a `404` to detect that the old route was gone, accept `405` too, or switch straight to this endpoint.
## Request parameters
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `status` | string | No | — | Optional exact lifecycle filter: `queued`, `processing`, `ready`, `failed`, or `cancelled`. Omit it to include videos in every status. |
| `cursor` | string | No | — | Opaque continuation token from the previous response's `next_cursor`. Pass it unchanged and keep the same filters while paging; maximum length is 512 characters. |
| `limit` | integer | No | `20` | Maximum video summaries returned in one page. Accepts 1–100; use `next_cursor` rather than calculating an offset for later pages. |
## Response parameters
Returns `200 OK`.
| Field | Type | Always present | Description |
| - | - | :-: | - |
| `items` | array | Yes | One page of video summaries, newest first, mixing Render-, H3-, and character-animation-sourced items. |
| `items[].id` | string | Yes | `render_...` for a character+motion Render, `vid_...` for an H3 generation, or `anim_...` for a character-animation generation. |
| `items[].status` | string | Yes | `queued`, `processing`, `ready`, `failed`, or `cancelled`. |
| `items[].stage` | string or null | Yes | Populated only for a Render-sourced item while `status` is `processing`. `null` in every other case — including a Render in `ready`/`queued`/`failed`/`cancelled`, and every H3 or character-animation item regardless of status. This is intentional, not a gap in the data. |
| `items[].progress` | integer or null | Yes | Progress from 0 to 100, or `null`. |
| `items[].created_at` | string or null | Yes | Render-sourced timestamps carry nanosecond precision; H3- and character-animation-sourced timestamps carry second precision. Two items created within the same second can look out of order if you diff the raw strings — the list order itself is correct, only the printed precision differs between sources. |
| `items[].completed_at` | string or null | Yes | See **Completion timing** below. |
A video summary deliberately carries no signed media URL and no failure detail — fetch [Get Video](/v1/api-reference/videos/get) for the full state.
```json theme={null}
{
"items": [
{"id":"render_a1b2c3","status":"ready","stage":null,"progress":100,"created_at":"2026-08-25T09:12:03.123456789Z","completed_at":"2026-08-25T09:13:47Z"},
{"id":"vid_3f2a9c","status":"ready","stage":null,"progress":100,"created_at":"2026-08-24T09:12:03Z","completed_at":"2026-08-24T09:13:47Z"},
{"id":"anim_7c1e4b","status":"ready","stage":null,"progress":100,"created_at":"2026-09-09T09:12:03Z","completed_at":"2026-09-09T09:13:47Z"}
],
"next_cursor": null,
"has_more": false
}
```
## Completion timing
On a Render-sourced item, `completed_at` can lag `status` reaching `ready` by up to roughly 30 seconds before it backfills — it isn't a permanent gap. Decide whether a video is finished by checking `status == "ready"`, not by whether `completed_at` is non-null.
## Examples
```bash cURL theme={null}
curl "https://apis.viggle.ai/v1/videos?status=ready&limit=20" -H "Authorization: Bearer $VIGGLE_API_KEY"
```
```python Python theme={null}
import os, requests
response = requests.get("https://apis.viggle.ai/v1/videos", params={"status": "ready", "limit": 20}, headers={"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"})
response.raise_for_status()
page = response.json()
```
```javascript JavaScript theme={null}
const response = await fetch("https://apis.viggle.ai/v1/videos?status=ready&limit=20", { headers: { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` } });
const page = await response.json();
```
```go Go theme={null}
req, _ := http.NewRequest("GET", "https://apis.viggle.ai/v1/videos?status=ready&limit=20", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("VIGGLE_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
```
To page through results, pass the previous response's `next_cursor` as `cursor` on the next request; stop once `has_more` is `false`.
## Next step
Use [Get Video](/v1/api-reference/videos/get) on any `items[].id` for the full state, including `video_url`.
# Upscale Video (2x)
Source: https://docs.viggle.ai/v1/api-reference/videos/super-resolution
openapi.yaml POST /v1/videos/super-resolution
Upscale an existing video by a fixed 2x with FlashVSR.
Starts an asynchronous fixed-2x video upscale. Supply one source video, then poll [Get Video](/v1/api-reference/videos/get) with the returned `vsr_` ID until it is ready. There is no target-resolution field: a 480p input becomes approximately 1080p, and a 768p input approximately 2K.
The service measures the source duration and charges **1 credit (\$0.01) per second, rounded up**. A 4.2-second source costs 5 credits.
## Request parameters
Send `multipart/form-data`.
| Parameter | Type | Required | Default | Description |
| - | - | :-: | - | - |
| `video` | file | One of | — | Source video upload. Maximum 100 MiB; duration 0.5–600 seconds; dimensions must be at least 64×64 and even. |
| `video_url` | string | One of | — | Publicly reachable source-video URL. Use instead of `video` and keep it accessible during ingestion. |
| `watermark` | boolean | No | `false` | Whether to include the service watermark in the upscaled result. |
Provide exactly one of `video` or `video_url`.
```bash cURL theme={null}
curl -X POST "https://apis.viggle.ai/v1/videos/super-resolution" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "video=@input.mp4" \
-F "watermark=false"
```
## Response
Returns `200 OK` with `{id, status, progress, created_at}`. The ID begins with `vsr_`; use it unchanged with `GET /v1/videos/{video_id}`. The completed Video has `stage=null`, `alpha_url=null`, and no `seed` field.
# Authentication
Source: https://docs.viggle.ai/v1/authentication
Authenticate V1 requests and trace them with request IDs.
All V1 endpoints require a credential. Server-side SDK clients use a project API key; Remote MCP clients use an OAuth access token.
```http theme={null}
Authorization: Bearer YOUR_API_KEY
```
Create and revoke keys in the [Viggle Dashboard](https://portal.viggle.ai/keys). Keep keys in server-side environment variables; never expose them in browser code, mobile apps, or public repositories.
## Example
```bash theme={null}
curl "https://apis.viggle.ai/v1/videos/render_xxx" \
-H "Authorization: Bearer $VIGGLE_API_KEY"
```
## Optional request headers
| Header | Description |
| - | - |
| `X-Request-Id` | Client-supplied request ID for tracing. Every response also includes an `X-Request-Id` you can capture even if you didn't set one. |
| `X-Viggle-Source` | Client-supplied label identifying the calling channel or integration. |
```bash theme={null}
curl "https://apis.viggle.ai/v1/videos/render_xxx" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "X-Request-Id: checkout-render-42" \
-H "X-Viggle-Source: checkout-web"
```
Include the request ID when contacting support so we can trace the request.
## Authentication errors
V1 errors use one envelope:
```json theme={null}
{
"error": {
"code": "INVALID_CREDENTIAL",
"message": "Invalid or expired API key",
"retryable": false,
"request_id": "req_123abc",
"details": {},
"remediation": { "action": "replace_credential", "retry_after_ms": null }
}
}
```
`UNAUTHENTICATED` means no credential was supplied at all; `INVALID_CREDENTIAL` means the key or token is malformed, revoked, or expired; `FORBIDDEN` means the credential is valid but doesn't grant access to the requested resource. See [Errors and recovery](/v1/production/errors) for the full code vocabulary and handling guidance.
# Quick Start: Character
Source: https://docs.viggle.ai/v1/guides/quickstart-character
Create a reusable Character from an image.
Create a reusable **Character** from an image once, then reference its ID from any number of [Video Remix](/v1/guides/quickstart-mix) renders without re-uploading the image. This is the `type=render` path of `POST /v1/characters` — see the [Character 3D Quickstart](/v1/guides/quickstart-character-3d) for the `vsplat` extraction instead.
Set `VIGGLE_API_KEY` and run the example below as-is — it uses a Viggle-hosted sample image, so you don't need to prepare your own character image first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
The examples below reference a placeholder sample asset URL (`https://assets.viggle.ai/samples/character.png`). Swap in your own `image`/`image_url`, or Viggle's published sample asset once available.
## Complete example
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image_url=https://assets.viggle.ai/samples/character.png" \
-F "name=Quickstart character")
character_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
character=$(curl -s "https://apis.viggle.ai/v1/characters/$character_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$character" | jq -r '.status')
case "$status" in
ready)
echo "$character_id"
exit 0
;;
failed)
echo "Character failed: $(echo "$character" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 3
done
echo "Timed out waiting for character $character_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
async function createCharacter() {
const form = new FormData();
form.append("image_url", "https://assets.viggle.ai/samples/character.png");
form.append("name", "Quickstart character");
const response = await fetch(`${baseUrl}/characters`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForCharacter(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/characters/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const character = await response.json();
if (["ready", "failed"].includes(character.status)) return character;
await new Promise((resolve) => setTimeout(resolve, 3000));
}
throw new Error(`Timed out waiting for character ${id}`);
}
const created = await createCharacter();
const character = await waitForCharacter(created.id);
if (character.status !== "ready") {
throw new Error(`Character failed: ${JSON.stringify(character.error)}`);
}
console.log(character.id);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
response = requests.post(
f"{base_url}/characters",
headers=headers,
files={"image_url": (None, "https://assets.viggle.ai/samples/character.png")},
data={"name": "Quickstart character"},
)
response.raise_for_status()
character = response.json()
for _ in range(60):
if character["status"] in {"ready", "failed"}:
break
time.sleep(3)
response = requests.get(f"{base_url}/characters/{character['id']}", headers=headers)
response.raise_for_status()
character = response.json()
else:
raise TimeoutError(f"Timed out waiting for character {character['id']}")
if character["status"] != "ready":
error = character.get("error") or {}
raise RuntimeError(f"failed: {error.get('code')} {error.get('message')}")
print(character["id"])
```
## Response
```json theme={null}
{
"id": "char_550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"name": "Quickstart character",
"progress": 0,
"capabilities": [],
"created_at": "2026-07-31T09:15:22+00:00",
"completed_at": null,
"error": null,
"type": "render",
"vsplat": null,
"glb": null
}
```
Poll `GET /v1/characters/{character_id}` every 3 seconds until `status` is `ready` or `failed`. Once ready, `id` is a **reusable Character ID** — pass it as `character_id` to [Render Video](/v1/api-reference/renders/create) (or the [Video Remix Quickstart](/v1/guides/quickstart-mix)) any number of times, without re-uploading the source image.
See [Pricing and retention](/v1/pricing#character) for how Character creation is billed.
See every request and response field, including the `vsplat`/`all` extraction options.
# Quick Start: Character 3D
Source: https://docs.viggle.ai/v1/guides/quickstart-character-3d
Extract a downloadable 3D Gaussian splat from a character image.
Create a Character with `type=vsplat` to extract a 3D Gaussian splat (`.vsplat`) instead of the render-ready 2D Character. When it's ready, fetch its signed download URL from [Export 3D Character](/v1/api-reference/characters/export). Pass `type=all` instead of `vsplat` if you also want the 2D render kept on the same Character — see the [Character Quickstart](/v1/guides/quickstart-character) for that path alone.
Set `VIGGLE_API_KEY` and run the example below as-is — it uses a Viggle-hosted sample image, so you don't need to prepare your own character image first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
The examples below reference a placeholder sample asset URL (`https://assets.viggle.ai/samples/character.png`). Swap in your own `image`/`image_url`, or Viggle's published sample asset once available.
## Create, poll, and export the Character
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image_url=https://assets.viggle.ai/samples/character.png" \
-F "type=vsplat")
character_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
character=$(curl -s "https://apis.viggle.ai/v1/characters/$character_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$character" | jq -r '.status')
case "$status" in
ready)
exported=$(curl -s "https://apis.viggle.ai/v1/characters/$character_id/export?download_type=vsplat" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
echo "$exported" | jq -r '.vsplat_url'
exit 0
;;
failed)
echo "Character failed: $(echo "$character" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 3
done
echo "Timed out waiting for character $character_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
async function createCharacter() {
const form = new FormData();
form.append("image_url", "https://assets.viggle.ai/samples/character.png");
form.append("type", "vsplat");
const response = await fetch(`${baseUrl}/characters`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForCharacter(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/characters/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const character = await response.json();
if (["ready", "failed"].includes(character.status)) return character;
await new Promise((resolve) => setTimeout(resolve, 3000));
}
throw new Error(`Timed out waiting for character ${id}`);
}
const created = await createCharacter();
const character = await waitForCharacter(created.id);
if (character.status !== "ready") {
throw new Error(`Character failed: ${JSON.stringify(character.error)}`);
}
const exportResponse = await fetch(
`${baseUrl}/characters/${character.id}/export?download_type=vsplat`,
{ headers },
);
if (!exportResponse.ok) {
const body = await exportResponse.json().catch(() => ({}));
throw new Error(`HTTP ${exportResponse.status}: ${JSON.stringify(body.error ?? body)}`);
}
const exported = await exportResponse.json();
console.log(exported.vsplat_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
response = requests.post(
f"{base_url}/characters",
headers=headers,
files={"image_url": (None, "https://assets.viggle.ai/samples/character.png")},
data={"type": "vsplat"},
)
response.raise_for_status()
character = response.json()
for _ in range(60):
if character["status"] in {"ready", "failed"}:
break
time.sleep(3)
response = requests.get(f"{base_url}/characters/{character['id']}", headers=headers)
response.raise_for_status()
character = response.json()
else:
raise TimeoutError(f"Timed out waiting for character {character['id']}")
if character["status"] != "ready":
error = character.get("error") or {}
raise RuntimeError(f"failed: {error.get('code')} {error.get('message')}")
exported = requests.get(
f"{base_url}/characters/{character['id']}/export",
params={"download_type": "vsplat"},
headers=headers,
)
exported.raise_for_status()
print(exported.json()["vsplat_url"])
```
## Response
```json theme={null}
{
"id": "char_550e8400-e29b-41d4-a716-446655440000",
"status": "ready",
"download_type": "vsplat",
"vsplat_url": "https://assets.viggle.ai/results/character.vsplat",
"thumbnail_url": null,
"created_at": "2026-07-31T09:15:22+00:00",
"updated_at": "2026-07-31T09:16:40+00:00",
"error": null
}
```
Poll `GET /v1/characters/{character_id}` every 3 seconds until `status` is `ready` or `failed`. Once ready, request `GET /v1/characters/{character_id}/export?download_type=vsplat` for the signed `vsplat_url` — it's valid for 1 hour and re-signed on every call, so re-request it rather than caching it. The create request above already runs the higher-quality `enhance` extraction pass, since it's on by default; add `enhance=false` for the cheaper standard extraction (a different price — see below), or `render_thumbnail=true` to also get a `thumbnail_url` back from the same export call.
See [Pricing and retention](/v1/pricing#character-3d) for `vsplat`/`enhance`/`all` pricing.
See every request field, including `model_precision`, `enhance`, and `joint_set`.
# Quick Start: First Frame to Video
Source: https://docs.viggle.ai/v1/guides/quickstart-first-frame-to-video
Anchor a MiniMax H3 video on a starting frame, uploaded directly or by URL.
Supply a starting frame alongside a text prompt to `POST /v1/videos`, and poll the returned video until it's ready. `prompt` is still required even though you're also supplying an image — text guides how the frame animates. This is one of four **H3 Video** modes; see also [Text to Video](/v1/guides/quickstart-text-to-video), [First-Last Frames to Video](/v1/guides/quickstart-first-last-frame-to-video), and [Reference to Video](/v1/guides/quickstart-reference-to-video).
Set `VIGGLE_API_KEY` and run the example below as-is — it uses a Viggle-hosted sample image, so you don't need to prepare your own starting frame first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
* A non-empty text prompt and a `quality` choice: `low` or `high`
The example below references a placeholder sample asset URL (`https://assets.viggle.ai/samples/first-frame.png`). Swap in your own `first_frame_image`/`first_frame_image_url`, or Viggle's published sample asset once available.
## Generate and poll the video
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera slowly zooms out" \
-F quality=high \
-F first_frame_image_url=https://assets.viggle.ai/samples/first-frame.png)
video_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
video=$(curl -s "https://apis.viggle.ai/v1/videos/$video_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$video" | jq -r '.status')
case "$status" in
ready)
echo "$video" | jq -r '.video_url'
exit 0
;;
failed|cancelled)
echo "Video $status: $(echo "$video" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for video $video_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
const TERMINAL = new Set(["ready", "failed", "cancelled"]);
async function createVideo() {
const form = new FormData();
form.append("prompt", "camera slowly zooms out");
form.append("quality", "high");
form.append("first_frame_image_url", "https://assets.viggle.ai/samples/first-frame.png");
const response = await fetch(`${baseUrl}/videos`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForVideo(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/videos/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const video = await response.json();
if (TERMINAL.has(video.status)) return video;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for video ${id}`);
}
const created = await createVideo();
const video = await waitForVideo(created.id);
if (video.status !== "ready") {
throw new Error(`Video ${video.status}: ${JSON.stringify(video.error)}`);
}
console.log(video.video_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
TERMINAL_STATES = {"ready", "failed", "cancelled"}
response = requests.post(
f"{base_url}/videos",
headers=headers,
data={"prompt": "camera slowly zooms out", "quality": "high"},
files={"first_frame_image_url": (None, "https://assets.viggle.ai/samples/first-frame.png")},
)
response.raise_for_status()
video = response.json()
for _ in range(60):
if video["status"] in TERMINAL_STATES:
break
time.sleep(5)
response = requests.get(f"{base_url}/videos/{video['id']}", headers=headers)
response.raise_for_status()
video = response.json()
else:
raise TimeoutError(f"Timed out waiting for video {video['id']}")
if video["status"] != "ready":
error = video.get("error") or {}
raise RuntimeError(f"{video['status']}: {error.get('code')} {error.get('message')}")
print(video["video_url"])
```
## Response
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
The create request answers immediately with `status: "queued"` — it's an acceptance acknowledgment, not the final result. Poll `GET /v1/videos/{video_id}` every 3–5 seconds until `status` is `ready`, `failed`, or `cancelled`, then read `video_url` — a signed link, valid for 1 hour and re-signed on every read. Supply exactly one of `first_frame_image` (file upload) or `first_frame_image_url`; supplying both answers `400 INVALID_REQUEST`.
See [Pricing and retention](/v1/pricing#h3-video) — image-conditioned generation costs the same as text-only for the same `quality` and `duration_s`.
See every field, both upload forms, and common validation errors.
# Quick Start: First-Last Frames to Video
Source: https://docs.viggle.ai/v1/guides/quickstart-first-last-frame-to-video
Anchor a MiniMax H3 video on both a starting and an ending frame.
Supply both a starting frame and an ending frame — each independently a file or a URL — alongside a text prompt to `POST /v1/videos`, and poll the returned video until it's ready. A last frame is only valid alongside a first frame; `prompt` is still required. This is one of four **H3 Video** modes; see also [Text to Video](/v1/guides/quickstart-text-to-video), [First Frame to Video](/v1/guides/quickstart-first-frame-to-video), and [Reference to Video](/v1/guides/quickstart-reference-to-video).
Set `VIGGLE_API_KEY` and run the example below as-is — it uses Viggle-hosted sample images, so you don't need to prepare your own frames first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
* A non-empty text prompt and a `quality` choice: `low` or `high`
The example below references placeholder sample asset URLs (`https://assets.viggle.ai/samples/first-frame.png` and `.../last-frame.png`). Swap in your own `first_frame_image`/`first_frame_image_url` and `last_frame_image`/`last_frame_image_url`, or Viggle's published sample assets once available.
## Generate and poll the video
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="camera pans across the room" \
-F quality=low \
-F first_frame_image_url=https://assets.viggle.ai/samples/first-frame.png \
-F last_frame_image_url=https://assets.viggle.ai/samples/last-frame.png)
video_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
video=$(curl -s "https://apis.viggle.ai/v1/videos/$video_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$video" | jq -r '.status')
case "$status" in
ready)
echo "$video" | jq -r '.video_url'
exit 0
;;
failed|cancelled)
echo "Video $status: $(echo "$video" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for video $video_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
const TERMINAL = new Set(["ready", "failed", "cancelled"]);
async function createVideo() {
const form = new FormData();
form.append("prompt", "camera pans across the room");
form.append("quality", "low");
form.append("first_frame_image_url", "https://assets.viggle.ai/samples/first-frame.png");
form.append("last_frame_image_url", "https://assets.viggle.ai/samples/last-frame.png");
const response = await fetch(`${baseUrl}/videos`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForVideo(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/videos/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const video = await response.json();
if (TERMINAL.has(video.status)) return video;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for video ${id}`);
}
const created = await createVideo();
const video = await waitForVideo(created.id);
if (video.status !== "ready") {
throw new Error(`Video ${video.status}: ${JSON.stringify(video.error)}`);
}
console.log(video.video_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
TERMINAL_STATES = {"ready", "failed", "cancelled"}
response = requests.post(
f"{base_url}/videos",
headers=headers,
data={"prompt": "camera pans across the room", "quality": "low"},
files={
"first_frame_image_url": (None, "https://assets.viggle.ai/samples/first-frame.png"),
"last_frame_image_url": (None, "https://assets.viggle.ai/samples/last-frame.png"),
},
)
response.raise_for_status()
video = response.json()
for _ in range(60):
if video["status"] in TERMINAL_STATES:
break
time.sleep(5)
response = requests.get(f"{base_url}/videos/{video['id']}", headers=headers)
response.raise_for_status()
video = response.json()
else:
raise TimeoutError(f"Timed out waiting for video {video['id']}")
if video["status"] != "ready":
error = video.get("error") or {}
raise RuntimeError(f"{video['status']}: {error.get('code')} {error.get('message')}")
print(video["video_url"])
```
## Response
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
The create request answers immediately with `status: "queued"` — it's an acceptance acknowledgment, not the final result. Poll `GET /v1/videos/{video_id}` every 3–5 seconds until `status` is `ready`, `failed`, or `cancelled`, then read `video_url` — a signed link, valid for 1 hour and re-signed on every read. Supplying a last frame without a first frame answers `400 INVALID_REQUEST` — "a last frame requires a first frame"; supplying both the file and URL form of the same slot answers `400 INVALID_REQUEST` — "supply only one of first\_frame\_image or first\_frame\_image\_url".
See [Pricing and retention](/v1/pricing#h3-video) — image-conditioned generation costs the same as text-only for the same `quality` and `duration_s`.
See every field, both upload forms, and common validation errors.
# Quick Start: Viggle Image
Source: https://docs.viggle.ai/v1/guides/quickstart-image
Generate PNG images from a text prompt, optionally guided by reference images.
Describe an image in text, submit it to `POST /v1/images`, poll the returned job until it's `ready`, and download the PNGs. One request generates 1–6 images with the `qwen` model; add up to 8 reference images to guide them.
Set `VIGGLE_API_KEY` and run the example below as-is — it needs no other input.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
* A text prompt, up to 4000 characters
* Credits: **1 credit ($0.01) per image** at `1K`, **2 credits ($0.02) per image** at `2K`. The example below generates two `1K` images for 2 credits.
## Generate, poll, and download
The script creates a job for two 16:9 images, polls every 3 seconds, and saves each image as `image_0.png`, `image_1.png`, and so on.
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/images" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="A watercolor fox reading a book under a maple tree" \
-F aspect_ratio=16:9 \
-F resolution=1K \
-F n=2)
image_id=$(echo "$response" | jq -r '.id')
# A job that hasn't finished within 15 minutes fails on its own.
for _ in $(seq 1 300); do
job=$(curl -s "https://apis.viggle.ai/v1/images/$image_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$job" | jq -r '.status')
case "$status" in
ready)
i=0
for url in $(echo "$job" | jq -r '.images[].url'); do
curl -s -o "image_$i.png" "$url"
echo "saved image_$i.png"
i=$((i + 1))
done
exit 0
;;
failed)
echo "Image job failed: $(echo "$job" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 3
done
echo "Timed out waiting for image job $image_id" >&2
exit 1
```
```javascript Node theme={null}
import { writeFile } from "node:fs/promises";
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
async function api(path, init = {}) {
const response = await fetch(`${baseUrl}${path}`, { ...init, headers });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return body;
}
const form = new FormData();
form.append("prompt", "A watercolor fox reading a book under a maple tree");
form.append("aspect_ratio", "16:9");
form.append("resolution", "1K");
form.append("n", "2");
let job = await api("/images", { method: "POST", body: form });
// A job that hasn't finished within 15 minutes fails on its own.
while (job.status === "queued" || job.status === "processing") {
await new Promise((resolve) => setTimeout(resolve, 3000));
job = await api(`/images/${job.id}`);
}
if (job.status !== "ready") {
throw new Error(`Image job failed: ${JSON.stringify(job.error)}`);
}
for (const [i, image] of job.images.entries()) {
const png = await fetch(image.url);
await writeFile(`image_${i}.png`, Buffer.from(await png.arrayBuffer()));
console.log(`saved image_${i}.png (${image.width}x${image.height}, seed ${image.seed})`);
}
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
fields = {
"prompt": "A watercolor fox reading a book under a maple tree",
"aspect_ratio": "16:9",
"resolution": "1K",
"n": "2",
}
# files= makes requests send multipart/form-data, which the endpoint requires.
response = requests.post(
f"{base_url}/images",
headers=headers,
files={name: (None, value) for name, value in fields.items()},
)
response.raise_for_status()
job = response.json()
# A job that hasn't finished within 15 minutes fails on its own.
while job["status"] in ("queued", "processing"):
time.sleep(3)
response = requests.get(f"{base_url}/images/{job['id']}", headers=headers)
response.raise_for_status()
job = response.json()
if job["status"] != "ready":
error = job.get("error") or {}
raise RuntimeError(f"Image job failed: {error.get('code')} {error.get('message')}")
for i, image in enumerate(job["images"]):
png = requests.get(image["url"])
png.raise_for_status()
with open(f"image_{i}.png", "wb") as f:
f.write(png.content)
print(f"saved image_{i}.png ({image['width']}x{image['height']}, seed {image['seed']})")
```
```typescript @viggle/sdk theme={null}
import { Viggle } from "@viggle/sdk";
const viggle = new Viggle({ apiKey: process.env.VIGGLE_API_KEY! });
let job = await viggle.images.create({
prompt: "A watercolor fox reading a book under a maple tree",
aspectRatio: "16:9",
resolution: "1K",
n: 2,
});
while (job.status === "queued" || job.status === "processing") {
await new Promise((resolve) => setTimeout(resolve, 3000));
job = await viggle.images.get(job.id);
}
console.log(job.images.map((image) => image.url));
```
The `@viggle/sdk` example requires an SDK version that includes the `images` resource.
## Response
The create request answers immediately with a `queued` job:
```json theme={null}
{
"id": "img_b16a190d-2d44-41a0-8056-ef68183c3b9e",
"status": "queued",
"model": "qwen",
"progress": 0,
"images": [],
"created_at": "2026-10-12T08:20:00Z",
"completed_at": null,
"error": null
}
```
Poll `GET /v1/images/{image_id}` until `status` is `ready` or `failed`. While `processing`, `progress` rises as each image finishes. Once `ready`, `images` lists all `n` images in request order, each with a `url`, its `width` and `height` in pixels, and the `seed` it used. In production, two `1K` images take about 17–22 seconds.
Each `url` is a signed link, valid for **1 hour** — if it expires, fetch the job again for a fresh one. Generated images are deleted **24 hours** after the job finishes, so download them before then.
## Add reference images
Upload files with `reference_image`, pass public `http(s)` URLs with `reference_image_url`, or mix both. Either field can be repeated, up to 8 reference images in total:
```bash theme={null}
curl -X POST https://apis.viggle.ai/v1/images \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="The same fox, now in winter, snow falling" \
-F aspect_ratio=1:1 \
-F resolution=2K \
-F reference_image=@fox.png \
-F reference_image_url=https://example.com/style.jpg
```
Poll and download the same way. With reference images, `2K` produces a smaller size than text-only `2K` — here 1536 × 1536 instead of 2048 × 2048 — at the same price. One `2K` image with a reference image takes about 37 seconds. Each reference image can be up to 24 MiB; see [Generate Image](/v1/api-reference/images/create#reference-image-limits) for formats and limits.
## If a job fails
Read `error.code`. `SERVICE_BUSY` and `TASK_FAILED` are retryable — create a new job with the same parameters. `INVALID_REQUEST` means the model rejected the request, for example an unusable reference image; change the inputs first. A failed job is refunded in full. See [Get Image](/v1/api-reference/images/get#status-and-errors).
Every field, output size, and limit.
The full Image object, statuses, and error codes.
# Quick Start: Video Remix
Source: https://docs.viggle.ai/v1/guides/quickstart-mix
Turn a character image and motion video into a finished video.
**Video Remix** turns a character image and a motion video into one finished video in a single request. It's the fastest path to a Viggle result.
Set `VIGGLE_API_KEY` and run the example below as-is — it uses Viggle-hosted sample assets, so you don't need to prepare your own character image or motion video first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
The examples below reference placeholder sample asset URLs (`https://assets.viggle.ai/samples/...`). Swap in your own `image`/`image_url` and `motion_video`/`motion_video_url`, or Viggle's published sample assets once available.
## Complete example
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/renders" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image_url=https://assets.viggle.ai/samples/character.png" \
-F "motion_video_url=https://assets.viggle.ai/samples/motion.mp4")
render_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 120); do
render=$(curl -s "https://apis.viggle.ai/v1/videos/$render_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$render" | jq -r '.status')
case "$status" in
ready)
echo "$render" | jq -r '.video_url'
exit 0
;;
failed|cancelled)
echo "Render $status: $(echo "$render" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for render $render_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
const TERMINAL = new Set(["ready", "failed", "cancelled"]);
async function createRender() {
const form = new FormData();
form.append("image_url", "https://assets.viggle.ai/samples/character.png");
form.append("motion_video_url", "https://assets.viggle.ai/samples/motion.mp4");
const response = await fetch(`${baseUrl}/renders`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForRender(id) {
for (let attempt = 0; attempt < 120; attempt++) {
const response = await fetch(`${baseUrl}/videos/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const render = await response.json();
if (TERMINAL.has(render.status)) return render;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for render ${id}`);
}
const created = await createRender();
const render = await waitForRender(created.id);
if (render.status !== "ready") {
throw new Error(`Render ${render.status}: ${JSON.stringify(render.error)}`);
}
console.log(render.video_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
TERMINAL_STATES = {"ready", "failed", "cancelled"}
response = requests.post(
f"{base_url}/renders",
headers=headers,
files={
"image_url": (None, "https://assets.viggle.ai/samples/character.png"),
"motion_video_url": (None, "https://assets.viggle.ai/samples/motion.mp4"),
},
)
response.raise_for_status()
render = response.json()
for _ in range(120):
if render["status"] in TERMINAL_STATES:
break
time.sleep(5)
response = requests.get(f"{base_url}/videos/{render['id']}", headers=headers)
response.raise_for_status()
render = response.json()
else:
raise TimeoutError(f"Timed out waiting for render {render['id']}")
if render["status"] != "ready":
error = render.get("error") or {}
raise RuntimeError(f"{render['status']}: {error.get('code')} {error.get('message')}")
print(render["video_url"])
```
## Inputs
* Character: `image` (file) or `image_url`, or a reusable `character_id` from the [Character Quickstart](/v1/guides/quickstart-character)
* Motion: `motion_video` (file) or `motion_video_url`, or a reusable `motion_id` from the [Motion Quickstart](/v1/guides/quickstart-motion)
* `background_mode`: `original` (default), `solid`, or `transparent`
* `bg_color`: an `R,G,B` color; only use it with `solid`
## Response
```json theme={null}
{
"id": "render_123abc",
"status": "queued",
"progress": 0,
"created_at": "2026-07-17T10:00:00+00:00",
"links": {
"self": "/v1/renders/render_123abc",
"events": "/v1/renders/render_123abc/events",
"download": "/v1/renders/render_123abc/download"
}
}
```
When processing, `stage` may be `analyzing`, `rendering`, or `finishing`. A completed job has `status: "ready"` and includes `video_url` — a signed link, valid for 1 hour and re-signed on every read; download it promptly or re-fetch [Get Video](/v1/api-reference/videos/get) for a fresh one. In transparent mode it also includes `alpha_url`. Instead of polling, you can watch the same lifecycle over Server-Sent Events with [Watch Render](/v1/api-reference/renders/events).
See [Pricing and retention](/v1/pricing#video-remix) for how Video Remix is billed.
Learn terminal states, polling, and result retention.
# Quick Start: Motion 3D
Source: https://docs.viggle.ai/v1/guides/quickstart-mocap
Turn a motion video into a downloadable 3D animation.
**Motion 3D** extracts a 3D skeletal animation from a driving video — this is the `type=glb` path of `POST /v1/motions`. When it's ready, request `?download_type=glb` (or `fbx`) from [Export 3D Motion](/v1/api-reference/motions/export) to get all three rigs' URLs at once. The exported GLB works with **Mixamo**, **MetaHuman**, and other **Unity-compatible** animation workflows. See the [Motion Quickstart](/v1/guides/quickstart-motion) for the render-ready 2D path instead, or [Text-to-Motion](/v1/guides/quickstart-text-to-motion) to generate a 3D animation from a text prompt with no source video.
Set `VIGGLE_API_KEY` and run the example below as-is — it uses a Viggle-hosted sample video, so you don't need to prepare your own motion video first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
The examples below reference a placeholder sample asset URL (`https://assets.viggle.ai/samples/motion.mp4`). Swap in your own `motion_video`/`motion_video_url`, or Viggle's published sample asset once available.
## Create, poll, and export the Motion
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/motions" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "motion_video_url=https://assets.viggle.ai/samples/motion.mp4" \
-F "type=glb")
motion_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
motion=$(curl -s "https://apis.viggle.ai/v1/motions/$motion_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$motion" | jq -r '.status')
case "$status" in
ready)
exported=$(curl -s "https://apis.viggle.ai/v1/motions/$motion_id/export?download_type=glb" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
echo "$exported" | jq -r '.mixamo_url'
exit 0
;;
failed)
echo "Motion failed: $(echo "$motion" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for motion $motion_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
async function createMotion() {
const form = new FormData();
form.append("motion_video_url", "https://assets.viggle.ai/samples/motion.mp4");
form.append("type", "glb");
const response = await fetch(`${baseUrl}/motions`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForMotion(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/motions/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const motion = await response.json();
if (["ready", "failed"].includes(motion.status)) return motion;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for motion ${id}`);
}
const created = await createMotion();
const motion = await waitForMotion(created.id);
if (motion.status !== "ready") {
throw new Error(`Motion failed: ${JSON.stringify(motion.error)}`);
}
const exportResponse = await fetch(
`${baseUrl}/motions/${motion.id}/export?download_type=glb`,
{ headers },
);
if (!exportResponse.ok) {
const body = await exportResponse.json().catch(() => ({}));
throw new Error(`HTTP ${exportResponse.status}: ${JSON.stringify(body.error ?? body)}`);
}
const exported = await exportResponse.json();
console.log(exported.mixamo_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
response = requests.post(
f"{base_url}/motions",
headers=headers,
files={"motion_video_url": (None, "https://assets.viggle.ai/samples/motion.mp4")},
data={"type": "glb"},
)
response.raise_for_status()
motion = response.json()
for _ in range(60):
if motion["status"] in {"ready", "failed"}:
break
time.sleep(5)
response = requests.get(f"{base_url}/motions/{motion['id']}", headers=headers)
response.raise_for_status()
motion = response.json()
else:
raise TimeoutError(f"Timed out waiting for motion {motion['id']}")
if motion["status"] != "ready":
error = motion.get("error") or {}
raise RuntimeError(f"failed: {error.get('code')} {error.get('message')}")
exported = requests.get(
f"{base_url}/motions/{motion['id']}/export",
params={"download_type": "glb"},
headers=headers,
)
exported.raise_for_status()
print(exported.json()["mixamo_url"])
```
## Response
```json theme={null}
{
"id": "mot_123abc",
"status": "queued",
"name": "",
"progress": 0,
"capabilities": [],
"created_at": "2026-07-21T10:00:00+00:00",
"completed_at": null,
"error": null,
"type": "glb",
"vsplat": null,
"glb": null
}
```
Poll `GET /v1/motions/{motion_id}` every five seconds. When `status` is `ready`, request `GET /v1/motions/{motion_id}/export?download_type=glb`; the response returns all three rig variants at once — `mixamo_url`, `metahuman_url`, `metahuman_body_url` — each a signed link, valid for 1 hour and re-signed on every call. Request `download_type=fbx` on the same `motion_id` for the same three rigs baked as FBX instead — both formats are pre-generated, so switching between them never waits on new work.
Not sure which rig to pick? `mixamo_url` is a generic 50-joint skeleton you'll retarget onto your own character; `metahuman_url`/`metahuman_body_url` matches a Viggle vsplat Character's rig 1:1, so it needs no retargeting at all. See [Export 3D Motion](/v1/api-reference/motions/export) for the full comparison, including the 441-joint (facial) vs. 86-joint (body-only) variants.
See [Pricing and retention](/v1/pricing#motion-3d) for how the `glb` extraction is billed by source-video duration.
See every request and response field.
# Quick Start: Motion
Source: https://docs.viggle.ai/v1/guides/quickstart-motion
Create a reusable Motion from a driving video.
Create a reusable **Motion** from a driving video once, then reference its ID from any number of [Video Remix](/v1/guides/quickstart-mix) renders without re-uploading the video. This is the `type=render` path of `POST /v1/motions` — see the [Motion 3D Quickstart](/v1/guides/quickstart-mocap) for the `glb` extraction instead, or the [Text-to-Motion Quickstart](/v1/guides/quickstart-text-to-motion) to generate a Motion from a text prompt with no source video at all.
Set `VIGGLE_API_KEY` and run the example below as-is — it uses a Viggle-hosted sample video, so you don't need to prepare your own motion video first.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
The examples below reference a placeholder sample asset URL (`https://assets.viggle.ai/samples/motion.mp4`). Swap in your own `motion_video`/`motion_video_url`, or Viggle's published sample asset once available.
## Complete example
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/motions" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "motion_video_url=https://assets.viggle.ai/samples/motion.mp4" \
-F "name=Quickstart motion")
motion_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
motion=$(curl -s "https://apis.viggle.ai/v1/motions/$motion_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$motion" | jq -r '.status')
case "$status" in
ready)
echo "$motion_id"
exit 0
;;
failed)
echo "Motion failed: $(echo "$motion" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 3
done
echo "Timed out waiting for motion $motion_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
async function createMotion() {
const form = new FormData();
form.append("motion_video_url", "https://assets.viggle.ai/samples/motion.mp4");
form.append("name", "Quickstart motion");
const response = await fetch(`${baseUrl}/motions`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForMotion(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/motions/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const motion = await response.json();
if (["ready", "failed"].includes(motion.status)) return motion;
await new Promise((resolve) => setTimeout(resolve, 3000));
}
throw new Error(`Timed out waiting for motion ${id}`);
}
const created = await createMotion();
const motion = await waitForMotion(created.id);
if (motion.status !== "ready") {
throw new Error(`Motion failed: ${JSON.stringify(motion.error)}`);
}
console.log(motion.id);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
response = requests.post(
f"{base_url}/motions",
headers=headers,
files={"motion_video_url": (None, "https://assets.viggle.ai/samples/motion.mp4")},
data={"name": "Quickstart motion"},
)
response.raise_for_status()
motion = response.json()
for _ in range(60):
if motion["status"] in {"ready", "failed"}:
break
time.sleep(3)
response = requests.get(f"{base_url}/motions/{motion['id']}", headers=headers)
response.raise_for_status()
motion = response.json()
else:
raise TimeoutError(f"Timed out waiting for motion {motion['id']}")
if motion["status"] != "ready":
error = motion.get("error") or {}
raise RuntimeError(f"failed: {error.get('code')} {error.get('message')}")
print(motion["id"])
```
## Response
```json theme={null}
{
"id": "mot_550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"name": "Quickstart motion",
"progress": 0,
"capabilities": [],
"created_at": "2026-07-21T10:00:00+00:00",
"completed_at": null,
"error": null,
"type": "render",
"vsplat": null,
"glb": null
}
```
Poll `GET /v1/motions/{motion_id}` every 3 seconds until `status` is `ready` or `failed`. Once ready, `id` is a **reusable Motion ID** — pass it as `motion_id` to [Render Video](/v1/api-reference/renders/create) (or the [Video Remix Quickstart](/v1/guides/quickstart-mix)) any number of times, without re-uploading the source video.
See [Pricing and retention](/v1/pricing#motion) for how Motion creation is billed — a `type=render` Motion has no separate preprocessing charge.
See every request and response field, including the `glb`/`all` extraction options.
# Quick Start: Reference to Video
Source: https://docs.viggle.ai/v1/guides/quickstart-reference-to-video
Generate a video from a reference video or images and a text prompt.
Upload a reference video, describe the shot you want, and generate a new video that carries the reference's subject or style. Submit one request to `POST /v1/videos`, then poll `GET /v1/videos/{video_id}` for the finished video. This **H3 Video** mode includes native audio and also accepts reference images, with or without a reference video.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`.
* Your reference video saved as `reference.mp4` in the directory where you run the example. It must be 0.5–600 seconds long, at least 64×64 pixels, with even width and height.
* For cURL, install `jq`; for Node, use Node.js 20+ and save the example as an `.mjs` file; for Python, install `requests` with `pip install requests`.
The examples upload your local file and request a 5-second output. Change the `prompt` to describe your desired shot. Both `prompt` and `quality` are required; use `low` for faster iteration or `high` for higher fidelity.
## Generate and poll the video
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl --fail-with-body -sS "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="keep the same outfit and walk forward" \
-F quality=high \
-F reference_video=@./reference.mp4 \
-F duration_s=5)
video_id=$(echo "$response" | jq -er '.id')
for _ in $(seq 1 60); do
video=$(curl --fail-with-body -sS "https://apis.viggle.ai/v1/videos/$video_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$video" | jq -r '.status')
case "$status" in
ready)
echo "$video" | jq -r '.video_url'
exit 0
;;
failed|cancelled)
echo "Video $status: $(echo "$video" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for video $video_id" >&2
exit 1
```
```javascript Node theme={null}
import { readFile } from "node:fs/promises";
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
const TERMINAL = new Set(["ready", "failed", "cancelled"]);
async function createVideo() {
const form = new FormData();
form.append("prompt", "keep the same outfit and walk forward");
form.append("quality", "high");
form.append("reference_video", new Blob([await readFile("./reference.mp4")], { type: "video/mp4" }), "reference.mp4");
form.append("duration_s", "5");
const response = await fetch(`${baseUrl}/videos`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForVideo(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/videos/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const video = await response.json();
if (TERMINAL.has(video.status)) return video;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for video ${id}`);
}
const created = await createVideo();
const video = await waitForVideo(created.id);
if (video.status !== "ready") {
throw new Error(`Video ${video.status}: ${JSON.stringify(video.error)}`);
}
console.log(video.video_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
TERMINAL_STATES = {"ready", "failed", "cancelled"}
with open("./reference.mp4", "rb") as reference_video:
response = requests.post(
f"{base_url}/videos",
headers=headers,
data={
"prompt": "keep the same outfit and walk forward",
"quality": "high",
"duration_s": 5,
},
files={"reference_video": ("reference.mp4", reference_video, "video/mp4")},
timeout=120,
)
response.raise_for_status()
video_id = response.json()["id"]
for _ in range(60):
response = requests.get(f"{base_url}/videos/{video_id}", headers=headers, timeout=30)
response.raise_for_status()
video = response.json()
if video["status"] in TERMINAL_STATES:
break
time.sleep(5)
else:
raise TimeoutError(f"Timed out waiting for video {video_id}")
if video["status"] != "ready":
error = video.get("error") or {}
raise RuntimeError(f"{video['status']}: {error.get('code')} {error.get('message')}")
print(video["video_url"])
```
## Response
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
The create request returns immediately with a video ID. The examples poll every 5 seconds and print `video_url` when `status` is `ready`; they stop on `failed`, `cancelled`, or a polling timeout. If polling times out, you can resume with the same ID using [Get Video](/v1/api-reference/videos/get).
Open the printed URL to view or download your video. The signed URL is valid for 1 hour; fetch Get Video again for a fresh link.
## Use a URL or reference images
* **Hosted video:** replace `reference_video` with `reference_video_url` containing your publicly reachable video URL. Supply at most one reference video across file uploads and URLs.
* **Add images:** include `reference_image` file uploads or `reference_image_url` fields alongside the video. Repeat these fields for up to 4 images in total.
* **Images only:** omit the video and supply at least one reference image. Keep `prompt` and `quality`.
Include at least one reference field; omitting all reference fields selects text-to-video generation. Do not combine reference fields with first/last-frame fields or Viggle-Animate's driving-video and character-image fields.
`duration_s` controls the generated video's length (5–15 seconds, default `5`), independently of the reference video's length. See [Pricing and retention](/v1/pricing#h3-video) for generation costs.
See every parameter, reference-image examples, and validation errors.
Apply a driving video's motion to character images.
# Quick Start: Text to Motion
Source: https://docs.viggle.ai/v1/guides/quickstart-text-to-motion
Generate a downloadable 3D animation from a text prompt.
Describe a motion in text, submit it as JSON, and poll the returned Motion until the generated glb is ready. No source video is required — this is a separate, flat-priced path from the [Motion 3D](/v1/guides/quickstart-mocap) `glb` extraction, which is billed by source-video duration instead.
Set `VIGGLE_API_KEY` and run the example below as-is — it needs no other input.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
* A motion prompt between 1 and 400 characters
## Create, poll, and export the Motion
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s -X POST "https://apis.viggle.ai/v1/motions" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"a person doing a cartwheel","duration_seconds":5}')
motion_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
motion=$(curl -s "https://apis.viggle.ai/v1/motions/$motion_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$motion" | jq -r '.status')
case "$status" in
ready)
exported=$(curl -s "https://apis.viggle.ai/v1/motions/$motion_id/export?download_type=glb" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
echo "$exported" | jq -r '.mixamo_url'
exit 0
;;
failed)
echo "Motion failed: $(echo "$motion" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for motion $motion_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = {
Authorization: `Bearer ${process.env.VIGGLE_API_KEY}`,
"Content-Type": "application/json",
};
async function createMotion() {
const response = await fetch(`${baseUrl}/motions`, {
method: "POST",
headers,
body: JSON.stringify({
text: "a person doing a cartwheel",
duration_seconds: 5,
}),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForMotion(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/motions/${id}`, {
headers: { Authorization: headers.Authorization },
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const motion = await response.json();
if (["ready", "failed"].includes(motion.status)) return motion;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for motion ${id}`);
}
const created = await createMotion();
const motion = await waitForMotion(created.id);
if (motion.status !== "ready") {
throw new Error(`Motion failed: ${JSON.stringify(motion.error)}`);
}
const exportResponse = await fetch(
`${baseUrl}/motions/${motion.id}/export?download_type=glb`,
{ headers: { Authorization: headers.Authorization } },
);
if (!exportResponse.ok) {
const body = await exportResponse.json().catch(() => ({}));
throw new Error(`HTTP ${exportResponse.status}: ${JSON.stringify(body.error ?? body)}`);
}
const exported = await exportResponse.json();
console.log(exported.mixamo_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
response = requests.post(
f"{base_url}/motions",
headers=headers,
json={
"text": "a person doing a cartwheel",
"duration_seconds": 5,
},
)
response.raise_for_status()
motion = response.json()
for _ in range(60):
if motion["status"] in {"ready", "failed"}:
break
time.sleep(5)
response = requests.get(f"{base_url}/motions/{motion['id']}", headers=headers)
response.raise_for_status()
motion = response.json()
else:
raise TimeoutError(f"Timed out waiting for motion {motion['id']}")
if motion["status"] != "ready":
error = motion.get("error") or {}
raise RuntimeError(f"failed: {error.get('code')} {error.get('message')}")
response = requests.get(
f"{base_url}/motions/{motion['id']}/export",
params={"download_type": "glb"},
headers=headers,
)
response.raise_for_status()
print(response.json()["mixamo_url"])
```
The create request returns immediately with a Motion whose `status` is normally `queued`. Poll `GET /v1/motions/{motion_id}` every five seconds until it becomes `ready` or `failed` — there is no `cancelled` state for a Motion.
When ready, call `GET /v1/motions/{motion_id}/export?download_type=glb` — the response returns all three rig variants at once: `mixamo_url`, `metahuman_url`, and `metahuman_body_url`. Use `download_type=fbx` instead for the same three rigs baked as FBX files. All three rigs, in both formats, are generated together, so switching `download_type` never waits on new work. Each URL is a signed link, valid for 1 hour and re-signed on every call. GLB works with **Mixamo**, **MetaHuman**, and other **Unity-compatible** animation workflows; FBX is the same rig/animation for pipelines that expect that format instead.
Not sure which rig to pick? `mixamo_url` is a generic 50-joint skeleton you'll retarget onto your own character; `metahuman_url`/`metahuman_body_url` matches a Viggle vsplat Character's rig 1:1, so it needs no retargeting at all. See [Export 3D Motion](/v1/api-reference/motions/export) for the full comparison, including the 441-joint (facial) vs. 86-joint (body-only) variants.
See [Pricing and retention](/v1/pricing#text-to-motion) — Text-to-Motion is a flat \$0.10 (10 credits) per request, independent of `duration_seconds`.
See every JSON request and Motion response field.
# Quick Start: Text to Video
Source: https://docs.viggle.ai/v1/guides/quickstart-text-to-video
Generate a MiniMax H3 video from a text prompt alone.
Describe a shot in text, submit it to `POST /v1/videos`, and poll the returned video until it's ready. No source image or video is required — this is the **H3 Video Generation** text-to-video mode, a Viggle-optimized MiniMax H3 capability separate from the character+motion [Video Remix](/v1/guides/quickstart-mix) flow. Every generated video includes **native audio**.
Set `VIGGLE_API_KEY` and run the example below as-is — it needs no other input.
## What you need
* An API key from the [Viggle Dashboard](https://portal.viggle.ai/keys), exported as `VIGGLE_API_KEY`
* A non-empty text prompt
* A `quality` choice: `low` for faster generation and quicker iteration, or `high` for higher-fidelity output — both are billed at the same **\$0.01/sec** rate
## Generate and poll the video
```bash cURL theme={null}
#!/usr/bin/env bash
set -euo pipefail
response=$(curl -s "https://apis.viggle.ai/v1/videos" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F prompt="a paper airplane gliding through a sunlit office" \
-F quality=low \
-F duration_s=5)
video_id=$(echo "$response" | jq -r '.id')
for _ in $(seq 1 60); do
video=$(curl -s "https://apis.viggle.ai/v1/videos/$video_id" \
-H "Authorization: Bearer $VIGGLE_API_KEY")
status=$(echo "$video" | jq -r '.status')
case "$status" in
ready)
echo "$video" | jq -r '.video_url'
exit 0
;;
failed|cancelled)
echo "Video $status: $(echo "$video" | jq -c '.error')" >&2
exit 1
;;
esac
sleep 5
done
echo "Timed out waiting for video $video_id" >&2
exit 1
```
```javascript Node theme={null}
const baseUrl = "https://apis.viggle.ai/v1";
const headers = { Authorization: `Bearer ${process.env.VIGGLE_API_KEY}` };
const TERMINAL = new Set(["ready", "failed", "cancelled"]);
async function createVideo() {
const form = new FormData();
form.append("prompt", "a paper airplane gliding through a sunlit office");
form.append("quality", "low");
form.append("duration_s", "5");
const response = await fetch(`${baseUrl}/videos`, {
method: "POST",
headers,
body: form,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
return response.json();
}
async function waitForVideo(id) {
for (let attempt = 0; attempt < 60; attempt++) {
const response = await fetch(`${baseUrl}/videos/${id}`, { headers });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(`HTTP ${response.status}: ${JSON.stringify(body.error ?? body)}`);
}
const video = await response.json();
if (TERMINAL.has(video.status)) return video;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Timed out waiting for video ${id}`);
}
const created = await createVideo();
const video = await waitForVideo(created.id);
if (video.status !== "ready") {
throw new Error(`Video ${video.status}: ${JSON.stringify(video.error)}`);
}
console.log(video.video_url);
```
```python Python theme={null}
import os
import time
import requests
base_url = "https://apis.viggle.ai/v1"
headers = {"Authorization": f"Bearer {os.environ['VIGGLE_API_KEY']}"}
TERMINAL_STATES = {"ready", "failed", "cancelled"}
response = requests.post(
f"{base_url}/videos",
headers=headers,
data={
"prompt": "a paper airplane gliding through a sunlit office",
"quality": "low",
"duration_s": 5,
},
)
response.raise_for_status()
video = response.json()
for _ in range(60):
if video["status"] in TERMINAL_STATES:
break
time.sleep(5)
response = requests.get(f"{base_url}/videos/{video['id']}", headers=headers)
response.raise_for_status()
video = response.json()
else:
raise TimeoutError(f"Timed out waiting for video {video['id']}")
if video["status"] != "ready":
error = video.get("error") or {}
raise RuntimeError(f"{video['status']}: {error.get('code')} {error.get('message')}")
print(video["video_url"])
```
## Response
```json theme={null}
{
"id": "vid_3f2a9c",
"status": "queued",
"progress": null,
"created_at": "2026-08-24T09:12:03Z"
}
```
The create request answers immediately with `status: "queued"` and no `video_url` yet — it's an acceptance acknowledgment, not the final result. Poll `GET /v1/videos/{video_id}` every 3–5 seconds until `status` is `ready`, `failed`, or `cancelled`, then read `video_url` — a signed link, valid for 1 hour and re-signed on every read. `duration_s` (5–15, default `5`), `resolution` (default `768p`), and `aspect_ratio` (default `16:9`) are all optional; `prompt` and `quality` are not.
See [Pricing and retention](/v1/pricing#h3-video) — H3 Video is billed at $0.01 per generated second regardless of `quality` or `aspect_ratio`, rising to $0.05 per second at `resolution=4k`.
See every field and the full response shape.
# Reuse characters and motions
Source: https://docs.viggle.ai/v1/guides/reuse-assets
Create reusable assets for repeated V1 renders.
Create a Character or Motion once when you expect to use it in more than one Render. V1 keeps these as separate reusable resources. See the [Character](/v1/guides/quickstart-character) and [Motion](/v1/guides/quickstart-motion) Quickstarts for complete, runnable versions of the two creation calls below.
## Create a Character
```bash theme={null}
curl -X POST "https://apis.viggle.ai/v1/characters" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image=@character.png" \
-F "name=Presenter"
```
## Create a Motion
```bash theme={null}
curl -X POST "https://apis.viggle.ai/v1/motions" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "motion_video=@dance.mp4" \
-F "name=Dance loop"
```
Both operations return an asynchronous resource. Wait until each resource has `status: "ready"` before using it in a Render.
## Render with reusable IDs
```bash theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "character_id=char_123abc" \
-F "motion_id=mot_456def"
```
## Asset readiness
Characters and Motions expose a `capabilities` array. Use an asset in a Render only when its `status` is `ready` and `capabilities` includes `video_render`.
Use direct inputs for a one-time job. Use reusable assets when the same character or motion appears in multiple renders.
# Transparent output
Source: https://docs.viggle.ai/v1/guides/transparent-output
Create alpha output for video compositing.
Set `background_mode` to `transparent` to create a main video and an alpha mask for compositing.
```bash theme={null}
curl -X POST "https://apis.viggle.ai/v1/renders" \
-H "Authorization: Bearer $VIGGLE_API_KEY" \
-F "image=@character.png" \
-F "motion_video=@motion.mp4" \
-F "background_mode=transparent"
```
When the Render is ready, the response contains:
```json theme={null}
{
"status": "ready",
"video_url": "https://...",
"alpha_url": "https://..."
}
```
Use `video_url` as the foreground video and `alpha_url` as its mask in your editor. For a green-screen workflow, use `background_mode=solid` and set `bg_color` to `0,255,0`.
# Viggle API V1
Source: https://docs.viggle.ai/v1/introduction
Create controllable character-motion videos with Viggle API V1.
**V1 is the current API.** Start new integrations with V1. Existing integrations using `/api/*` can follow the [migration guide](/v1/production/migrate-from-legacy).
Viggle API is organized around reusable assets, asynchronous operations, and delivered artifacts. Create Characters and Motions once, then reuse them in multiple Renders. A Render is an operation; its MP4 and optional alpha video are its artifacts.
```mermaid theme={null}
graph LR
A["Character asset"] --> D["Render operation"]
B["Motion asset"] --> D
C["Inline image or video"] --> D
D --> E["Video artifacts"]
```
## Create your first render
Create an API key in the [Viggle Dashboard](https://portal.viggle.ai/keys).
Upload an image and a motion video to `POST /v1/renders`.
Poll `GET /v1/videos/{render_id}` until `status` is `ready`, then use `video_url`.
Turn a character image and a motion video into a finished video.
Turn a motion video into a downloadable 3D (glb) animation.
## Choose a workflow
Generate a video from a reference video or images and a text prompt.
Upload an image and a motion video in one request. Best for a first render or one-off work.
Create Characters and Motions once, then reference their IDs in later Render requests.
## Core concepts
| Concept | What it represents |
| - | - |
| Character | A reusable character created from an image. IDs start with `char_`. `type=vsplat`/`all` also extracts a 3D Gaussian splat. |
| Motion | A reusable driving motion created from a video, or generated from text. IDs start with `mot_`. `type=glb`/`all` also extracts or generates a 3D animation. |
| Render | An asynchronous video-generation job. IDs start with `render_`. |
| Motion Template | An official Viggle motion, referenced by its template ID, that you can reuse directly in a Render or import into your own Motion. |
## Next steps
Send API keys securely.Browse every V1 resource.Handle async jobs and failures.
# Pricing and retention
Source: https://docs.viggle.ai/v1/pricing
Understand V1 render charges and result availability.
## Credits
1 credit = \*\*$0.01**. New accounts start with **100 free credits** ($1.00).
Check your balance with [`GET /v1/credits`](/v1/api-reference/credits) or manage billing in the [Viggle Dashboard](https://portal.viggle.ai/dashboard). If an operation can't start because the account has insufficient credit, the API returns `402` with `error.code: "INSUFFICIENT_CREDITS"` — see [Errors and recovery](/v1/production/errors).
## Pricing summary
| Capability | Endpoint | Price |
| - | - | - |
| [Video Remix](#video-remix) | `POST /v1/renders` | **\$0.01 per character per output second** |
| [Character](#character) | `POST /v1/characters` — `type=render` | **\$0.01** each |
| [Character 3D](#character-3d) | `POST /v1/characters` — `type=vsplat` | **$0.30** each (**$0.25** with `enhance=false`) |
| [Motion](#motion) | `POST /v1/motions` (video) — `type=render` | No separate charge |
| [Motion 3D](#motion-3d) | `POST /v1/motions` (video) — `type=glb` | **\$0.05 per source-video second**, rounded up |
| [Text-to-Motion](#text-to-motion) | `POST /v1/motions` (JSON `text`) | **\$0.10** flat |
| [H3 Video](#h3-video) | `POST /v1/videos` | \*\*$0.01 per generated second** ($0.05/sec at `resolution=4k`) |
| [Character Animation](#character-animation) | `POST /v1/videos` (character-animation mode) | **$0.01 per driving-video second**, rounded up (5–16 credits); **$0.11** flat without a driving video |
| [Viggle Image](#viggle-image) | `POST /v1/images` | **$0.01 per image** at `1K`, **$0.02 per image** at `2K` |
| [Import Motion](#import-motion) | `POST /v1/motions/import` | **$0.01 per second** of the template's duration, minimum **$0.01** |
Every other operation — `GET`/`DELETE`/list/export on any resource, and `POST /v1/renders/prepare` — is free.
## Video Remix
[Video Remix](/v1/guides/quickstart-mix) (`POST /v1/renders`) is billed at **\$0.01 per character in the render, per second of finished output**:
```
cost = $0.01 × characters in the render × finished duration in seconds
```
A render today always has exactly one character (`image`, `image_url`, or `character_id` — the API accepts only one character source per render; see [Render Video](/v1/api-reference/renders/create)), so in practice this is **$0.01 per finished second**. For example, a 10-second finished video costs **$0.10 (10 credits)**. The per-character term is stated explicitly so the price is unambiguous if a render ever includes more than one character — it isn't something you can request today.
Billing uses the video's exact finished duration in seconds — fractional seconds are billed fractionally, there is no per-render minimum charge. The amount reserved when the render starts is an estimate based on the requested inputs; it's finalized against the actual completed duration once the render reaches `ready`.
## Character
Creating a reusable [Character](/v1/api-reference/characters/create) with `type=render` (the default) costs **\$0.01 (1 credit)** each, flat — independent of image size or `name`.
## Character 3D
Creating a Character with `type=vsplat` extracts a 3D Gaussian splat instead of (or in addition to) the 2D render:
| `type` | `enhance` | Price |
| - | - | - |
| `vsplat` | `true` (default) | **\$0.30** flat |
| `vsplat` | `false` | **\$0.25** flat |
| `all` (render + vsplat) | `true` (default) | **\$0.31** flat |
| `all` (render + vsplat) | `false` | **\$0.26** flat |
`enhance` only affects the vsplat extraction — it **replaces** the $0.25 price with $0.30 rather than adding a surcharge on top, and is on by default. `type=all` is billed for both outputs it produces (the \$0.01 render plus the vsplat price above). `model_precision`, `joint_set`, and `render_thumbnail` don't change the price. Once ready, download the extraction from [Export 3D Character](/v1/api-reference/characters/export).
## Motion
Creating a reusable [Motion](/v1/api-reference/motions/create) from a directly uploaded or URL-supplied video with `type=render` (the default) has **no separate preprocessing charge** — you're only billed later, when that Motion is used in a Video Remix.
## Motion 3D
Creating a Motion with `type=glb` extracts a 3D skeletal animation instead of (or in addition to) the 2D render, billed by the **source video's duration**:
```
cost = $0.05 × ceil(source video duration in seconds)
```
Any fractional remainder rounds up to the next whole second before the rate is applied — a 0.3-second clip is billed as 1 second ($0.05), and a 10.1-second clip is billed as 11 seconds ($0.55). `type=all` is billed for both the glb extraction above and the (free) 2D render. `enable_smoothing` and `target_fps` don't change the price. Once ready, download both the `mixamo` and `metahuman` skeletons from [Export 3D Motion](/v1/api-reference/motions/export).
## Text-to-Motion
[Create Motion (from Text)](/v1/api-reference/motions/create-from-text) — `POST /v1/motions` with a JSON `text` body — is a **flat \$0.10 (10 credits)** per request, regardless of `duration_seconds`, `guidance_scale`, or `smooth`. This is a distinct price from the per-second [Motion 3D](#motion-3d) `glb` extraction above; a text-generated Motion is always `type: glb` under the hood, but it is not billed by duration.
## H3 Video
[Generate Video](/v1/api-reference/videos/create-from-text) (`POST /v1/videos`) covers all four MiniMax H3 modes — text-to-video, [first-frame](/v1/api-reference/videos/create-from-first-frame), [first-and-last-frame](/v1/api-reference/videos/create-from-first-last-frame), and [reference-video/image](/v1/api-reference/videos/create-from-reference-video) — on one price, driven by `duration_s` and `resolution`:
```
cost = ceil(duration_s) × ($0.05 if resolution == "4k" else $0.01)
```
`duration_s` (5–15, default `5`) is rounded up to the next whole second before the rate is applied. `resolution` (`480p`, `768p`, `1080p`, `2k`, or `4k`, default `768p`) sets the per-second rate — **$0.01/second (1 credit)** at every tier except `4k`, which is **$0.05/second (5 credits)**. `quality` (`low` for faster generation, `high` for higher fidelity) and `aspect_ratio` do **not** change the price — both quality tiers are billed at the same rate for a given resolution. `enhance` (an optional AI prompt-rewrite pass — see [Generate Video (from Text)](/v1/api-reference/videos/create-from-text)) also doesn't affect price. Every generated video includes native audio at no extra cost. Supplying a first frame, last frame, and/or reference video/images costs the same as text-only generation for the same `quality`, `resolution`, and `duration_s`.
## Character Animation
[Using Viggle-Animate](/v1/api-reference/videos/create-from-character-animation) (`POST /v1/videos`, selected by `character_image`/`character_image_url`/`character_image_urls` and an optional `driving_video`/`driving_video_url`) dispatches to a separate backend, billed at 1 credit/second:
```
reserved = ceil(driving video duration in seconds) × $0.01 # with a driving video
reserved = $0.11 # images only, no driving video
charged = min(ceil(finished video duration) × $0.01, reserved)
```
* **With a driving video**, the service measures the driving video at submission and reserves **5–16 credits ($0.05–$0.16)**. The 5–15 second duration check truncates to whole seconds first, so a 15.9-second clip is accepted and reserves 16 credits.
* **Without a driving video** (images only), there's nothing to measure, so every request reserves a fixed **11 credits (\$0.11)**.
On `ready`, the charge is settled against the finished video's duration at the same rate, capped at the reservation — settlement only ever refunds, never charges above what was reserved. There's no caller-controlled `duration_s`/`quality`/`resolution`/`aspect_ratio` for this mode. `watermark` does not affect price.
## Viggle Image
[Generate Image](/v1/api-reference/images/create) (`POST /v1/images`) is billed per image, by `resolution`:
```
cost = n × ($0.02 if resolution == "2K" else $0.01)
```
**$0.01 (1 credit) per image** at `1K`, **$0.02 (2 credits) per image** at `2K`, for `n` images (1–6) — for example, `2K` with `n=3` is **\$0.06 (6 credits)**. `aspect_ratio`, `seed`, and reference images don't change the price, even though `2K` with reference images produces a smaller size than text-only `2K`. The whole amount is reserved when the job is created — `402 INSUFFICIENT_CREDITS` if the balance can't cover it, and no generation starts. A job either produces all `n` images and is charged, or fails and is refunded in full, even if some images had already been generated before it failed.
## Import Motion
[Import Motion](/v1/api-reference/motions/import) — `POST /v1/motions/import` — copies a Viggle motion template into your account as your own reusable Motion. It's billed at **$0.01 per second** of the source template's duration, with a minimum of **$0.01 (1 credit)**. This is unrelated to the Video Remix, Motion, and Motion 3D prices above — it doesn't process any media of yours, it only clones an existing template.
## Credit reservation and settlement
Every billable operation reserves credits **when the asynchronous task is created**, using an estimate (for Video Remix, the requested/expected duration; for Character Animation, the driving video's measured duration or the flat images-only amount; for everything else, the price is already fixed at request time). That reservation is then settled once the task reaches a terminal state:
* **`ready`** — the reservation is settled to the final price (for Video Remix, refined against the actual completed duration; for Character Animation, refined against the finished video's duration but never above the reservation; for everything else, unchanged from the initial reservation). This is the only outcome that results in a net charge.
* **`failed`** or **`cancelled`** — the reservation is released and **no credits are charged**. `cancelled` only applies to Renders and is not client-triggerable (see [Async jobs and result availability](/v1/production/async-jobs)), but it's treated the same as `failed` for billing.
* **Retrying** after a `failed`/`cancelled` outcome means submitting a new request. That new request opens its own, independent reservation — you are not double-charged for the earlier unsuccessful attempt, since it never settled.
This reservation/settlement behavior mirrors what the Legacy extraction endpoints documented explicitly ("reserved on create, settled on success, refunded on failure"). If your integration observes different behavior for a specific V1 resource, check `error.code` and `request_id` and contact support.
## Result retention
Signed output URLs — `video_url`, `alpha_url`, `vsplat_url`, `thumbnail_url`, a Motion export's `mixamo_url`/`metahuman_url`/`metahuman_body_url`, and a generated image's `images[].url` — are valid for **1 hour** from the moment they're issued, and are **re-signed with a fresh 1-hour window** every time you read the endpoint that returns them (for example, re-fetching [Get Video](/v1/api-reference/videos/get), or calling [Export 3D Character](/v1/api-reference/characters/export) / [Export 3D Motion](/v1/api-reference/motions/export) again, or re-fetching [Get Image](/v1/api-reference/images/get)).
Generated images are deleted **24 hours after the job finishes**. After that, [Get Image](/v1/api-reference/images/get) still returns the job, but every `images[].url` is `null` — download images within that window.
Don't cache a URL past the hour — a request against an expired one fails with `RESULT_EXPIRED` (`400`). Download completed output promptly and store it in your own system for anything beyond quick testing; if you need a link again later, re-request the same export/download/get endpoint for a freshly signed one rather than reusing the old URL. See [Errors and recovery](/v1/production/errors).
## Failed jobs
If a job fails, inspect its `error` object — `code`, `retryable`, and `remediation` in particular. Do not assume a failed request created a usable output. See [Errors and recovery](/v1/production/errors).
# Async jobs and result availability
Source: https://docs.viggle.ai/v1/production/async-jobs
Poll or watch V1 jobs reliably and handle their lifecycle.
Characters and Motions are reusable asynchronous assets. Renders are asynchronous operations that deliver artifacts.
## Status lifecycle
```mermaid theme={null}
stateDiagram-v2
[*] --> queued
queued --> processing
processing --> ready
processing --> failed
processing --> cancelled
```
`cancelled` applies to Render state only. It is not client-triggerable — there is no cancel operation — but a Render can still reach it on its own; treat it as terminal when you see it. Poll every 3–5 seconds while a resource is `queued` or `processing`. Stop polling for `ready`, `failed`, or `cancelled`.
## Polling example
```python theme={null}
import time
import requests
def wait_for_resource(url, api_key, interval_seconds=3):
headers = {"Authorization": f"Bearer {api_key}"}
while True:
response = requests.get(url, headers=headers)
response.raise_for_status()
resource = response.json()
if resource["status"] == "ready":
return resource
if resource["status"] in {"failed", "cancelled"}:
error = resource.get("error") or {}
raise RuntimeError(
f"{resource['status']}: {error.get('code')} {error.get('message')}"
)
time.sleep(interval_seconds)
render = wait_for_resource(
"https://apis.viggle.ai/v1/videos/render_123abc",
"YOUR_API_KEY",
)
print(render["video_url"])
```
Render status polling and the Characters/Motions polling above use the same pattern — only the base resource URL changes. `GET /v1/renders/{render_id}` is retired (`405`); poll `GET /v1/videos/{video_id}` instead, which also accepts `vid_`-prefixed H3 generation IDs and `anim_`-prefixed character-animation IDs. See [Get Video](/v1/api-reference/videos/get) and [List Videos](/v1/api-reference/videos/list).
## Watching instead of polling
`GET /v1/renders/{render_id}/events` streams the same lifecycle over Server-Sent Events: a snapshot on connect, one event per state change, a heartbeat every 10–15 seconds, and stream closure on a terminal state. It removes polling latency and load; reconnect with `Last-Event-ID` if the connection drops. See [Watch Render](/v1/api-reference/renders/events). Characters and Motions have no equivalent stream — poll those.
## Render progress
Render responses can include:
* `progress`: an integer from 0 to 100, when available
* `stage`: a coarse hint — `analyzing`, `rendering`, `finishing` from the direct multipart pipeline; `queued`, `preparing`, `generating`, `finalizing` from the draft-based pipeline. Treat an unrecognized value as "in progress" rather than failing. On a video fetched from `GET /v1/videos/{video_id}`, `stage` is populated only while `status` is `processing`, and only for a Render-sourced ID — it is always `null` for an H3 (`vid_`) video.
* `video_url`: available only when the Render is ready
* `alpha_url`: available with transparent output
* `links`: `{self, events, download}` on the object returned by `POST /v1/renders` — absent on renders served by the legacy migration proxy, and absent entirely from the `GET /v1/videos/{video_id}` response
## Download promptly
Export and download URLs (`video_url`, `alpha_url`, `vsplat_url`, `thumbnail_url`, and a Motion export's `mixamo_url`/`metahuman_url`/`metahuman_body_url`) are short-lived and re-signed on every read of their endpoint. Persist output in your own storage if it is needed later, and re-request the export/download endpoint rather than caching a URL.
## Recover safely
* For `failed`, inspect `error.code`, `error.retryable`, and `error.remediation`, and follow the suggested recovery path — see [Errors and recovery](/v1/production/errors).
* The JSON draft form of `POST /v1/renders` requires `Idempotency-Key` and is safe to retry after a network timeout with the same key and body. The `multipart/form-data` form of `POST /v1/renders`, and Character/Motion creation, have no idempotency key: if a create response was received, query its known resource ID; otherwise resolve the outcome with your request logs and `X-Request-Id` before submitting again.
* Include `X-Request-Id` in support requests.
# Errors and recovery
Source: https://docs.viggle.ai/v1/production/errors
Handle V1 errors predictably.
All V1 errors use a single envelope, `ErrorBody`, shared by synchronous HTTP failures (wrapped in `{"error": ...}`), asynchronous resource `error` fields, and render SSE events:
```json theme={null}
{
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Insufficient credits to start this job",
"retryable": false,
"request_id": "req_123abc",
"details": {},
"remediation": {
"action": "add_credits",
"retry_after_ms": null
}
}
}
```
| Field | Type | Description |
| - | - | - |
| `code` | string | `SCREAMING_SNAKE_CASE` value from the closed vocabulary below. Use this for program logic. |
| `message` | string | Human-readable description. Do not parse it. |
| `retryable` | boolean | Whether retrying the same request could succeed. |
| `request_id` | string or null | Correlates the response with Viggle logs and support. `null` only when an asynchronous worker cannot recover the originating request ID. |
| `details` | object | Additional structured context. May be empty. |
| `remediation` | object | `{action, retry_after_ms}` — a machine-readable hint for what to do next. `retry_after_ms` is `null` unless the recommended action is timed. |
Internal implementation codes are never exposed.
## Error codes
| `code` | HTTP | Typical cause | Recovery |
| - | - | - | - |
| `UNAUTHENTICATED` | 401 | No credential supplied. | Add `Authorization: Bearer YOUR_API_KEY`. |
| `INVALID_CREDENTIAL` | 401 | API key or OAuth token is invalid or expired. | Replace the credential. |
| `FORBIDDEN` | 403 | Credential does not grant access to this resource. | Use a credential scoped to the right project/principal. |
| `INVALID_REQUEST` | 400 | Missing input, malformed field, or unsupported parameter combination. | Correct the request from the endpoint's parameter table. |
| `INVALID_CHARACTER` / `INVALID_MOTION` | 400 | Supplied character/motion source failed validation. | Check the source file, URL, or asset ID. |
| `UNSUPPORTED_MEDIA` | 400 / 422 | File type or encoding not accepted. | Convert to a supported format. |
| `CONTENT_POLICY_VIOLATION` | 422 | Media rejected by content policy. | Do not retry unchanged; use different source media. |
| `NO_HUMANS_DETECTED` | 422 | No person detected in the source image/video. | Use source media with a clearly visible person. |
| `VIDEO_TOO_LONG` / `VIDEO_TOO_LARGE` / `VIDEO_RESOLUTION_TOO_HIGH` / `VIDEO_DIMENSIONS_MUST_BE_EVEN` / `VIDEO_UNREADABLE` / `VIDEO_INACCESSIBLE` | 400 / 422 | Motion source video fails a specific constraint. | Trim, re-encode, resize, or re-host the video and retry. |
| `IMAGE_REQUIRED` / `MOTION_VIDEO_REQUIRED` | 400 | Required source omitted. | Supply the missing `image`/`image_url` or `motion_video`/`motion_video_url`. |
| `INVALID_BACKGROUND_MODE` | 400 | `background_mode`/`aspect_ratio` combination not supported. | Use one of the documented enum values; note only `aspect_ratio: source` is served today. |
| `UPLOAD_REQUIRED` | 400 / 409 | `POST /v1/renders` (JSON) called before all declared uploads completed. | Finish every `PUT` from `renders/prepare`, then include a matching `upload_completions` entry. |
| `UPLOAD_MISMATCH` | 400 | An `upload_completions` entry doesn't match a declared upload slot. | Re-check `upload_handle` values against the `prepare` response. |
| `UPLOAD_EXPIRED` | 409 | A direct-upload URL's `expires_at` passed before the `PUT`. | Call `renders/prepare` again for a fresh upload plan. |
| `DRAFT_NOT_FOUND` | 404 | `draft_id` doesn't exist or already expired. | Call `renders/prepare` again. |
| `DRAFT_ALREADY_CONSUMED` | 409 | The draft was already used to create a render. | Use the render returned by that earlier call; don't resubmit the same draft. |
| `IDEMPOTENCY_KEY_REUSED` | 409 | `Idempotency-Key` reused with different normalized input. | Use a new key for a genuinely new render, or resend with the original input to fetch the original render. |
| `CHARACTER_NOT_FOUND` / `MOTION_NOT_FOUND` / `RENDER_NOT_FOUND` / `MOTION_TEMPLATE_NOT_FOUND` / `NOT_FOUND` | 404 | The resource doesn't exist, or belongs to another account — the two cases are deliberately indistinguishable. | Verify the ID and that it belongs to the calling principal. |
| `MOTION_NOT_READY` | 400 / 409 | A reusable Motion is still processing. | Poll the Motion until `ready`, then submit the Render. |
| `MOTION_MODEL_MISMATCH` | 400 | Requested model/skeleton incompatible with how the Motion was created. | Re-check the `model`/`download_type` value against the Motion's `type`. |
| `ID_ALREADY_EXISTS` | 409 | A client-supplied `task_id` is already in use. | Use a new `task_id`, or treat the existing task as the result. |
| `RENDER_NOT_CANCELLABLE` | 409 | Reserved — Render cancellation is not a published client operation. | Not applicable to normal integrations. |
| `EXTRACTION_UNAVAILABLE` | 502 | The 3D extraction backend is temporarily unavailable. | Retry with backoff. |
| `EXTRACTION_FAILED` | 502 | The 3D extraction itself failed. | Inspect `vsplat.error`/`glb.error`; retry only if `retryable` is `true`. |
| `INSUFFICIENT_CREDITS` | 402 | Not enough balance to start the operation. | Add credits in the [Dashboard](https://portal.viggle.ai/dashboard). |
| `RATE_LIMITED` | 429 | Too many requests. | Back off using `remediation.retry_after_ms` or the `Retry-After` header. |
| `SERVICE_BUSY` / `ALL_WORKERS_BUSY` / `WORKER_STOPPED` | 503 | Temporary processing capacity issue. | Retry with exponential backoff. |
| `INTERNAL_ERROR` / `PROCESSING_FAILED` / `TASK_FAILED` | 500 | Unexpected server-side failure. | Retry only if `retryable` is `true`; contact support with `request_id` if persistent. |
| `RESULT_EXPIRED` | 400 | Requested output's signed URL window has passed. | Re-request the export/download endpoint for a freshly signed URL. |
| `AVATAR_NOT_FOUND` / `ANIMATION_NOT_FOUND` | 404 | Reserved for requests still routed through the legacy migration proxy. New integrations never trigger these — use Character/Motion `export` instead. | Migrate off `/v1/avatars` and `/v1/animations`; see [Migrate from Legacy](/v1/production/migrate-from-legacy). |
## Retry policy
Use `retryable` and `remediation` on the error itself rather than hardcoding a policy per HTTP status — they're specific to what actually happened. As a baseline: retry temporary service failures (`RATE_LIMITED`, `SERVICE_BUSY`, `ALL_WORKERS_BUSY`, `WORKER_STOPPED`, and `retryable: true` `INTERNAL_ERROR`s) with exponential backoff, honoring `retry_after_ms` or `Retry-After` when present. Don't retry a 400-, 401-, 402-, 403-, 404-, 409-, or 422-level response unchanged.
The JSON draft form of `POST /v1/renders` requires `Idempotency-Key`, so it's safe to retry after a network timeout with the same key and body — you'll get the original render back rather than a duplicate. The `multipart/form-data` form of `POST /v1/renders`, and Character/Motion creation, have no such key: if a create response was received, use its resource ID; otherwise resolve the outcome with your request logs and `X-Request-Id` before submitting again.
## Failed async jobs
An HTTP `200` response from a status endpoint can still describe a failed job — the resource's own `error` field carries the same `ErrorBody`:
```json theme={null}
{
"id": "render_123abc",
"status": "failed",
"error": {
"code": "TASK_FAILED",
"message": "The render could not be completed.",
"retryable": false,
"request_id": "req_123abc",
"details": {},
"remediation": { "action": "contact_support", "retry_after_ms": null }
}
}
```
Treat `failed` as terminal. Keep the resource ID and `request_id` for support.
# Migrate from Legacy to V1
Source: https://docs.viggle.ai/v1/production/migrate-from-legacy
Move an existing Viggle integration from the Legacy API to V1.
**Legacy is deprecated.** New integrations must use V1. Keep the old endpoint only while you complete this migration.
## Endpoint mapping
| Legacy | V1 |
| - | - |
| `POST /api/characters/preprocess` | `POST /v1/characters` |
| `/api/scenes/*` | `/v1/motions/*` |
| `POST /api/render` | `POST /v1/renders` (`multipart/form-data`) |
| `GET /api/render/{job_id}` | `GET /v1/videos/{video_id}` (unified with H3 and character-animation generations; `GET /v1/renders/{render_id}` is retired) |
| `DELETE /api/render/{job_id}` | *(removed — Render cancellation is not a published V1 operation)* |
| `GET /api/credits` | `GET /v1/credits` |
| `/api/extract/static-bin` | `POST /v1/characters` with `type=vsplat` (or `all` to keep the render) |
| `/api/extract/dynamic-fbx` | `POST /v1/motions` with `type=glb` (or `all` to keep the render) |
There is no separate `/v1/avatars` or `/v1/animations` resource in V1 — 3D extraction is a property (`type`, `vsplat`/`glb`) of the same Character/Motion asset used for 2D renders, and its output is fetched from [`GET /v1/characters/{character_id}/export`](/v1/api-reference/characters/export) or [`GET /v1/motions/{motion_id}/export`](/v1/api-reference/motions/export) instead of a standalone `avatar_`/`anim_` resource.
The extraction controls keep mostly the same names in V1, with a few changes: there is no caller-facing vsplat model selection (Legacy's `model` field is gone — the service chooses internally) or `fp` field; callers only choose whether to generate a thumbnail with `render_thumbnail`, while its render source and pose are selected internally; and the output is a single `.vsplat` file (`vsplat_url`) instead of `bin_url`/`stripped_bin_url`. Motion extraction accepts `enable_smoothing`, `target_fps`, and `task_id` unchanged. Only the source fields change: use `image`, `image_url`, or `character_id` for the character, and `motion_video`, `motion_video_url`, or `motion_id` for the motion — both alongside the new `type` field.
## Response mapping
| Legacy | V1 |
| - | - |
| `scene_id` | `motion_id` |
| `job_id` | `id` with a `render_` prefix |
| `complete` | `ready` |
| `cdn_url` | `video_url` |
| `mask_cdn_url` | `alpha_url` |
| top-level error fields | nested `error` object with `code`, `message`, `retryable`, `request_id`, `details`, `remediation` |
| `bin_url` / `stripped_bin_url` | `vsplat_url`, from `GET /v1/characters/{character_id}/export` |
| `fbx_url` | `mixamo_url`/`metahuman_url`/`metahuman_body_url` from `GET /v1/motions/{motion_id}/export?download_type=fbx` (`download_type=glb` for the GLB equivalents) |
## Recommended migration sequence
1. Update request paths and form-field names, including the new `type` field on Character/Motion creation.
2. Store V1 prefixed IDs (`char_`, `mot_`, `render_`) instead of mixing UUIDs and job IDs. There is no separate `avatar_`/`anim_` family to track.
3. Update polling to check for `ready`, `failed`, and `cancelled` — and stop relying on client-triggered cancellation, since V1 has none.
4. Update error handling to use `error.code` (now `SCREAMING_SNAKE_CASE`) and `error.retryable`/`error.remediation`.
5. Verify transparent-output consumers use `alpha_url`.
6. If you fetch 3D output, switch from `GET /v1/avatars/{avatar_id}` / `GET /v1/animations/{animation_id}` to `GET /v1/characters/{character_id}/export` / `GET /v1/motions/{motion_id}/export`.