Render endpoints
The campaign-free path: one call in, a webhook or poll for the result.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /renders | Create 1-100 renders in one call |
| GET | /renders | List; filter by batch_id, status, campaign_id, or on_demand=true (no campaign) |
| GET | /renders/:id | Get a single render |
| POST | /renders/:id/cancel | Cancel a queued/processing render |
| POST | /renders/:id/retry | Re-run a finished render in place |
Send the same video URL on every call — it's content-addressed, so a repeat costs a lookup, not a re-upload, and the voice clone is reused automatically. There is no synchronous mode: renders take 1-5 minutes, so the terminal render.* webhook (or a poll) is the completion signal.
The render object
Every endpoint on this page returns this shape under data.
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | Render id. Use it for GET, cancel and retry. |
| batchId | string (uuid) | The batch this render was created in. One POST /renders call = one batch. |
| campaignId | string | null | Set only when you passed campaignId on create; null for a pure on-demand render. |
| status | string | queued, processing, completed, degraded, failed or cancelled. The last four are terminal. |
| recipient | object | The recipient snapshot as submitted — firstName plus whichever of company, title, language, pronunciation, url you sent. |
| metadata | object | Your passthrough object, echoed verbatim. {} when you sent none. |
| degradationReason | string | null | Why a degraded render dropped the screen recording. A degraded render is never billed. |
| videoUrl | string | null | Direct video file URL. null until the render finishes. |
| shareableUrl | string | null | The Outvo play page link — the URL to put in an email. |
| thumbnailUrl | string | null | The raw signed storage URL, which expires. Use it as a null check only; for email, build the durable proxy from shareableUrl + '/thumb.gif' — see Email embed. |
| createdAt | string (ISO 8601) | When the render was created. |
| completedAt | string | null | When it reached a terminal status. |
Reference
POST/renders
Create 1-100 renders from one recording. Nothing is enqueued or billed unless the whole batch validates.
Success: 202 Accepted — or 200 OK on an idempotent replay
Validation is collected across the whole batch: a 100-recipient call reports every bad row in one 400 rather than making you fix them one at a time, and no render is created or billed until all of them pass.
url and the capture produces nothing, you get render.failed — not a greeting-only video. That is deliberate and differs from the campaign path, which ships the greeting-only video flagged as degraded and unbilled: a person can look at one and judge it, an automation ships whatever it is handed. Fix the URL and retry. degraded is still reachable here for other reasons, such as a lip-sync fallback.Headers
| Field | Type | Description |
|---|---|---|
| Idempotency-Key | string | Optional, max 255 characters. A replay with the same key and the same body returns 200 with the original batch at its current status; a different body returns 422. See Idempotency. |
Body
application/json or multipart/form-data. In multipart, send the recording as the file part and placeholderWord, campaignId as plain fields, with recipients and options as JSON-encoded strings. Uploads are capped at 200 MB.
| Field | Type | Description |
|---|---|---|
| video | string (url) | The base recording, as an http/https URL. Required unless you send a videoChecksum that already resolves, or upload the file as multipart. Fetched once and content-addressed; a repeat URL costs a HEAD, not a download. |
| videoChecksum | string | SHA-256 of the recording bytes (sha256sum your-video.mp4). Skips the fetch entirely when Outvo already holds those bytes. An unknown checksum with no video and no upload is a 400. |
| placeholderWordreq | string | The word in your recording that gets replaced with each name — e.g. there if you say "Hey there". |
| recipientsreq | array | 1 to 100 recipient objects (fields below). |
| options | object | Per-batch recording options (fields below). Omitted keys use the pipeline defaults. |
| campaignId | string (uuid) | Associate these renders with an existing campaign for reporting. Optional; the renders still run on this path, not the campaign lifecycle. |
recipients[] fields
| Field | Type | Description |
|---|---|---|
| firstNamereq | string | The name spoken in the greeting. |
| company | string | Carried on the render for your own reporting. |
| title | string | Carried on the render for your own reporting. |
| language | string | Language hint for the TTS greeting, e.g. "en", "pt". |
| pronunciation | string | Phonetic respelling used when the name is not pronounced as written. |
| url | string (url) | The page to screen-record behind the greeting. Must be http/https, and is required unless options.pipLayout is none — a missing URL with PIP on would silently bill for a weaker video. |
| metadata | object | Arbitrary JSON, max 16 KB per recipient. Echoed verbatim on GET and in the terminal webhook — the correlation key back to your own record. |
options fields
| Field | Type | Description |
|---|---|---|
| pipLayout | string | Where the screen recording sits against the talking head. none renders a greeting-only talking head and makes url optional.Values bottom-rightbottom-leftside-panelnoneDefaultbottom-right |
| pipDurationSeconds | number | How long the screen recording runs, in seconds. 0 lets the pipeline decide. Default 0 |
| recordingMode | string | Viewport used for the screen recording. Mobile records portrait. Values desktopmobileDefaultdesktop |
| scrollStartSec | number | null | Seconds before scrolling begins. 0-30, or null for the pipeline default. Values 0-30nullDefaultnull |
| scrollDownSec | number | null | Seconds spent scrolling down. 0-30, or null. Values 0-30nullDefaultnull |
| scrollPauseSec | number | null | Seconds paused at the bottom. 0-30, or null. Values 0-30nullDefaultnull |
| scrollUpSec | number | null | Seconds spent scrolling back up. 0-30, or null. Values 0-30nullDefaultnull |
| disableScroll | boolean | null | Hold the page still instead of scrolling it. Omitted means no scroll on this path; an explicit false scrolls. Values truefalsenull |
| nameSpotlight | boolean | null | Emphasize the name moment visually. Values truefalsenull |
| liveRecordingOverlay | boolean | null | Draw the live-recording chrome over the screen capture. Values truefalsenull |
Request
POST /renders
Authorization: Bearer pc_live_xxx
Content-Type: application/json
Idempotency-Key: batch-2026-08-13-001
{
"video": "https://your-cdn.com/base-recording.mp4",
"placeholderWord": "there",
"recipients": [
{
"firstName": "Sarah",
"company": "Acme",
"title": "Head of Growth",
"url": "https://acme.com",
"metadata": { "row": 1, "crmId": "003xx" }
}
],
"options": { "pipLayout": "bottom-right", "recordingMode": "desktop" }
}Response
202 Accepted
{
"data": {
"batchId": "6f5b1a2c-6d1e-4a71-9f0e-1c2f3a4b5c6d",
"renders": [
{
"id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"batchId": "6f5b1a2c-6d1e-4a71-9f0e-1c2f3a4b5c6d",
"campaignId": null,
"status": "queued",
"recipient": { "firstName": "Sarah", "company": "Acme", "url": "https://acme.com" },
"metadata": { "row": 1, "crmId": "003xx" },
"degradationReason": null,
"videoUrl": null,
"shareableUrl": null,
"thumbnailUrl": null,
"createdAt": "2026-08-13T12:00:00.000Z",
"completedAt": null
}
]
}
}Errors
| Code | HTTP | When |
|---|---|---|
| VALIDATION_ERROR | 400 | Any invalid recipient row, a missing placeholderWord, a bad option value, or an unresolvable videoChecksum. The message lists every bad row at once — nothing was rendered. |
| FILE_TOO_LARGE | 400 | A multipart upload over 200 MB. |
| PLAN_LIMIT_REACHED | 402 | The whole batch is reserved up front: 100 renders against 3 remaining fails here, before anything is queued. |
| IDEMPOTENCY_KEY_REUSED | 422 | Same Idempotency-Key, different body. |
| RATE_LIMITED | 429 | Over 100 requests/min. Retry after the Retry-After header. |
GET/renders
List your renders. This is the poll path when you have no webhook endpoint.
Success: 200 OK
Query parameters
| Field | Type | Description |
|---|---|---|
| page | number | Page number, minimum 1. Default 1 |
| per_page | number | Items per page, maximum 100. Default 20 |
| status | string | Filter by exact status, e.g. queued, completed, failed. |
| batch_id | string (uuid) | Collect one POST /renders call. Results come back in submission order rather than newest-first. |
| campaign_id | string (uuid) | Only renders associated with that campaign. |
| on_demand | boolean | true returns only renders with no campaign at all. Wins over campaign_id if you send both. |
Response
200 OK
{
"data": [ /* render objects, newest first (or batch order with batch_id) */ ],
"meta": { "page": 1, "perPage": 20, "total": 42, "totalPages": 3 }
}Errors
| Code | HTTP | When |
|---|---|---|
| RATE_LIMITED | 429 | Over 100 requests/min. |
GET/renders/:id
Fetch one render, including its output URLs once it has finished.
Success: 200 OK
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Render id from the create response. |
Response
200 OK
{
"data": {
"id": "b1c2d3e4-f5a6-4b7c-8d9e-0f1a2b3c4d5e",
"batchId": "6f5b1a2c-6d1e-4a71-9f0e-1c2f3a4b5c6d",
"campaignId": null,
"status": "completed",
"recipient": { "firstName": "Sarah", "company": "Acme", "url": "https://acme.com" },
"metadata": { "row": 1 },
"degradationReason": null,
"videoUrl": "https://cdn.outvo.io/outputs/....mp4",
"shareableUrl": "https://share.outvo.io/v/abc123",
"thumbnailUrl": "https://...signed...",
"createdAt": "2026-08-13T12:00:00.000Z",
"completedAt": "2026-08-13T12:03:41.000Z"
}
}Errors
| Code | HTTP | When |
|---|---|---|
| NOT_FOUND | 404 | No render with that id on your account. |
POST/renders/:id/cancel
Stop a render that has not finished. An in-flight job is aborted, not merely dequeued.
Success: 200 OK
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Render id. |
Response
200 OK
{ "data": { "id": "...", "status": "cancelled", "completedAt": "2026-08-13T12:01:10.000Z", ... } }Errors
| Code | HTTP | When |
|---|---|---|
| NOT_FOUND | 404 | No render with that id on your account. |
| VALIDATION_ERROR | 409 | The render already reached a terminal status. Only queued or processing renders can be cancelled. |
POST/renders/:id/retry
Re-run a finished render in place, against the same recording and recipient. Jumps the queue.
Success: 200 OK
The render is reset in place — the same id keeps its history, and degradationReason, completedAt are cleared. A retry is a fresh render: it is billed if it succeeds.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Render id. |
Response
200 OK
{ "data": { "id": "...", "status": "queued", "degradationReason": null, "completedAt": null, ... } }Errors
| Code | HTTP | When |
|---|---|---|
| NOT_FOUND | 404 | No render with that id on your account. |
| VALIDATION_ERROR | 409 | The render is still queued or processing; the original recording has been swept by retention (submit it again); or the status changed between the check and the write. |
| PLAN_LIMIT_REACHED | 402 | A retry counts as one render against your quota. |
