Campaign endpoints
The batch workflow: draft, assets, recipients, ready, launch.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /campaigns | Create a new campaign |
| GET | /campaigns | List campaigns (paginated) |
| GET | /campaigns/:id | Get campaign detail + recipient stats |
| PATCH | /campaigns/:id | Update campaign (draft only) |
| DELETE | /campaigns/:id | Soft-delete campaign (any status except rendering) |
A campaign moves draft → ready → rendering → completed (or failed / cancelled). Assets, recipients and settings are editable in draft only — every write endpoint below that says "draft only" answers 409 CAMPAIGN_NOT_DRAFT otherwise.
The campaign object
| Field | Type | Description |
|---|---|---|
| id | string (uuid) | Campaign id. |
| userId | string (uuid) | Owner. |
| name | string | 1-200 characters. |
| description | string | omitted | Free text. Omitted when never set. |
| status | string | draft, ready, rendering, completed, failed or cancelled.Values draftreadyrenderingcompletedfailedcancelled |
| placeholderWord | string | The word in the base recording that gets replaced. Empty string means the campaign has none. |
| greetingStartMs | number | Start of the greeting window in the base recording, in milliseconds. Default 0 |
| greetingEndMs | number | End of the greeting window, in milliseconds. Default 3000 |
| pipLayout | string | Where the screen recording sits against the talking head. none renders greeting-only. Values bottom-rightbottom-leftside-panelnoneDefaultbottom-right |
| pipDurationSeconds | number | Screen-recording length in seconds. 0 lets the pipeline decide. Default 0 |
| videoHost | string | Resolved host. play is the Outvo player. Settable while draft only; immutable after launch. Values playvimeo |
| recordingMode | string | Viewport used for the screen recording. Mobile records portrait. Values desktopmobileDefaultdesktop |
| scrollStartSec | number | null | Seconds before scrolling begins. null uses the pipeline default. Values 0-30nullDefaultnull |
| scrollDownSec | number | null | Seconds spent scrolling down. Values 0-30nullDefaultnull |
| scrollPauseSec | number | null | Seconds paused at the bottom. Values 0-30nullDefaultnull |
| scrollUpSec | number | null | Seconds spent scrolling back up. Values 0-30nullDefaultnull |
| disableScroll | boolean | null | Hold the recorded page still rather than scrolling it. Values truefalsenullDefaulttrue |
| nameSpotlight | boolean | null | Emphasize the name moment visually. Values truefalsenullDefaultnull |
| liveRecordingOverlay | boolean | null | Draw the live-recording chrome over the capture. Values truefalsenullDefaultnull |
| deletedAt | string | omitted | Set while soft-deleted. Restorable for 7 days. |
| recipientCount | number | Present on list and detail. |
| completedCount | number | Detail only. Recipients in completed status. |
| failedCount | number | Detail only. |
| degradedCount | number | Detail only. Rendered without the screen recording — never billed. |
| cancelledCount | number | Detail only. |
| createdAt | string (ISO 8601) | Creation timestamp. |
| updatedAt | string (ISO 8601) | Last write timestamp. |
Create, read, update, delete
POST/campaigns
Create a draft campaign. Everything except name is optional and can be PATCHed later.
Success: 201 Created
Headers
| Field | Type | Description |
|---|---|---|
| Idempotency-Key | string | Optional, max 255 characters. A retry with the same key replays the original campaign instead of minting a duplicate — see Idempotency. |
Body
| Field | Type | Description |
|---|---|---|
| namereq | string | 1-200 characters. |
| description | string | Free text. |
| placeholderWord | string | The word spoken in the base recording. Optional, and an empty string is legal — a campaign with no placeholder renders the base video with no name splice. |
| greetingStartMs | number | Milliseconds, minimum 0. Default 0 |
| greetingEndMs | number | Milliseconds, minimum 0. Default 3000 |
| pipLayout | string | Where the screen recording sits against the talking head. none renders greeting-only. Values bottom-rightbottom-leftside-panelnoneDefaultbottom-right |
| pipDurationSeconds | number | Seconds, minimum 0. 0 lets the pipeline decide. Default 0 |
| videoHost | string | Falls back to your account default, and to vimeo if you have not set one. Values playvimeo |
| recordingMode | string | Viewport used for the screen recording. Mobile records portrait. Values desktopmobileDefaultdesktop |
| scrollStartSec | number | null | Seconds before scrolling begins. null uses the pipeline default. Values 0-30nullDefaultnull |
| scrollDownSec | number | null | Seconds spent scrolling down. Values 0-30nullDefaultnull |
| scrollPauseSec | number | null | Seconds paused at the bottom. Values 0-30nullDefaultnull |
| scrollUpSec | number | null | Seconds spent scrolling back up. Values 0-30nullDefaultnull |
| disableScroll | boolean | null | Send false to scroll the recorded page. An explicit null also resolves to true. Values truefalsenullDefaulttrue |
| nameSpotlight | boolean | null | Emphasize the name moment visually. Values truefalsenullDefaultnull |
| liveRecordingOverlay | boolean | null | Draw the live-recording chrome over the capture. Values truefalsenullDefaultnull |
Request
POST /campaigns
{
"name": "Q2 Outbound",
"placeholderWord": "there",
"pipLayout": "bottom-right",
"pipDurationSeconds": 8
}Response
201 Created
{
"data": {
"id": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"userId": "...",
"name": "Q2 Outbound",
"status": "draft",
"placeholderWord": "there",
"greetingStartMs": 0,
"greetingEndMs": 3000,
"pipLayout": "bottom-right",
"pipDurationSeconds": 8,
"videoHost": "vimeo",
"recordingMode": "desktop",
"scrollStartSec": null,
"scrollDownSec": null,
"scrollPauseSec": null,
"scrollUpSec": null,
"disableScroll": true,
"nameSpotlight": null,
"liveRecordingOverlay": null,
"createdAt": "2026-08-13T12:00:00.000Z",
"updatedAt": "2026-08-13T12:00:00.000Z"
}
}Errors
| Code | HTTP | When |
|---|---|---|
| VALIDATION_ERROR | 400 | Missing name, name outside 1-200 characters, or an enum/range violation. |
| IDEMPOTENCY_KEY_REUSED | 422 | Same Idempotency-Key, different body. |
GET/campaigns
List your campaigns, newest first. Soft-deleted campaigns are excluded.
Success: 200 OK
List rows carry recipientCount but not the per-status counts — fetch the campaign by id for those.
Query parameters
| Field | Type | Description |
|---|---|---|
| page | number | Page number, minimum 1. Default 1 |
| per_page | number | Items per page, maximum 100. Default 20 |
| search | string | Case-insensitive substring match on name, max 200 characters. A blank value is not a filter. % and _ are matched literally; * behaves as a wildcard. |
Response
200 OK
{
"data": [ { "id": "...", "name": "Q2 Outbound", "status": "completed", "recipientCount": 42, ... } ],
"meta": { "page": 1, "perPage": 20, "total": 7, "totalPages": 1 }
}GET/campaigns/:id
Fetch one campaign with its live recipient breakdown.
Success: 200 OK
This endpoint self-heals a stuck campaign: if the status is still rendering while every recipient has reached a terminal state, it transitions to completed (or failed, if nothing succeeded) before responding. That path does not deliver a campaign.completed webhook, so do not use polling as a substitute for one.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{
"data": {
"id": "...",
"name": "Q2 Outbound",
"status": "completed",
"recipientCount": 42,
"completedCount": 40,
"failedCount": 1,
"degradedCount": 1,
"cancelledCount": 0,
"...": "all campaign-object fields"
}
}Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
PATCH/campaigns/:id
Partial update. Only the keys you send are written; draft campaigns only.
Success: 200 OK
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Request
PATCH /campaigns/9a8b7c6d-...
{ "placeholderWord": "there", "pipDurationSeconds": 10 }Response
200 OK
{ "data": { /* the updated campaign object */ } }Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
| CAMPAIGN_NOT_DRAFT | 409 | The campaign has left draft. Unready it first, if it is ready. |
| VALIDATION_ERROR | 400 | An enum or range violation. |
DELETE/campaigns/:id
Soft-delete. The campaign disappears from lists and is purged for good after a 7-day grace period.
Success: 204 No Content
Any status except rendering can be deleted, not just drafts. Undo with POST /campaigns/:id/restore while the grace period lasts.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or already deleted. |
| CONFLICT | 409 | The campaign is rendering. Cancel it first — deleting would tear down in-flight jobs. |
Lifecycle actions
Status-transition and recovery actions, all scoped to a single campaign. None of them takes a request body — send the POST without a Content-Type header (clone is the one exception).
| Method | Path | Description |
|---|---|---|
| POST | /campaigns/:id/ready | Transition draft → ready |
| POST | /campaigns/:id/unready | Transition ready → draft |
| POST | /campaigns/:id/launch | Start rendering all recipients |
| POST | /campaigns/:id/cancel | Cancel a rendering campaign |
| POST | /campaigns/:id/retry-failed | Re-queue every failed, degraded, cancelled and stuck recipient |
| POST | /campaigns/:id/retry-recipient/:recipientId | Re-queue one recipient |
| POST | /campaigns/:id/restore | Restore a soft-deleted campaign |
| POST | /campaigns/:id/clone | Duplicate a campaign’s settings into a new draft |
POST/campaigns/:id/ready
Lock the draft for launch. Verifies the campaign has a base video, that the video has finished processing, and that it has at least one recipient.
Success: 200 OK
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{ "data": { "id": "...", "status": "ready", ... } }Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
| CAMPAIGN_NOT_DRAFT | 409 | Only a draft can become ready. |
| VALIDATION_ERROR | 400 | No base video uploaded, or no recipients added. |
| CAMPAIGN_NOT_READY | 409 | The base video is still being processed. Wait a few seconds and call again — this is transient, not a bad request. |
POST/campaigns/:id/unready
Send a ready campaign back to draft so its settings, asset and recipients become editable again.
Success: 200 OK
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{ "data": { "id": "...", "status": "draft", ... } }Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
| VALIDATION_ERROR | 409 | The campaign is not in ready status. The message names the status it is actually in. |
POST/campaigns/:id/launch
Enqueue a render for every pending recipient and move the campaign to rendering. This is the call that spends quota.
Success: 200 OK
Launching is atomic against itself: a double-click or a retried request cannot enqueue every recipient twice. Renders are billed one at a time as they succeed, not charged up front, and a degraded render is never billed.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{
"data": { "id": "...", "status": "rendering", ... },
"meta": { "jobsEnqueued": 42 }
}Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
| ASSET_NOT_FOUND | 404 | The base video is gone — storage retention swept it. Upload it again. |
| CAMPAIGN_NOT_READY | 409 | Status is not ready, there are no pending recipients left (a concurrent launch won the race), or the campaign transitioned out of ready mid-call. |
| PLAN_LIMIT_REACHED | 402 | Plan quota is exhausted and the plan has no self-serve wallet rate (Enterprise). Contact your account manager. |
| WALLET_TOPUP_REQUIRED | 402 | Plan quota and wallet balance together do not cover the campaign. details carries shortfallVideos, estimatedShortfallCents and walletVideoRateCents. |
POST/campaigns/:id/cancel
Stop a rendering campaign: aborts in-flight jobs, drops queued ones, and marks untouched recipients cancelled.
Success: 200 OK
Cancelled recipients were never charged — billing happens per successful render, so there is nothing to credit back.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{
"data": { "id": "...", "status": "cancelled", ... },
"meta": { "cancelledRecipients": 12, "removedJobs": 9 }
}Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
| CAMPAIGN_NOT_READY | 409 | Only a campaign in rendering status can be cancelled. |
POST/campaigns/:id/retry-failed
Re-queue every failed, degraded, cancelled and orphaned-queued recipient in one call, at retry priority.
Success: 200 OK
Nothing to retry is a success, not an error: the call returns 200 with meta.jobsEnqueued: 0. Each retried recipient re-rolls its TTS take, so a bad-sounding greeting is redrawn rather than reproduced.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{
"data": { "id": "...", "status": "rendering", ... },
"meta": { "jobsEnqueued": 3 }
}Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown id, not yours, or soft-deleted. |
| CAMPAIGN_NOT_READY | 409 | Status must be completed, rendering, failed or cancelled. |
| ASSET_NOT_FOUND | 404 | The base video is gone — storage retention swept it. |
| PLAN_LIMIT_REACHED / WALLET_TOPUP_REQUIRED | 402 | Retries count against quota exactly like first renders. |
POST/campaigns/:id/retry-recipient/:recipientId
Re-queue exactly one recipient, at retry priority.
Success: 200 OK
The source-drift refusal exists because the cloned voice is a function of the base recording: retrying against a replaced video would ship a recipient a video in a different voice from the rest of the campaign.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
| recipientIdreq | string (uuid) | Recipient id, which must belong to that campaign. |
Query parameters
| Field | Type | Description |
|---|---|---|
| force | string | force=true renders against the current base recording even when it changed since this recipient last rendered. Without it, that case is refused — see below. |
Response
200 OK
{
"data": { "id": "...", "status": "rendering", ... },
"meta": { "jobsEnqueued": 1, "jobId": "render-..." }
}Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown campaign id, not yours, or soft-deleted. |
| RECIPIENT_NOT_FOUND | 404 | The recipient does not belong to that campaign. |
| CONFLICT | 409 | The recipient is currently processing, or the base recording changed since its last render (retry with ?force=true, or re-render the whole campaign). |
| CAMPAIGN_NOT_READY | 409 | Status must be completed, rendering, failed or cancelled. |
| PLAN_LIMIT_REACHED / WALLET_TOPUP_REQUIRED | 402 | A retry costs one render. |
POST/campaigns/:id/restore
Undo a soft-delete within the 7-day grace period.
Success: 200 OK
Restoring a campaign that is not deleted is a no-op success, not an error.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Campaign id. |
Response
200 OK
{ "data": { "id": "...", "deletedAt": null, ... } }Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | The row is gone for good — the retention sweep already purged it. |
POST/campaigns/:id/clone
Copy a campaign's settings, base video and recipient list into a fresh draft. Recipients come back as pending; nothing is rendered.
Success: 201 Created
The base video is deep-copied into the new campaign, so deleting the source later does not break the clone. If any step fails the whole clone is rolled back — you never get a half-built campaign.
Path parameters
| Field | Type | Description |
|---|---|---|
| idreq | string (uuid) | Source campaign id. |
Body
The body is optional — send no body at all to accept both defaults.
| Field | Type | Description |
|---|---|---|
| name | string | 1-200 characters. Defaults to the source name plus " (copy)", truncated to fit. |
| description | string | Defaults to the source description. |
Request
POST /campaigns/9a8b7c6d-.../clone
{ "name": "Q3 Outbound" }Response
201 Created
{ "data": { "id": "<new id>", "name": "Q3 Outbound", "status": "draft", "recipientCount": 42, ... } }Errors
| Code | HTTP | When |
|---|---|---|
| CAMPAIGN_NOT_FOUND | 404 | Unknown source id, not yours, or soft-deleted. |
| VALIDATION_ERROR | 400 | name outside 1-200 characters. |
