PageToVid API: a URL in, an MP4 out
Send the URL of a web page and get back a narrated, captioned video of it. One POST starts the film, one GET tells you when it is ready. Use it for automations, bulk generation and video Open Graph previews.
Creating videos with POST /api/v1/videos needs the Pro plan or above; on a lower plan it answers 402 with code UPGRADE_REQUIRED. The MCP server works on every plan, Free included.
Using an AI assistant?Connect PageToVid to Claude, Cursor, VS Code or Codex and just ask for a video.Working in VS Code?PageToVid Studio sets up the MCP server for Claude Code in one click, with slash commands for videos, shows and formats.Your first call in three steps
1. Get an API key
Create an API key in your account, then send it as a Bearer token. Keep it secret — it spends your credits.
shellexport PAGETOVID_API_KEY="cp_live_xxxxxxxxxxxxxxxx"2. Start a film
Only the URL is required; every other field has a default. The answer comes back at once with an id and a statusUrl. A finished film costs 40 credits.
curlcurl -X POST https://pagetovid.com/api/v1/videos \ -H "Authorization: Bearer $PAGETOVID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-site.com" }'3. Fetch the result
Call the statusUrl every few seconds, or set a callbackUrl and wait for our POST. When status is "done", videoUrl is your MP4.
curlcurl https://pagetovid.com/api/v1/videos/VIDEO_ID \ -H "Authorization: Bearer $PAGETOVID_API_KEY"
Base URL: https://pagetovid.com · a rendered video costs 40 credits.
Create a video
POST /api/v1/videos — returns immediately with an id; rendering runs async.
curlcurl -X POST https://pagetovid.com/api/v1/videos \ -H "Authorization: Bearer $PAGETOVID_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://stripe.com", "goal": "ad", "aspect": "16:9", "language": "en", "targetSeconds": 35, "subtitles": true, "outputs": ["16:9", "9:16"], "qualityGate": { "minScore": 70, "onFail": "hold" }, "pronunciations": [{ "text": "SaaS", "say": "sass" }], "callbackUrl": "https://your-server.com/hooks/pagetovid" }'
goal— explainer · ad · demo · tutorial · articleaspect— 16:9 · 9:16 · 1:1 · 4:5targetSeconds— the length in seconds, 15–180 (default 45); the narration is written to fit itoutputs— optional: 1 to 4 shapes from one storyboard, each with its own idqualityGate— optional: hold a film that scores under minScore instead of delivering it (onFail: auto_fix, hold or deliver)pronunciations— optional: how the voice says a word; the captions keep the spellingcallbackUrl— optional: we send a signed POST here when the render ends (done, held, error or cancelled) — see Verify the webhook below
Response (202):
json{ "id": "cmq…", "status": "analyzing", "statusUrl": "https://pagetovid.com/api/v1/videos/cmq…", "webhookSecret": "pvwh_…", // only on the first call that sets a callbackUrl: keep it "outputs": [ // only when several shapes were asked for { "aspect": "16:9", "id": "cmq…", "statusUrl": "…" }, { "aspect": "9:16", "id": "cmr…", "statusUrl": "…" } ] }
Verify the webhook
Every callback carries X-PageToVid-Timestamp, X-PageToVid-Event-Id and X-PageToVid-Signature: sha256= followed by the hex HMAC-SHA256 of the timestamp, a dot and the raw body keyed with your webhook secret (returned once as webhookSecret, replaced by rotate_webhook_secret). Recompute it over the raw body, compare in constant time and refuse a timestamp more than 300 seconds old. The event id is stable across retries, so use it to ignore a repeat.
javascriptimport { createHmac, timingSafeEqual } from "node:crypto"; // raw: the request body exactly as received, BEFORE any JSON parsing. function verifyPageToVid(raw, headers, secret) { const ts = headers["x-pagetovid-timestamp"]; const sig = headers["x-pagetovid-signature"] ?? ""; if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const want = "sha256=" + createHmac("sha256", secret).update(ts + "." + raw).digest("hex"); return sig.length === want.length && timingSafeEqual(Buffer.from(sig), Buffer.from(want)); }
Get a video / poll status
GET /api/v1/videos/:id — poll until status: "done".
json{ "id": "cmq…", "status": "done", // queued | analyzing | scripting | recording | voicing | rendering | done | held | error "progress": 100, "videoUrl": "https://pagetovid.com/media/cmq…/video.mp4", "posterUrl": "https://pagetovid.com/media/cmq…/scene-2.png", "subtitleUrl": "https://pagetovid.com/media/cmq…/subtitles.vtt", "durationMs": 41000, "width": 1920, "height": 1080, "score": 82, // the reviewer's score, or null "qualityGate": { "minScore": 70, "onFail": "hold", "result": "passed", "held": false }, "og": { "og:video": "…", "og:image": "…", "og:video:type": "video/mp4", … } }
Sites, plan and shows
The site plan and the shows an assistant manages through the MCP also have REST doors, with the same bearer key. A site is identified by its domain.
- GET/POST
/api/v1/sites/:domain/plan— the plan for a whole site — the videos it should have, each with a goal, a brief and its shots. POST generates or regenerates it (free). - GET/POST
/api/v1/sites/:domain/plan/videos— the set of videos the plan asks for, with the video each idea already became. POST previews what it would make; POST with confirm: true starts them, 40 credits each. - GET/POST
/api/v1/sites/:domain/shows— the site's shows — recurring videos from an RSS, Atom or JSON feed: the same storyboard every episode, new data every episode. POST creates one; it starts held. - POST
/api/v1/shows/:id/run— runs a show by hand. Without confirm it previews the episodes for free; confirm: true makes them (40 credits each) and, on a held show, confirms it — the hourly scheduler runs it from then on, within its per-run and per-day caps. PATCH /api/v1/shows/:id pauses, resumes or retunes it.
POST /api/v1/sites/example.com/shows
json{ "name": "Release notes", "kind": "video", "feed_kind": "rss", "feed_url": "https://example.com/changelog.xml", "cadence": "weekly", "max_episodes_per_run": 1, "max_credits_per_day": 80, "aspect_ratio": "9:16", "language": "en", "goal": "explainer", "target_seconds": 30, "focus_template": "This episode is about {{title}}: {{summary}}. Lead with what is new." }
201 { "ok": true, "show": { "show_id", "confirmed": false, … } }, then POST /api/v1/shows/:id/run { "confirm": true }. max_credits_per_day caps what the show may spend in a day: here 2 films.
A show never spends before its owner has seen a run once, never more than max_credits_per_day in a day, and never makes the same feed item twice.
More endpoints
The same bearer key opens these. The request fields are the ones each route reads; the answers are shown as they come back.
- POST
/api/v1/videos/:id/translate— body { languages: 1 to 6 language codes, idempotencyKey? } (or the Idempotency-Key header). One new video per language, 40 credits each: the storyboard and pictures are kept and the words translated. 202 → { sourceVideoId, videos: [{ language, ok, video_id, status, credits_charged, … }], creditsCharged }. Needs the same plan as POST /api/v1/videos. - PATCH
/api/v1/shows/:id— body { active?, cadence?, max_episodes_per_run?, max_credits_per_day?, name? }: pauses, resumes or retunes a show → { ok: true }. - GETPOST
/api/v1/sites— GET lists your sites: [{ id, domain, name, createdAt }]. POST { url, name? } registers the site a URL belongs to → 201 { ok, site }. - GET
/api/v1/sites/:domain— one site → { known, domain, name, createdAt, videos, assets, public_videos, shows, formats, session, site_url, channel_url, feed_url, credits }. A domain you have not filmed answers { known: false, domain }. - GETPUT
/api/v1/sites/:domain/brand-kit— the site's brand kit → { ok, domain, exists, kit, logo_url, font_url, film_theme, painted, not_painted, watermark_effective, watermark_from }. PUT is a partial update: a key sent replaces, null clears, an absent key is kept. An unknown key is refused with the accepted list; a setting your plan does not include answers 402. - GETPOSTDELETE
/api/v1/sites/:domain/session— the site's capture session, which lets the recorder film behind a login. POST { storage_state } (a Playwright storage state) or { login_url, email, password } of a test account, with optional origin, label and ttl_hours → 201 { ok, domain, how, session, origins }; the password is never stored. GET → { ok, domain, sessions, capture_allowed, capture_plan }, never the cookies. DELETE forgets every session of the site → { ok, removed }. Below the Scale plan, POST answers 402. - POST
/api/v1/sites/:domain/record-requests— ask the Chrome extension to record the site's flows. POST { flowIds? } (none = every flow not yet recorded) → 201 { ok, request }; a newer ask replaces a pending one. The browser shows it and a person presses Record; an ask nobody claims expires after 30 minutes. - GETPATCH
/api/v1/record-requests— the caller's open record asks, each with its flows, for the browser: GET → { ok, requests, pollMs }. GET /api/v1/record-requests/:id follows one (pending, claimed, done, declined or expired, with the result per flow); PATCH { status: claimed | done | declined, result? } moves it, 409 if it already moved. - GETPOST
/api/v1/characters— identities kept consistent across generated clips. GET → { ok, characters }. POST { name, refs? (image URLs on this account), generate? (draws a reference for 40 credits), description?, aspect?, site_url? } → 201 { ok, character, credits_charged }. DELETE /api/v1/characters/:name → { ok, name }. - GETPOST
/api/v1/connections— the upstream servers a format's resolvers may call, by alias. GET → { ok, connections }. POST { alias, kind: mcp | http, url, secret?, note? } → 201 { ok, connection }; a secret is accepted and never returned. DELETE /api/v1/connections/:alias → { ok, alias }. - GET
/api/v1/commons— the shared asset commons, no key needed: ?kind=image|video, tag, q, limit (up to 200) → { ok, assets: [{ id, url, kind, tags, prompt, attribution, licence, created_at }] }. Putting another account's generated asset in a film goes through Use asset in the app, which pays its owner. - GET
/api/v1/sites/:domain/formats— the site's formats (the versioned shape a show runs): format_id, name, kind, latest_version, shows, programme, updated_at. - POST
/api/v1/shows/:id/webhook— no API key: push items to a show whose feed_kind is webhook. The body is items: an array of 1–50 objects, each with a title and optionally key, link, summary, image and date. Sign the raw body with the show's own secret: X-PageToVid-Signature is sha256= followed by its hex HMAC-SHA256. An unconfirmed show answers held and spends nothing. - PUT
/api/v1/uploads/:token— no API key: send the file to the upload_url create_upload returned (curl -T), up to 30 MB; 413 above.
Use it as an Open Graph video (SEO / social)
A finished video doubles as a rich og:video preview — so links to your page unfurl as a playable video on social and search. The status response hands you the exact tags in og. Drop them in your page's <head>:
html<meta property="og:image" content="POSTER_URL" /> <meta property="og:video" content="VIDEO_URL" /> <meta property="og:video:secure_url" content="VIDEO_URL" /> <meta property="og:video:type" content="video/mp4" /> <meta property="og:video:width" content="1920" /> <meta property="og:video:height" content="1080" /> <meta name="twitter:card" content="player" />
Automate it
Generate + wait, in any language.
javascriptconst KEY = process.env.PAGETOVID_API_KEY; const create = await fetch("https://pagetovid.com/api/v1/videos", { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://your-site.com", goal: "ad" }), }).then((r) => r.json()); let v; do { await new Promise((r) => setTimeout(r, 5000)); v = await fetch(create.statusUrl, { headers: { Authorization: `Bearer ${KEY}` } }).then((r) => r.json()); } while (!["done", "held", "error"].includes(v.status)); console.log(v.videoUrl, v.posterUrl, v.og);
pythonimport os, time, requests KEY = os.environ["PAGETOVID_API_KEY"] H = {"Authorization": f"Bearer {KEY}"} r = requests.post("https://pagetovid.com/api/v1/videos", headers=H, json={"url": "https://your-site.com", "goal": "ad"}).json() while True: time.sleep(5) v = requests.get(r["statusUrl"], headers=H).json() if v["status"] in ("done", "held", "error"): break print(v["videoUrl"], v["posterUrl"], v["og"])
Errors & limits
401— invalid or missing API key.402— out of credits, or the API is not on your plan (code UPGRADE_REQUIRED). Top up or upgrade on pricing.400— invalid input (a url is required).404— not found: the video, site, show, character or connection is not on this account.409— a conflict: a render with this Idempotency-Key is still starting, the key was used for a different request, or the video is busy.422— understood but not done: a login that did not sign in, or a translation where no language could start.429— too many requests; when a Retry-After header is sent, it says how many seconds to wait.500/502— our side failed, or a service we called did. A credit spent on what failed is returned; retry in a moment.- Rendering is async — it takes a few minutes; poll the status URL.
- CORS is enabled on /api/v1/videos (create, poll, translate), so those can be called straight from the browser; the other doors are for servers.
Get your API key
The REST API comes with the Pro plan and up, from $49 a month. On every plan, Free included, your AI assistant makes films through the MCP — start there for free.