Docs
ClipYourSaaS API
Make demo videos from your release pipeline or your own tools. Every rule the dashboard enforces applies here too.
The API is for workspaces that have bought credits (a pack or a plan), and API videos are always paid from credits. The OpenAPI document describes every endpoint and field.
Quick start
# 1. Create a key in Settings → API, then:
export CYS_KEY="cys_live_…"
# 2. Find your app and a voice
curl -s https://clipyoursaas.com/api/v1/apps -H "Authorization: Bearer $CYS_KEY"
curl -s https://clipyoursaas.com/api/v1/voices -H "Authorization: Bearer $CYS_KEY"
# 3. Start a video (paid from credits). Videos up to 2 minutes usually take 10–15 minutes; longer ones up to 2 hours.
curl -s https://clipyoursaas.com/api/v1/videos \
-H "Authorization: Bearer $CYS_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: release-2026-10-04" \
-d '{"app_id":"APP_ID","goal":"Show how to create a project and invite a teammate","length_seconds":75,"voice_id":"hpp4J3VqNfWAUOO0d1Us"}'
# or, for a silent video that runs as long as it takes (up to 10 minutes):
# -d '{"app_id":"APP_ID","goal":"Show how to create a project and invite a teammate","length_seconds":"auto","narration":false}'
# 4. Check on it until status is ready, then download outputs.landscape.url
curl -s https://clipyoursaas.com/api/v1/videos/VIDEO_ID -H "Authorization: Bearer $CYS_KEY"Authentication
Owners and admins create keys in Settings → API. Send the key on every request as Authorization: Bearer cys_live_….
A key acts as the person who created it, and it stops working when they leave the workspace or stop being an owner or admin. We store only a hash of each key, so copy it when it is shown; it isn't shown again. A key can have a daily credit cap.
AI assistants don't use keys: they connect over MCP and sign in with OAuth.
Endpoints
Base URL https://clipyoursaas.com/api/v1. JSON in and out; timestamps are ISO 8601 in UTC. Every response carries an X-Request-Id header.
- GET /me
- The workspace, its credit balance, and this key with its daily cap and what it has spent today.
- GET /apps
- Your connected apps as { data }. Each has a status (ready, or needs_reconnect with the dashboard_url where you fix it), changes (view_only, limited or any: what a video may change), reset (whether a reset link runs before every run), reset_failing (whether that link failed during a video, which stops new videos until it passes a test), sign_in (whether the app uses a sign-in), start_url (the page a video starts on), learning (whether we are learning the app right now; new videos for it wait until it finishes) and learned_at (when we last learned it, or null). not_verified is no longer returned.
- GET /apps/{id}
- One app.
- POST /apps/{id}/relearn
- Forget what we learned about the app (what each section holds and when you’d use it, and an About the app we drafted; never notes you wrote) and learn it again from its pages. Returns 202 with { data: { app_id, learning: true } }. The app shows learning: true until it finishes, usually within a few minutes; new videos for it wait. Owners’ and admins’ keys only, like every key. Counts toward the new-video limit.
- GET /voices
- The 9 narration voices as { data }, each with a preview_url sample.
- POST /videos
- Start a video. Body: app_id or url (one of them; url is the page to record, http or https: we use your app on that site, or add one), sign_in (with url, optional: whether the site needs a sign-in), goal (10–1000 characters), audience (optional, up to 200), length_seconds (30–120, or "auto"), narration (optional, default true; false makes a silent video, 25% less), captions (optional, default true), voice_id (required with narration), style (optional: walkthrough, the default, or showcase), changes (optional: view_only, limited with allowed_changes, or any with confirm_any_changes set to this sentence, word for word, once you have agreed to it: “I'm happy for this account to be changed, including permanently. I'm responsible for those changes.”; narrower than the app's setting applies to this video only, wider changes the app's setting). length_seconds: The most the video may run, in whole seconds (30–120), or "auto" for as long as it takes (up to 10 minutes; paid from your balance). A video that covers the goal sooner is shorter and costs less. Unknown fields are refused. Returns 202 with the video, app ({ id, name, created: whether this request added the app }), estimated_credits, the most it can cost (a shorter video costs less), and warnings: advisory notes, such as a goal that looks like it changes data on a View only app (those steps may be left out), or what changed in the app's setting; a Limited or Any changes app with no reset link gets a warning that its steps run several times and the changes stay. warnings is empty when there are none, and never stops the video. An "auto" video's answer adds credits_note: what is held while it records and what it is likely to cost. When the site needs a sign-in we don't have, the video waits: its stage is waiting_for_sign_in and sign_in_url is the link to open and sign in (it works for 30 minutes). It starts once someone signs in and the app's connection test passes, and is cancelled free (cancel_reason sign_in_timeout) if no one does. In the rare case no link could be made, sign_in_needed says what to do and dashboard_url is the app's page. A link stops working if the person who asked leaves the workspace or the key that asked is revoked.
- GET /videos
- Videos, newest first. Query: status, app_id, limit (1–100, default 20), cursor. Returns { data, next_cursor }; pass next_cursor back as cursor for the next page.
- GET /videos/{id}
- One video. While it waits for a sign-in: stage waiting_for_sign_in and sign_in_url, or sign_in_needed and dashboard_url.
- POST /videos/{id}/cancel
- Cancel a queued or running video. It stops at once, and video.cancelled fires. Cancelling is free until we’ve rehearsed the demo; after that it costs 0.25 credits. The fee never applies to free videos. A ready video isn’t cancelled: its square and vertical keep rendering. Returns the cancelled video plus charged_credits: what this cancel was charged (0 or 0.25), the final charge. A repeat cancel is never charged again. Cancelling a video that has already stopped answers with charged_credits set to what its cancel was actually charged (0 if it finished another way).
- POST /videos/{id}/share
- Create or update the video’s unlisted share link. A new share link needs a ready video. Optional body: { "allow_downloads": true | false }; a new link without it has downloads off. Returns { url, allow_downloads }.
- DELETE /videos/{id}/share
- Revoke the share link. Returns 204.
- GET /openapi.json
- The OpenAPI 3.1 document. No key needed.
The video object
max_length_seconds is the most the video may run, in seconds: the length asked for, or for "auto" the length its quote paid for (up to 600). length_mode is fixed or auto. narration is false for a silent video, and captions is false when captions (or, without narration, step labels) are off. Fields appear only when they apply: queue while queued, stage while running (it may be null at first, before the first stage starts), error when failed (or cancelled because the app's connection test failed), duration_seconds and outputs when ready, credits_charged when ready or cancelled (a cancelled video reports the cancel fee, if any), and share when a share link exists. Square and vertical follow a few minutes after ready: until they exist, outputs.square and outputs.vertical are null, so fetch the video again for their links. A format that failed to render is left out. eta_minutes can be null. Output links expire after an hour; fetch the video again for fresh ones. watch_url is the video's page on ClipYourSaaS once it is ready. stage is waiting_for_sign_in while a video waits for a sign-in, with sign_in_url (or, if no link could be made, sign_in_needed and dashboard_url), and waiting_for_app_test while it waits for its new app's connection test; a waiting video has no queue. cancel_reason (sign_in_timeout, sign_in_superseded, app_test_failed or app_test_timeout) says why a cancelled video stopped, free; with app_test_failed, error gives the test's message and dashboard_url the app's page, where you can fix it.
{
"id": "…", "app_id": "…", "title": "…",
"status": "queued | running | ready | failed | cancelled",
"created_at": "2026-10-04T12:00:00.000Z", "created_via": "dashboard | api | mcp",
"max_length_seconds": 75, "length_mode": "fixed | auto",
"narration": true, "captions": true,
"queue": { "ahead": 3, "eta_minutes": 25 },
"stage": "rehearse | script | narrate | record | render",
"error": { "code": "…", "message": "…" },
"duration_seconds": 74, "credits_charged": 1.3,
"outputs": {
"landscape": { "url": "…", "expires_at": "…" },
"square": { "url": "…", "expires_at": "…" },
"vertical": { "url": "…", "expires_at": "…" },
"captions_vtt": { "url": "…", "expires_at": "…" },
"poster": { "url": "…", "expires_at": "…" }
},
"share": { "url": "…", "allow_downloads": false }
}Failure codes
A failed video's error.code is one of these. New codes may be added.
- reset_failed
- The app’s reset link didn’t answer with a 2xx within 2 minutes, answered another status, or isn’t a public https address. The message says which. Nothing is charged. If it happens after the video’s work started, the link is also paused (see Apps and change modes).
- session_expired
- The app’s saved sign-in expired. Reconnect it at the app’s dashboard page.
- session_corrupt
- The app’s saved sign-in can’t be read. Reconnect it at the app’s dashboard page.
- session_missing
- The app has no saved sign-in. Connect it at the app’s dashboard page.
- workflow_blocked
- Our browser couldn’t complete the workflow in the app.
- view_only_blocked
- The video needed to change data in an app set to view only, so our browser stopped it. Nothing is charged. Switch the app to limited or any changes and try again.
- content_flagged
- The narration didn’t pass our content check, even after one rewrite. Nothing is charged.
- validation_failed
- No path through the app survived our replay checks.
- quality_failed
- The recording failed our quality checks.
- blocked_host
- The app’s address points to a private or internal network.
- suspended
- The workspace is suspended, so the video didn’t run. Contact support.
- infrastructure
- Something went wrong on our side. Nothing is charged.
- stalled
- The video stopped making progress on our side. Try again.
- stuck_queued
- The video couldn’t start. Try again.
- pipeline_error
- Any other failure while making the video. The message says what happened.
- reset_link_failing
- The video was queued, then the app’s reset link failed during another video, or reset links in the workspace failed during videos 3 times in 24 hours, so it didn’t start. Nothing is charged. The message says which; test the link on the app’s page.
Apps and change modes
Every app has a change mode, which owners and admins set on the app's page in the dashboard, or with changes on POST /videos. GET /apps returns it as changes: view_only (the default: a video only reads and navigates), limited (only the changes the workspace described) or any (create, edit, delete, send, pay and change settings, for an account kept for demos). In every mode the agent never signs out, changes the sign-in, deletes the account or leaves the app's site.
Our browser enforces the mode. In view_only, requests to the app's site that change data (anything but GET, HEAD and OPTIONS) are blocked, except clear reads: GraphQL queries, POSTs to search and analytics paths such as /search or /collect, and POSTs that refresh the sign-in, to a path ending in /refresh or /token. On a GraphQL endpoint, a path ending in /graphql or /query, a POST reads only when its JSON body is a GraphQL query. A GraphQL mutation is a change on any path. In limited, DELETE requests are blocked. In any, nothing is blocked.
The video's log says Blocked a change (POST) when a change is blocked, naming the method only, once per method in each browser session. A method other than GET, HEAD, OPTIONS, POST, PUT, PATCH and DELETE is shown as OTHER. For our support team we keep each change request's method and path, never its query or body: the first 500 each time a video is made, with each path cut to 512 characters.
The checks have limits. The modes only cover requests to the app's own site (its domain and its subdomains). Changes the app sends to a backend on another domain, such as Supabase, Firebase or an API on a domain of its own, aren't blocked or logged in any mode, so view_only doesn't protect that data. An app that deletes through a plain POST isn't caught in limited mode, but a POST marked as a delete, with a _method=delete form field or an X-HTTP-Method-Override: DELETE header, is blocked and shown as DELETE. An app that reads through other POSTs may not be filmable in view_only: if the rehearsal can't finish without them, the video then fails with view_only_blocked, free, and says to switch modes. Changes an app sends over an open WebSocket connection aren't checked in any mode.
A limited or any app can have a reset link (reset: true). Before every run in the app (each rehearsal, each check, the recording and a retake) we POST to it and wait up to 2 minutes for any 2xx. The response body is ignored and redirects are not followed. run is rehearsal, validation, recording or retake. Without a reset link the account is left as it is, so a video's changes happen several times and stay.
POST https://your-app.example/clipyoursaas/reset
Content-Type: application/json
User-Agent: ClipYourSaaS-Reset/1
ClipYourSaaS-Signature: t=<unix seconds>,v1=<hex>
{"type":"app.reset","app_id":"…","video_id":"…","run":"rehearsal","attempt":1}The request has User-Agent: ClipYourSaaS-Reset/1 and a ClipYourSaaS-Signature header made exactly like a webhook's, keyed with the app's reset signing secret (rsec_…, shown once when you add the link). Check it with the same code as for webhooks (see Webhooks), restore the account, then answer with a 2xx. If the link answers anything else, takes longer, or isn't a public https address, the video fails with reset_failed and nothing is charged.
// verifyClipYourSaaS is the function under "Verify a signature in Node", unchanged.
app.post('/clipyoursaas/reset', express.text({ type: 'application/json' }), async (req, res) => {
let event;
try { event = JSON.parse(req.body); } catch { return res.sendStatus(400); }
// The test call resets nothing: answer it at once. A new link is tested before you see its secret.
if (event.run === 'test') return res.sendStatus(204);
if (!verifyClipYourSaaS(req.get('ClipYourSaaS-Signature'), process.env.CLIPYOURSAAS_RESET_SECRET, req.body)) return res.sendStatus(401);
await restoreDemoAccount(); // put the account back to its starting state
res.sendStatus(204);
});When you save a new or changed reset link, and when you press Test reset link on the app's page, we send a test call: the same signed POST with "run": "test", "video_id": null and "attempt": 0. We wait up to 15 seconds for a 2xx, and a link that fails the test isn't saved. Answer it at once without resetting anything. A new link is tested before its signing secret is shown, so don't refuse a test call for its signature; it changes nothing.
{"type":"app.reset","app_id":"…","video_id":null,"run":"test","attempt":0}If a reset fails after a video's work has started, the video still fails with reset_failed and nothing is charged, and the link is paused. Every reset counts except the video's first rehearsal reset, and when we retry a video after a problem on our side, that one counts too. While the link is paused, GET /apps shows reset_failing: true, new videos for the app are refused with reset_link_failing, and videos already queued fail with reset_link_failing when they start, at no charge, until a test passes. Choosing View only or removing the link also lifts the pause. After 3 such failures in a workspace within 24 hours, new videos for every app in it that has a reset link are refused until fewer than 3 such failures fall within the last 24 hours.
Errors
Errors look like { "error": { "code", "message", "docs_url", "fields"? } }, and docs_url links to the code below. App errors add dashboard_url, and 429s send Retry-After. New codes may be added, so fall back on the HTTP status for a code you don't know.
- 400 invalid_request
- A field, parameter or header is invalid, or the body is not valid JSON. fields names each problem.
- 401 invalid_api_key
- The key is missing, unknown or revoked, or the person who created it is no longer an owner or admin of the workspace.
- 401 invalid_token
- MCP only: the connection was revoked or no longer works, or the assistant’s access token is missing or expired. Connect it again.
- 402 insufficient_credits
- The balance is below the video’s estimate. The message gives the amount needed, the balance and the billing link.
- 403 workspace_not_paid
- The workspace has not bought credits (a pack or a plan) yet. The API and assistant connections are for workspaces that have.
- 403 forbidden
- You may not do this here, for example share someone else’s video through a member’s assistant connection.
- 403 suspended
- The workspace is suspended and can’t make videos. Contact support.
- 404 not_found
- No such id in this workspace, or no such endpoint.
- 409 idempotency_conflict
- The Idempotency-Key was already used with a different body. Use a new key for a new video.
- 422 app_needs_reconnect
- The app’s saved sign-in has expired or is missing. Reconnect it at dashboard_url.
- 422 app_not_verified
- No longer returned. The code stays listed so older clients keep working.
- 422 app_busy
- The app signs in fresh for every video and already has one in progress. Try again when it finishes.
- 422 reset_link_failing
- The app’s reset link failed during a video: test it on the app’s page (dashboard_url) to start new videos again. Or reset links in the workspace failed during videos 3 times in the last 24 hours: try again later, or contact support.
- 429 rate_limited
- Too many requests. Wait for the number of seconds in Retry-After.
- 429 spend_cap_reached
- This key’s or connection’s daily credit cap would be exceeded. Retry-After counts down to 00:00 UTC.
- 500 internal_error
- Something went wrong on our side. Quote the X-Request-Id header if you contact support.
- 422 confirmation_required
- changes "any" was sent without confirm_any_changes set to the exact sentence the user agreed to. Nothing was created or changed. Show the user the sentence in the message, and only if they agree, send the request again with it.
Idempotency
Send an Idempotency-Key header (1–255 characters) with POST /videos so a retried request can't start a second video. For 24 hours, the same key with the same body returns the original video, in its current state, with Idempotent-Replayed: true. The same key with a different body returns 409 idempotency_conflict. Keys are separate for each API key.
Webhooks
Owners and admins can add up to 5 endpoints in Settings → API. Each one subscribes to some or all of video.ready, video.failed, video.cancelled and app.needs_reconnect (all of them by default), for every video and app in the workspace, however the video was started. Endpoint URLs must use https.
The request
We POST a JSON body { "id", "type", "created_at", "data": { "video" } }, with data.app instead for app.needs_reconnect. The video is the object above, rendered when we send it, so its links are fresh. Send test event, on the endpoint's page, posts a ping event whose data is empty.
There is no separate event for square and vertical. They follow a few minutes after video.ready; until then the video's outputs.square and outputs.vertical are null, so fetch the video (GET /videos/{id}) for their links.
Each request has User-Agent: ClipYourSaaS-Webhooks/1 and ClipYourSaaS-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> keyed with your endpoint's signing secret. Check it against the raw body before you parse it, and refuse timestamps more than 5 minutes old.
Retries and turning off
Answer with any 2xx within 10 seconds. We don't follow redirects, so a 3xx counts as a failure. A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then marked failed. Every retry of a delivery carries the same event id, so you can use it to ignore duplicates. An event more than 48 hours old is marked failed and never sent, and so is an event for an endpoint that is turned off.
An endpoint with no success for 72 hours is turned off, and the workspace's owners and admins get an email. Turning it back on starts a fresh 72-hour window. Deliveries are logged for 30 days on the endpoint's page. Resend on a failed delivery sends the event again as a new delivery, with a new event id. Rotate secret signs every later delivery with a new secret.
Verify a signature in Node
import { createHmac, timingSafeEqual } from 'node:crypto';
// header: the ClipYourSaaS-Signature header. rawBody: the body exactly as it arrived, as a string
// (read it as text before parsing it, for example with express.text({ type: 'application/json' })).
export function verifyClipYourSaaS(header, secret, rawBody, toleranceSec = 300, nowSec = Date.now() / 1000) {
const parts = Object.fromEntries(String(header ?? '').split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(nowSec - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const got = String(parts.v1 ?? '');
// Only 64 lowercase hex characters can match; anything else is refused before comparing (never thrown).
if (!/^[0-9a-f]{64}$/.test(got)) return false;
return timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}Verify a signature in Python
import hashlib, hmac, re, time
from typing import Optional
# raw_body: the request body exactly as it arrived, as bytes (for example request.get_data() in Flask).
def verify_clipyoursaas(header: str, secret: str, raw_body: bytes, tolerance: int = 300, now: Optional[float] = None) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs((time.time() if now is None else now) - t) > tolerance:
return False
got = parts.get("v1", "")
# Only 64 lowercase hex characters can match; anything else is refused before comparing (never raised).
if not re.fullmatch(r"[0-9a-f]{64}", got):
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, got)Check your code against this example
With this secret, timestamp and body, your code should compute this header (pass the timestamp as the current time, or the 5-minute check refuses it).
secret: whsec_ExampleOnly000000000000000000000
timestamp: 1791115200
body: {"id":"evt_7f3c2a9e4b1d4e8a9c6f2d5e8b1a0c37","type":"ping","created_at":"2026-10-04T12:00:00.000Z","data":{}}
ClipYourSaaS-Signature: t=1791115200,v1=d37d24d415bdab8263a87922d0f6f5ec55365cdc9f0155800b9a60cf6ef3cd43Limits
- 120 requests a minute per key or assistant connection, and 600 a minute per workspace. Over a limit you get
429 rate_limited. - 10 new videos and relearns a minute per key or connection.
- An optional daily credit cap per key or connection, counted from 00:00 UTC.
- Request bodies up to 16 KB.
- Each workspace runs a few videos at once; the rest wait in the queue, and
queueshows their place. API and assistant videos get paid priority. - At most 20 active keys and 5 webhook endpoints per workspace.