API & webhooks
Your workspace, from your own tools.
Read a workspace's projects, scripts, content, versions and audit log over a bearer token, and have every workspace event posted to a URL you run. Part of Studio.
Getting a token Link to Getting a token#
As a workspace owner or admin, open Workspace › Developers, name the token for what will use it, and choose Create token. The token is shown once — copy it then. The page keeps only its first characters, and Revoke ends it immediately.
Tokens read one workspace: every production in it, every script, whoever the person holding the token is. Creating and revoking a token are recorded in the audit log.
The API is part of Studio. A workspace that leaves the plan keeps its tokens and answers 402 until it returns.
Authentication Link to Authentication#
Every request carries the token in the Authorization header. Requests and answers are JSON except content, which is a file. The base URL is https://screencake.com/api/v1.
curl -H "Authorization: Bearer sck_…" https://screencake.com/api/v1/workspace
Ids in URLs and bodies are the same 26-character public ids the app's own links use. Answers are sent with Cache-Control: no-store.
Endpoints Link to Endpoints#
The API is read-only. Everything below is a GET.
GET /workspace Link to GET /workspace#
The workspace and its roster (workspace-level seats).
{
"id": "01J8ZQ3K4X0000000000000000",
"name": "Lantern Pictures",
"created_at": "2026-01-14T09:12:00Z",
"members": [
{ "id": "01J8…", "name": "Ada Wren", "email": "ada@lantern.example", "role": "owner" }
]
}GET /projects Link to GET /projects#
Every production in the workspace.
{
"projects": [
{ "id": "01J8…", "title": "Night Bus", "description": null, "created_at": "2026-02-03T18:40:11Z" }
]
}GET /projects/:id Link to GET /projects/:id#
One production, with its scripts (archived ones included — archived_at says so).
{
"id": "01J8…",
"title": "Night Bus",
"description": null,
"created_at": "2026-02-03T18:40:11Z",
"scripts": [ { …script row… } ]
}GET /projects/:id/scripts Link to GET /projects/:id/scripts#
The same script rows on their own.
{ "scripts": [ { …script row… } ] }GET /scripts/:id Link to GET /scripts/:id#
One script row. Carries an ETag (see below).
{
"id": "01J8…",
"title": "Night Bus",
"project_id": "01J8…",
"kind": "screenplay",
"template": false,
"page_format": "letter",
"revision": "white",
"encrypted": false,
"archived_at": null,
"content_version": 1757930118,
"created_at": "2026-02-03T18:41:02Z",
"updated_at": "2026-09-15T10:35:18Z",
"metadata": {
"logline": "A bus, at night.",
"format": "Feature",
"revision": "white",
"title_page": { "title": "NIGHT BUS", "author": "Ada Wren" },
"season_number": null,
"episode_number": null
}
}GET /scripts/:id/content Link to GET /scripts/:id/content#
The script itself, as a file: ?format=fountain (the default), fdx, pdf or docx — the same files the app exports. The PDF takes the app's own options (revised=1, marks=1, comments=1, title=0, stamp=Name).
INT. BUS - NIGHT The bus idles.
GET /scripts/:id/versions Link to GET /scripts/:id/versions#
Saved versions, newest first. automatic is true for the hourly captures.
{
"versions": [
{ "id": "01J9…", "label": "Draft one", "automatic": false, "wordCount": 18240, "createdAt": "2026-09-12T16:02:44Z" }
]
}GET /audit Link to GET /audit#
The workspace audit log, newest first, fifty rows a page. nextBefore is the cursor: pass it as ?before= for the next page (null at the end). ?group= narrows to one of the groups listed under Webhooks, e.g. ?group=security.
{
"events": [
{
"id": "01J9…",
"action": "organization.mfa_required",
"actor": "Ada Wren",
"details": {},
"createdAt": "2026-09-14T08:00:12Z"
}
],
"nextBefore": "01J9…"
}ETags Link to ETags#
GET /scripts/:id answers with an ETag that changes whenever the script's row or its content does. Send it back as If-None-Match and an unchanged script answers 304 Not Modified with no body — the cheap way to poll for changes without webhooks.
curl -H "Authorization: Bearer sck_…" -H 'If-None-Match: W/"…"' https://screencake.com/api/v1/scripts/01J8…
Rate limit Link to Rate limit#
600 requests a minute per token. Past that, requests answer 429 with { "error": "rate_limited" } until the minute is over. Each token has its own allowance, so one runaway integration never starves another.
Errors Link to Errors#
Every refusal is JSON with one error code:
401unauthorized— No token, an unknown token, or a revoked one.402upgrade_required— The workspace is no longer on Studio. The token is intact; it works again the day the plan does.404not_found— Nothing by that id in this workspace.409encrypted_script— The content of an encrypted script. The server holds ciphertext it cannot open.422unknown_format— A format other than fountain, fdx, pdf or docx.429rate_limited— More than 600 requests in a minute on one token.
An encrypted script still appears in listings with "encrypted": true; only its content is refused.
Webhooks Link to Webhooks#
The audit log is the event bus: every row a workspace records can be posted to an endpoint you add under Workspace › Developers. Give it an https URL that resolves to a public address and choose the events. The signing secret is shown once when the endpoint is created.
Events Link to Events#
Subscribe to any of these, grouped the way the audit page filters them:
- access.session.signed_in, script.opened, script.exported, membership.role_changed, membership.removed, membership.auto_joined, invite.sent, invite.revoked, invite.accepted
- sharing.share_link.created, share_link.download_changed, share_link.revoked, share_link.viewed, share_link.downloaded
- security.organization.mfa_required, organization.mfa_unrequired, organization.cloud_ai_forbidden, organization.cloud_ai_allowed, organization.link_passcode_required, organization.link_passcode_unrequired, organization.link_downloads_forbidden, organization.link_downloads_allowed, organization.link_max_age_set, organization.link_max_age_cleared, organization.encryption_required, organization.encryption_unrequired, organization.session_max_age_set, organization.session_max_age_cleared, script.encrypted, script.decrypted, sso.connected, sso.updated, sso.disconnected, sso.domain_verified, sso.enforced, sso.unenforced
- scripts.organization.renamed, organization.exported, script.created, script.renamed, script.archived, script.unarchived
- developers.api_token.created, api_token.revoked, webhook.created, webhook.updated, webhook.removed
Sign-ins, opens, exports and guest views are recorded only on Studio; the rest exist on every plan and post while the workspace is on Studio.
Delivery Link to Delivery#
Each event is one POST with a JSON body and these headers: X-Screencake-Event (the event name), X-Screencake-Delivery (the event's id — the same event is never sent twice, so use it to skip duplicates from your own retries) and X-Screencake-Signature.
{
"id": "01J9ABCD…",
"type": "script.renamed",
"created_at": "2026-09-15T10:35:18Z",
"organization": { "id": "01J8…", "name": "Lantern Pictures" },
"actor": { "id": "01J8…", "name": "Ada Wren" },
"subject": { "type": "script", "id": "01J8…", "title": "Night Bus" },
"details": { "script": "Night Bus", "from": "Untitled Script" }
}actor is null for something the system or a guest did; subject is null when the event has no single subject, and its id is null when that subject has since been deleted — details still names it.
Verifying the signature Link to Verifying the signature#
The header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 with your secret over the string <t>.<raw body>. Compute it over the bytes you received — not over re-serialised JSON — compare in constant time, and reject a timestamp older than you are willing to accept.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the request body as received — bytes, not re-serialised JSON.
export function verify(header, rawBody, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= tolerance
return fresh && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}require "openssl"
# raw_body: the request body as received — bytes, not re-serialised JSON.
def verify(header, raw_body, secret, tolerance: 300)
parts = header.split(",").to_h { |pair| pair.split("=", 2) }
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts["t"]}.#{raw_body}")
fresh = (Time.now.to_i - parts["t"].to_i).abs <= tolerance
fresh && OpenSSL.secure_compare(expected, parts["v1"])
endRetries and failures Link to Retries and failures#
Answer with any 2xx within ten seconds. A timeout, a 5xx or a 429 is retried up to 5 times with increasing delay; any other answer counts as a refusal and is not retried.
After 10 failed deliveries in a row the endpoint switches itself off. The Developers page shows the last error and the count, and its Active switch turns it back on with a fresh count. Events that happen while an endpoint is off are not sent later.