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:

  • 401 unauthorized — No token, an unknown token, or a revoked one.
  • 402 upgrade_required — The workspace is no longer on Studio. The token is intact; it works again the day the plan does.
  • 404 not_found — Nothing by that id in this workspace.
  • 409 encrypted_script — The content of an encrypted script. The server holds ciphertext it cannot open.
  • 422 unknown_format — A format other than fountain, fdx, pdf or docx.
  • 429 rate_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"])
end

Retries 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.