Skip to content

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.
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=d37d24d415bdab8263a87922d0f6f5ec55365cdc9f0155800b9a60cf6ef3cd43

Limits

  • 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 queue shows their place. API and assistant videos get paid priority.
  • At most 20 active keys and 5 webhook endpoints per workspace.