← All documentation

The API

Create a demo from a script, attach a session, read a job back with its beats and a stable error code, and list the assets and voices you can name — every field, on one page.

Everything the composer does, a script can do: the API is the same create path the form walks, validated by the same schema and counted against the same allowances. Every request and every answer is JSON.

There are no API keys yet. A request rides on the same browser cookie the app uses, and a request with no cookie is answered as a guest — a session is minted for it, and it is counted and capped exactly like a guest at the composer (see Guests and accounts). Send the cookie back on every later call, or the jobs you created will not be yours to read.

Create a demo

POST /api/jobs with a JSON body. Two fields are required; everything else is optional, and an unknown key is dropped rather than refused.

FieldWhat it is
urlThe page to record.
promptThe intent line — what to show. See The URL and the intent line.
qualityOne of standard, high and ultra. Absent is the free tier; the others need Pro.
formatOne of desktop and mobile. Absent is desktop.
connectionIdRecord signed in, through one of your saved connections.
sessiontrue to attach a one-shot browser session instead (below). Not together with connectionId.
introAssetId, outroAssetId, audioAssetIdFinishing, by library asset id.
introTransition, outroTransitionWhat happens at each stinger's seam: one of cut, fade and dip. Absent is a cut.
voiceProfileIdThe voice that narrates this one demo, from GET /api/voices (below).
{
  "url": "https://…",
  "prompt": "Show how a new project is created and shared",
  "quality": "high",
  "format": "desktop",
  "connectionId": "3f1c9c0e-6c1e-4a3a-9c1e-2b1f0d8a7e11",
  "introAssetId": "b0b3e8b2-2c8d-4a9d-8c3a-1d2e3f4a5b6c",
  "introTransition": "fade",
  "voiceProfileId": "7c2d1e9a-4b3f-4c8d-9e1f-0a1b2c3d4e5f"
}

The rules the composer enforces are enforced here too, with the same sentences:

  • An asset id must be in your library, or a built-in preset, and of the kind its slot expects.
  • A transition needs a stinger to run against. "introTransition": "fade" with no introAssetId is refused rather than ignored — see Intros, outros and audio.
  • A voiceProfileId must be on the account's live voice list, and naming one at all is a Pro lever — the same two gates the workspace voice in Settings passes. Absent, the demo narrates in the workspace's voice, or the house voice when none is set. If the voice service cannot be reached to check the id, the create is refused rather than trusted; try again in a moment.
  • The saved connection must be one of yours.

A created job answers 202 Accepted:

{ "id": "…", "status": "queued", "quality": "high", "url": "/jobs/…" }

The other answers, each with a machine-readable error and a sentence you can show:

  • 400 — the body is not JSON, or a field fails validation; issues lists which.
  • 422 — the body is well formed but one of the rules above refused it; error is the sentence.
  • 402 upgrade_required — a paid lever on a workspace that has not paid; features names the lever, message says so in words. No amount of waiting fixes it, so it carries no Retry-After.
  • 429 quota_exhausted — an allowance is spent; scope says which, limit the number, and Retry-After how long. See Limits.

A scripted job

Send "kind": "scripted" and a complete demoscript instead of a prompt, and the job replays your plan with no discovery and no model call. title is optional and becomes the library's name for it; the finishing ids, the two transitions, session and connectionId mean exactly what they mean on a demo create. The URL, the tier and the format come from the plan's own meta, so there is nowhere to state them twice. See The kinds of job.

Attaching a one-shot session

When session is true the job is created but not started, and the answer carries two more fields:

{
  "id": "…",
  "status": "queued",
  "url": "/jobs/…",
  "sessionUploadUrl": "https://…",
  "sessionCompleteUrl": "/api/jobs/…/session/complete"
}
  1. PUT a Playwright storage-state JSON to sessionUploadUrl with Content-Type: application/json.
  2. POST to sessionCompleteUrl. The upload is checked against the live schema, and the pipeline starts. An invalid file is deleted and answered 422; you may upload again and complete again.

The session is single-use. It is destroyed at the end of the job, whatever the outcome, which has two consequences worth knowing:

  • A refresh of that demo cannot reuse it — pressing re-shoot on a job that ran on a one-shot session is refused with a sentence that says to record again with a session attached, or to save a connection. The automatic re-shoot after a failed capture is the one exception: it copies the session under the new job's own key before the old one is destroyed, so it runs signed in.
  • A saved connection is the reusable alternative, and the better one for a script that records the same product repeatedly. See Recording behind a login and What happens to a saved session.

Reading a job

GET /api/jobs/:id answers the job's state and, once it has finished, short-lived signed URLs for its files. Every URL in the body expires; fetch a fresh body rather than storing one. An id you cannot see — unknown, or somebody else's — is a 404 either way.

FieldWhat it is
id, kind, status, quality, titleWhat the library card shows.
createdAt, updatedAtISO-8601, UTC.
urlThe job page, as an app-relative path.
cancelledtrue when failed means somebody stopped it, not that it broke.
errorThe failure sentence the job page prints, or the cancellation sentence; null otherwise.
errorCodeA stable name for that failure: one of stale_selector, no_usable_flow, bot_challenge, discovery_timeout, repeated_action, llm_budget, cancelled and unclassified. null when error is.
narrationThe soundtrack's last verdict line — why a video is silent, or that it is voiced.
stepsThe plan's beats, in order, or null before there is a stored plan.
video{ url, contentType, downloadUrl } once the video exists; null before.
poster, captions{ url } each, when the file exists.
shareUrlThe public link, or null while sharing is off.

Each entry in steps is one beat: id, kind (goto, click, fill, press or scroll), title, target and narration. The target is the label a person would read — the button's text, the field's name, the URL of a goto, the key of a press — never a selector, because a selector is an implementation detail of the page and it is not yours.

errorCode is for the branch in your code; error is for the person. A sign-in wall the session did not cover, for instance, ends the run with repeated_action and a sentence that says discovery could not get past the page it kept pressing the same button on. llm_budget is the one code that is Demofy's own fault — the day's planning budget is spent — so nothing about your request needs changing; create the job again later.

To follow a job while it runs, GET /api/jobs/:id/events is a Server-Sent Events stream: it replays the whole build log, then tails it until the job ends. It is the same log the job page shows — see Reading the build log. Reading the JSON again works too; there is no webhook.

Your library and your voices

GET /api/assets?kind=intro — or outro, or audio — lists the assets you may attach in that slot: yours, plus the built-in presets. Each carries id, kind, title, contentType, sizeBytes, durationS, a playable url, isPreset and createdAt. A signed-out caller sees only the presets. Uploading goes through the same two steps the picker uses, a signed PUT and a register; see The media library.

GET /api/voices lists the voices a demo can narrate with. Each carries id and name, plus description, language and voiceType when the voice service stated them. The id is what voiceProfileId takes. A signed-out caller is refused; an unreachable voice service answers with voices set to null and a sentence, which is not the same thing as an empty list.

GET /api/jobs lists the videos in your active workspace — the same set the library page shows, never another workspace's.