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.
{
"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 nointroAssetIdis refused rather than ignored — see Intros, outros and audio. - A
voiceProfileIdmust 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;issueslists which.422— the body is well formed but one of the rules above refused it;erroris the sentence.402 upgrade_required— a paid lever on a workspace that has not paid;featuresnames the lever,messagesays so in words. No amount of waiting fixes it, so it carries noRetry-After.429 quota_exhausted— an allowance is spent;scopesays which,limitthe number, andRetry-Afterhow 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"
}
PUTa Playwright storage-state JSON tosessionUploadUrlwithContent-Type: application/json.POSTtosessionCompleteUrl. The upload is checked against the live schema, and the pipeline starts. An invalid file is deleted and answered422; 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.
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.