Developers

Preshow API

Connect your own tools to Preshow: read projects, upload videos into them, build comps on their stages, and get a webhook the moment something happens. It is a REST API over HTTPS with JSON bodies, authenticated with API keys you create in the app.

Base URL https://preshow.link/api/v1 · OpenAPI 3.1 description

Overview

Every request is made by an API key, and a key acts as the person who created it: it can see and change only what they can, only in the one workspace it was created for, and only with the permissions ticked when it was made. If that person loses access to a project or leaves the workspace, their keys lose it at the same moment.

Responses are JSON with snake_case fields and ISO 8601 UTC timestamps. Every response carries a Request-Id header; quote it if you contact us. The API is for servers and scripts: it sends no CORS headers, so a browser page cannot call it with a key, which is deliberate, since a key in a web page is a leaked key.

Authentication

  1. In Preshow, open Settings → Developers and choose Create API key.
  2. Name it after what will use it, pick the workspace, tick its permissions and an expiry (90 days by default).
  3. Confirm it is you, then copy the key. It is shown once: Preshow stores only a fingerprint of it.

Send it as a bearer token on every request:

curl https://preshow.link/api/v1/me \
  -H "Authorization: Bearer $PRESHOW_API_KEY"

Keys look like psk_ followed by 49 letters and digits, the last six a checksum, so a mistyped key is refused at once and secret scanners can recognise a leaked one. Keep keys in an environment variable or a secrets manager, never in source code. Revoke a key in the same settings page; it stops working immediately.

Permissions

A key has only the permissions chosen when it was created. To change them, create a new key.

PermissionAllows
projects:readList projects and read a project.
media:writeUpload videos into a project, and new versions of them.
comps:writeCreate comps from a project’s media.
webhooks:manageAdd and remove webhook endpoints, e.g. for an automation platform to subscribe itself.

OAuth: apps other people connect

An API key is for your own scripts. If you are building a product that other Preshow users connect to their accounts, use OAuth 2.1 instead: they sign in on Preshow, see what your app is asking for, and press Allow; you never see their password or ask them to paste a key, and they can disconnect you at any time in Settings → Connections. Register your app in Settings → Developers → OAuth apps. Everything is discoverable from https://preshow.link/.well-known/oauth-authorization-server (RFC 8414).

  1. Make a PKCE verifier and challenge (required of every app, with a secret or not) and a random state:
    import crypto from 'node:crypto';
    
    const codeVerifier = crypto.randomBytes(32).toString('base64url');   // keep it for the token request
    const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
    const state = crypto.randomBytes(16).toString('base64url');          // check it on the callback
  2. Send the person to the authorization endpoint, asking only for the scopes you need (space separated):
    https://preshow.link/app/oauth/authorize
      ?response_type=code
      &client_id=psc_8Zk2Qm4vT7bN1xR6yW3cL9dF
      &redirect_uri=https%3A%2F%2Fexample.com%2Foauth%2Fcallback
      &scope=projects%3Aread%20media%3Awrite
      &state=af0ifjsldkj
      &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
      &code_challenge_method=S256
  3. They come back to your redirect URI with a code. Check that state is yours and iss is https://preshow.link:
    https://example.com/oauth/callback?code=pac_…&state=af0ifjsldkj&iss=https%3A%2F%2Fpreshow.link
  4. Within five minutes, trade the code for tokens. A server app authenticates with its client secret (HTTP Basic, or client_secret in the body); a desktop, mobile or browser app has no secret and sends only client_id:
    curl -X POST https://preshow.link/api/oauth/token \
      -u "$CLIENT_ID:$CLIENT_SECRET" \
      -d grant_type=authorization_code \
      -d code="$CODE" \
      -d redirect_uri=https://example.com/oauth/callback \
      -d code_verifier="$CODE_VERIFIER"
    {
      "access_token": "psa_…",
      "token_type": "Bearer",
      "expires_in": 3600,
      "refresh_token": "psr_…",
      "scope": "media:write projects:read"
    }
  5. Call the API with Authorization: Bearer psa_…, exactly as with a key. The access token lasts 60 minutes; on access_token_expired, refresh it. Every refresh returns a new refresh token: store it and discard the old one, because presenting a used refresh token disconnects the app (it means a copy leaked). The one exception: for 30 seconds after a refresh, the token it replaced may be sent again, so a retry after a lost response still works, and if two refreshes race, whichever new token you keep works (the other stops working once you use it). A refresh token unused for 90 days lapses. The token endpoint allows 600 requests a minute per app.
    curl -X POST https://preshow.link/api/oauth/token \
      -u "$CLIENT_ID:$CLIENT_SECRET" \
      -d grant_type=refresh_token \
      -d refresh_token="$REFRESH_TOKEN"
  6. To sign out, revoke either token; the whole connection ends (RFC 7009):
    curl -X POST https://preshow.link/api/oauth/revoke \
      -u "$CLIENT_ID:$CLIENT_SECRET" \
      -d token="$REFRESH_TOKEN"

Redirect URIs are matched exactly as registered: https, a reverse-domain scheme for a mobile app (com.example.app:/callback), or http://127.0.0.1 for a desktop app, which may use any port (RFC 8252). The token and revocation endpoints answer errors as RFC 6749 JSON ({ "error", "error_description" }), not problem details:

invalid_requestA parameter is missing or wrong; PKCE with S256 is required.
invalid_clientUnknown client, wrong secret, or a public client that sent a secret.
invalid_grantThe code or refresh token is invalid, expired, already used, or the user disconnected the app. A refresh token used twice revokes the connection.
invalid_scopeA requested scope does not exist.
unsupported_grant_typeOnly authorization_code and refresh_token.
rate_limitedOver 600 token requests a minute for this app, or from one address (HTTP 429). Requests that fail to authenticate count against their address, never against your app. Wait the Retry-After seconds.
access_deniedThe user pressed Cancel (returned to your redirect URI).

Endpoints

GET/v1/me

The key making the request: its name, permissions, workspace and expiry. Handy as a first call to check a key works. Any valid key.

curl https://preshow.link/api/v1/me \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{
  "object": "api_key",
  "id": "key_7Qm2x9K4pT0aZ1bC",
  "name": "Render farm uploader",
  "scopes": [
    "projects:read"
  ],
  "workspace": {
    "kind": "team",
    "id": "team_abc123",
    "name": "Northlight Studio"
  },
  "created_at": "2026-09-23T15:04:05.000Z",
  "expires_at": "2026-12-22T15:04:05.000Z"
}

GET/v1/projects

The projects in the key’s workspace that its owner can open, oldest id first. Requires projects:read.

  • limit (query) Page size, 1 to 100. Default 25.
  • cursor (query) The next_cursor from the previous page. Omit for the first page.
curl "https://preshow.link/api/v1/projects?limit=10" \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{
  "object": "list",
  "data": [
    {
      "object": "project",
      "id": "proj-1790130227580-2c6a1f827",
      "name": "Arena tour 2027",
      "workspace": {
        "kind": "team",
        "id": "team_abc123"
      },
      "role": "editor",
      "visibility": "team",
      "created_at": "2026-09-01T09:30:00.000Z",
      "updated_at": "2026-09-22T18:12:44.000Z",
      "app_url": "https://preshow.link/app?project=proj-1790130227580-2c6a1f827"
    }
  ],
  "has_more": true,
  "next_cursor": "proj-1790130227580-2c6a1f827"
}

GET/v1/projects/{projectId}

One project. Answers 404 alike for a project that does not exist, is in another workspace, or the owner cannot open, so a key cannot probe for ids. Requires projects:read.

  • projectId (path) The project’s id, as returned by the list.
curl https://preshow.link/api/v1/projects/proj-1790130227580-2c6a1f827 \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "project", "id": "proj-1790130227580-2c6a1f827", … }

GET/v1/projects/{projectId}/stages

A project’s stages, each with the screens a comp can use. A screen is known once it is the stage’s default screen or a comp in Preshow already uses it; a stage with none yet needs one of those first. Requires projects:read.

  • limit (query) Page size, 1 to 100. Default 25.
  • cursor (query) The next_cursor from the previous page. Omit for the first page.
curl https://preshow.link/api/v1/projects/$PROJECT_ID/stages \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{
  "object": "list",
  "data": [
    {
      "object": "stage",
      "id": "scene-1790130230112-8d1e0b4c2",
      "name": "Arena main stage",
      "default_screen": "Main_LED_Wall",
      "known_screens": [
        "Main_LED_Wall",
        "Side_LED_Left",
        "Side_LED_Right"
      ],
      "thumbnail_url": "https://preshow.link/media/…/thumbnail.jpg"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

GET/v1/projects/{projectId}/media

A project’s media library: videos, stills and audio, with the processing status of each. Requires projects:read.

  • limit (query) Page size, 1 to 100. Default 25.
  • cursor (query) The next_cursor from the previous page. Omit for the first page.
curl https://preshow.link/api/v1/projects/$PROJECT_ID/media \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "list", "data": [{ "object": "media", … }], "has_more": false, "next_cursor": null }

POST/v1/projects/{projectId}/media

Adds a video to the project and returns a one-time upload URL. Then PUT the file’s bytes to upload.url (it stays valid for an hour) and poll the media item until its status is ready. The video appears in Preshow at once, marked as processing, and in open tabs without a reload. Send an Idempotency-Key so a retry cannot add it twice. Requires media:write.

  • name (body) Required. Shown in the media library.
  • filename (body) Optional. Defaults to name.
  • duration_seconds (body) Optional but recommended: checked against your plan’s video minutes before anything is uploaded.
  • width, height (body) Optional pixel size. 4K and wide LED content keeps its full resolution.
  • size_bytes (body) Optional. The file’s size.
curl -X POST https://preshow.link/api/v1/projects/$PROJECT_ID/media \
  -H "Authorization: Bearer $PRESHOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name": "Opener v3", "filename": "opener_v3.mov", "duration_seconds": 42.5, "width": 3840, "height": 2160}'

# then send the file itself to the upload url from the response
curl -X PUT --upload-file opener_v3.mov "$UPLOAD_URL"
{
  "object": "media",
  "id": "asset-1790131002211-4be09a7d1",
  "project_id": "proj-1790130227580-2c6a1f827",
  "name": "Opener v3",
  "type": "video",
  "status": "processing",
  "duration_seconds": 42.5,
  "width": null,
  "height": null,
  "thumbnail_url": null,
  "version": {
    "id": "ver-1790131002211-0c5f2e9ab",
    "created_at": "2026-09-23T21:03:22.211Z"
  },
  "versions_count": 1,
  "created_at": "2026-09-23T21:03:22.211Z",
  "upload": {
    "url": "https://storage.googleapis.com/video-storage-…",
    "method": "PUT",
    "expires_at": "2026-09-23T22:03:22.211Z"
  }
}

GET/v1/projects/{projectId}/media/{mediaId}

One media item. Poll this after uploading: status goes from processing to ready (usually well under a minute per minute of video), or to error if the file could not be read. Requires projects:read.

curl https://preshow.link/api/v1/projects/$PROJECT_ID/media/$MEDIA_ID \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "media", "id": "asset-1790131002211-4be09a7d1", "status": "ready", "duration_seconds": 42.5, "width": 3840, "height": 2160, … }

POST/v1/projects/{projectId}/media/{mediaId}/versions

Adds a new version to a video, for a render that replaces the last one, and returns the URL to upload it to. PUT the file’s bytes there. The version joins the stack at once while it processes, and the media item switches to it when it is ready unless someone picked another version since. Comps keep the version they use until they take the update. Send an Idempotency-Key so a retry cannot add it twice. Requires media:write.

  • filename (body) Required. The rendered file’s name, shown on the version.
  • size_bytes (body) Optional. The file’s size.
  • duration_seconds (body) Optional but recommended: checked against the plan’s video minutes before the upload. Without it, ten minutes are reserved until the file is processed.
  • width, height (body) Optional. Pick the encoding tier: 4K and LED-wide files keep their full resolution.
curl -X POST https://preshow.link/api/v1/projects/$PROJECT_ID/media/$MEDIA_ID/versions \
  -H "Authorization: Bearer $PRESHOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"filename": "Opener_v004.mov", "duration_seconds": 42.5, "width": 3840, "height": 2160}'

# then send the file to upload.url
curl -X PUT -T Opener_v004.mov "$UPLOAD_URL"
{
  "object": "media_version",
  "id": "ver-1790131402117-0c5e2b9a4",
  "media_id": "asset-1790131002211-4be09a7d1",
  "project_id": "proj-1790130227580-2c6a1f827",
  "number": 4,
  "filename": "Opener_v004.mov",
  "status": "processing",
  "created_at": "2026-09-23T15:16:42.117Z",
  "upload": {
    "url": "https://storage.googleapis.com/video-storage-…",
    "method": "PUT",
    "expires_at": "2026-09-23T16:16:42.117Z"
  }
}

GET/v1/projects/{projectId}/media/{mediaId}/notes

The notes on a video: the ones left on the video itself and the ones written in comps that point into it, by note id. at_seconds is where in the video each note points, counted from its first frame, so an editing app can put it on its own timeline. Requires projects:read.

  • status (query) open (the default), resolved or all.
  • version_id (query) Optional. Only notes written on this version.
  • limit (query) Page size, 1 to 100. Default 25.
  • cursor (query) The next_cursor from the previous page. Omit for the first page.
curl "https://preshow.link/api/v1/projects/$PROJECT_ID/media/$MEDIA_ID/notes?status=open" \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{
  "object": "list",
  "data": [
    {
      "object": "note",
      "id": "comment-1790131620514-77ab0e3d1",
      "project_id": "proj-1790130227580-2c6a1f827",
      "media_id": "asset-1790131002211-4be09a7d1",
      "version_id": "ver-1790131002211-1f0e9c2b7",
      "version_number": 3,
      "comp_id": null,
      "text": "Logo lands a beat late here.",
      "author": {
        "name": "Dana (client)",
        "signed_in": false
      },
      "at_seconds": 12.48,
      "status": "open",
      "has_drawing": true,
      "replies_count": 1,
      "created_at": "2026-09-23T15:20:20.514Z",
      "app_url": "https://preshow.link/app?project=proj-1790130227580-2c6a1f827&view=editor&asset=asset-1790131002211-4be09a7d1&comment=comment-1790131620514-77ab0e3d1"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

GET/v1/projects/{projectId}/comps

A project’s comps, with the media on each screen and a link that opens the comp in Preshow. Requires projects:read.

  • limit (query) Page size, 1 to 100. Default 25.
  • cursor (query) The next_cursor from the previous page. Omit for the first page.
curl https://preshow.link/api/v1/projects/$PROJECT_ID/comps \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "list", "data": [{ "object": "comp", … }], "has_more": false, "next_cursor": null }

POST/v1/projects/{projectId}/comps

Makes a comp on a stage, putting media on its screens. Each screen must be one of the stage’s known_screens and each media item must be ready. The comp opens in Preshow as a draft, like one made by hand. Send an Idempotency-Key so a retry cannot make it twice. Requires comps:write.

  • stage_id (body) Required. From the stages list.
  • screens (body) Required, 1 to 32 of { screen, media_id, start_seconds }. start_seconds defaults to 0.
  • name (body) Optional. Defaults to the first media item’s name.
curl -X POST https://preshow.link/api/v1/projects/$PROJECT_ID/comps \
  -H "Authorization: Bearer $PRESHOW_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"name": "Opener look A", "stage_id": "scene-1790130230112-8d1e0b4c2",
       "screens": [{"screen": "Main_LED_Wall", "media_id": "asset-1790131002211-4be09a7d1"}]}'
{
  "object": "comp",
  "id": "comp-1790131090418-a93d0c1e7",
  "project_id": "proj-1790130227580-2c6a1f827",
  "name": "Opener look A",
  "stage_id": "scene-1790130230112-8d1e0b4c2",
  "status": "draft",
  "duration_seconds": 42.5,
  "screens": [
    {
      "screen": "Main_LED_Wall",
      "media_id": "asset-1790131002211-4be09a7d1",
      "start_seconds": 0,
      "duration_seconds": 42.5
    }
  ],
  "created_at": "2026-09-23T21:04:50.418Z",
  "updated_at": "2026-09-23T21:04:50.418Z",
  "app_url": "https://preshow.link/app?project=proj-1790130227580-2c6a1f827&view=editor&composition=comp-1790131090418-a93d0c1e7"
}

GET/v1/projects/{projectId}/comps/{compId}

One comp. Requires projects:read.

curl https://preshow.link/api/v1/projects/$PROJECT_ID/comps/$COMP_ID \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "comp", "id": "comp-1790131090418-a93d0c1e7", … }

GET/v1/webhook_endpoints

The webhook endpoints this key (or app connection) added. Endpoints added in Settings or by other keys are not listed. Revoking the key, or disconnecting the app, disables its endpoints. Requires webhooks:manage.

  • limit (query) Page size, 1 to 100. Default 25.
  • cursor (query) The next_cursor from the previous page. Omit for the first page.
curl https://preshow.link/api/v1/webhook_endpoints \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "list", "data": [{ "object": "webhook_endpoint", … }], "has_more": false, "next_cursor": null }

POST/v1/webhook_endpoints

Starts sending the chosen events to a URL. The response holds the signing secret, the only time it is shown. This is how an automation platform subscribes itself; people usually add endpoints in Settings instead. Deliveries carry project content, so this also needs projects:read. Requires webhooks:manage.

  • url (body) Required. An https URL on the public internet.
  • events (body) Required. Event types from the list below, or ["*"] for all of them, including ones added later.
  • description (body) Optional, up to 100 characters.
curl -X POST https://preshow.link/api/v1/webhook_endpoints \
  -H "Authorization: Bearer $PRESHOW_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/preshow-events", "events": ["media.ready", "comp.status_changed"]}'
{
  "object": "webhook_endpoint",
  "id": "we_4hQ8mZ2rT6yV0bN3cX7k",
  "url": "https://example.com/preshow-events",
  "description": "",
  "events": [
    "comp.status_changed",
    "media.ready"
  ],
  "status": "enabled",
  "disabled_reason": null,
  "created_at": "2026-09-23T21:10:00.000Z",
  "last_success_at": null,
  "last_failure_at": null,
  "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}

GET/v1/webhook_endpoints/{endpointId}

One endpoint, with when it last delivered and whether it is failing. The secret is never returned again. Requires webhooks:manage.

curl https://preshow.link/api/v1/webhook_endpoints/$ENDPOINT_ID \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
{ "object": "webhook_endpoint", "id": "we_4hQ8mZ2rT6yV0bN3cX7k", "status": "enabled", … }

DELETE/v1/webhook_endpoints/{endpointId}

Stops sending to the endpoint at once, including retries already scheduled. Answers 204 with no body. Requires webhooks:manage.

curl -X DELETE https://preshow.link/api/v1/webhook_endpoints/$ENDPOINT_ID \
  -H "Authorization: Bearer $PRESHOW_API_KEY"
HTTP/1.1 204 No Content

Uploading a video

  1. POST /v1/projects/{projectId}/media with the video’s name. The answer holds its id and an upload.url.
  2. PUT the file’s bytes to that URL as the whole request body, within the hour. No key is needed for this request: the URL itself is the permission.
  3. Poll GET /v1/projects/{projectId}/media/{mediaId} every few seconds until status is ready.
  4. Put it on a stage: GET …/stages for the screens, then POST …/comps.

The video shows up in Preshow as soon as step 1 answers, marked as processing, and becomes playable when step 3 says ready, in every open tab. Video minutes count against the plan of the project’s owner, as they do for uploads made in the app.

Retries

Send an Idempotency-Key header (a fresh UUID is ideal) with every POST. If the connection drops and you retry with the same key and the same body within 24 hours, you get the first answer again, marked Idempotent-Replayed: true, instead of a second video or comp. A request that was refused (a 4xx) is not remembered, so once you fix the cause, retrying it runs it again. One that failed on our side (a 5xx) may have got partway, so the same key answers with that failure from then on: check whether it took effect, then retry with a new key.

Webhooks

Instead of asking Preshow over and over whether something changed, give it a URL and it will tell you: a signed POST to your server within seconds of the event. Add endpoints in Settings → Developers → Webhooks, or with POST /v1/webhook_endpoints (how an automation platform subscribes itself). An endpoint receives events from the projects in its workspace that the person who added it can open; if they lose access, the events stop.

Events

TypeWhendata
media.readyA video finished processing and can be used in a comp.The media item, as GET …/media/{mediaId} returns it, plus processed_version_id.
media.failedA video could not be processed.The media item, plus processed_version_id.
comp.createdA comp was created, in the app or through the API.The comp, as GET …/comps/{compId} returns it.
comp.status_changedA comp’s review status changed, for example to approved.The comp (its status is the new one), plus changed_by and via (app or review_link).
comp.version_publishedA new version of a comp was published for review.A comp_version: its id, number, label, published_at and published_by, and the comp.
comment.createdSomeone left a note on a comp.A comment: text, author, at_seconds on the timeline, has_drawing and a link that opens it.

Subscribe to * to receive every type, including ones added later. Send test event in Settings delivers a webhook.test event whatever you subscribed to.

What arrives

Every body is { type, timestamp, data }, where data is the same object the API returns for that thing. Deliveries follow the Standard Webhooks specification, so its libraries verify them unchanged.

POST /preshow-events HTTP/1.1
Content-Type: application/json
User-Agent: Preshow-Webhooks/1.0
webhook-id: msg_2c6a1f827e0b4d5a9f13c7e2b8d4a6f0
webhook-timestamp: 1790205562
webhook-signature: v1,49ffLVXq4ec3YTYpJTpr9dbiqkXGZY6UFW/T3zBSwe8=

{
  "type": "comp.status_changed",
  "timestamp": "2026-09-23T21:59:22.000Z",
  "data": {
    "object": "comp",
    "id": "comp-1790131090418-a93d0c1e7",
    "project_id": "proj-1790130227580-2c6a1f827",
    "name": "Opener look A",
    "stage_id": "scene-1790130230112-8d1e0b4c2",
    "status": "approved",
    "duration_seconds": 42.5,
    "screens": [
      {
        "screen": "Main_LED_Wall",
        "media_id": "asset-1790131002211-4be09a7d1",
        "start_seconds": 0,
        "duration_seconds": 42.5
      }
    ],
    "created_at": "2026-09-23T21:04:50.418Z",
    "updated_at": "2026-09-23T21:59:21.870Z",
    "app_url": "https://preshow.link/app?project=proj-1790130227580-2c6a1f827&view=editor&composition=comp-1790131090418-a93d0c1e7",
    "changed_by": {
      "name": "Dana (client)"
    },
    "via": "review_link"
  }
}

Verifying a delivery

Anyone can POST to your URL, so check the signature before trusting the body. It is an HMAC-SHA256, keyed with your endpoint’s secret (whsec_…, shown once when the endpoint is added), of {webhook-id}.{webhook-timestamp}.{raw body}. Use the raw body exactly as received: parsing and re-serialising the JSON changes it. Reject a timestamp more than five minutes from now, which makes a captured request useless to replay. The Standard Webhooks libraries (JavaScript, Python, Go, Ruby, PHP, Java, C#, Rust and more) do all of this in one call:

import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.PRESHOW_WEBHOOK_SECRET);
const event = wh.verify(rawBody, request.headers); // throws if it is not from Preshow

Or by hand, in Node.js:

import crypto from 'node:crypto';

// rawBody: the request body exactly as received (a string or Buffer), before any JSON parsing.
function verifyPreshowWebhook(rawBody, headers, secret) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // replayed or clock skew
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return headers['webhook-signature'].split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    const given = Buffer.from(signature || '', 'base64');
    return version === 'v1' && given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

The example delivery above is real: it verifies with the secret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw once the timestamp check is off, so you can test your receiver against it.

Responding, retries and duplicates

  • Answer with any 2xx within 10 seconds. Do slow work after answering, from a queue.
  • Anything else (an error, a redirect, a timeout) is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours: eight attempts over about 28 hours. After that the delivery is marked failed, and you can resend it from Settings.
  • A retry carries the same webhook-id. Store the ids you have handled and skip repeats: a delivery can arrive twice if your answer is lost on the way back.
  • Events can arrive out of order. Use timestamp, or fetch the current state from the API.
  • If every delivery to an endpoint fails for five days, Preshow switches it off and tells the person who added it. Turning it back on in Settings starts it again.
  • Preshow only sends to https URLs on the public internet, and does not follow redirects. Its sending addresses are not fixed, so do not allowlist IPs: verify the signature instead.

AI assistants (MCP)

Preshow is also a remote MCP server at https://preshow.link/api/mcp, so an assistant such as Claude, Cursor or VS Code can read your projects and build comps when you ask it to. Its tools are this API’s endpoints under other names: the same permissions, the same project access and the same rate limits. Give it a key with only projects:read to let it look without changing anything.

With an API key, in Claude Code:

claude mcp add --transport http preshow https://preshow.link/api/mcp \
  --header "Authorization: Bearer psk_…"

Or in a client that takes a JSON configuration (Cursor, VS Code, Windsurf):

{
  "mcpServers": {
    "preshow": {
      "url": "https://preshow.link/api/mcp",
      "headers": {
        "Authorization": "Bearer psk_…"
      }
    }
  }
}

A client that signs in with OAuth instead (ChatGPT, Claude and others) needs only the URL: it registers itself and sends you to Preshow to approve it, and you choose the workspace and see what it may do.

ToolWhat it doesNeeds
whoamiThe credential in use, its workspace and permissions.—
list_projectsProjects the credential can see.projects:read
get_projectOne project.projects:read
list_stagesA project’s stages and their known_screens.projects:read
list_mediaA project’s media and its processing status.projects:read
get_mediaOne media item.projects:read
create_media_uploadAdds a video and returns the URL to PUT its bytes to.media:write
list_compsA project’s comps, with review status and app links.projects:read
get_compOne comp.projects:read
create_compPuts ready media on a stage’s screens as a new draft comp.comps:write

Pagination

Lists return data, has_more and next_cursor. While has_more is true, request the next page with cursor=<next_cursor>. Cursors are stable: items added or removed between pages never cause one to be repeated or skipped. A page can hold fewer items than limit; rely on has_more, not the count.

Errors

Errors use RFC 9457 problem details, served as application/problem+json. Branch on code, which is stable; detail is written for people and may change.

{
  "type": "https://preshow.link/docs/api#insufficient_scope",
  "title": "Forbidden",
  "status": 403,
  "detail": "This key lacks the \"projects:read\" permission. Create a key that includes it.",
  "code": "insufficient_scope",
  "request_id": "a1b2c3d4e5f6"
}
CodeStatusMeaningWhat to do
missing_api_key401No Authorization header, or not a Bearer token.Send Authorization: Bearer psk_….
invalid_api_key401The key is malformed, mistyped or unknown.Copy the key again; it is 53 characters starting psk_.
api_key_revoked401The key was revoked in Settings.Create a new key.
api_key_expired401The key passed its expiry date.Create a new key; choose a longer expiry if you need one.
api_key_owner_inactive401The account that owns the key was deleted or disabled.Ask a current member to create a key.
invalid_access_token401The OAuth access token is malformed or unknown.Send the access_token from the token endpoint, exactly.
access_token_expired401The OAuth access token is more than an hour old.Get a new one with your refresh token, then retry.
access_token_revoked401The user disconnected your app, or it was deleted.Send the user through the authorization flow again.
workspace_access_revoked403The key’s owner is no longer a member of its workspace.A current member creates a new key for that workspace.
insufficient_scope403The key lacks the permission this endpoint needs.Create a key that includes it; permissions cannot be added to an existing key.
invalid_request400A parameter is missing, unknown or out of range.Read `detail`; it names the parameter.
project_not_found404No project with that id is visible to this key.Check the id and the key’s workspace.
project_read_only403The key’s owner can view this project but not change it.Ask the project’s owner for editor access.
project_needs_first_open409The project was last opened before a storage update and has not been moved to the current format yet.Open it once in Preshow, then retry.
media_not_found404No media item with that id in this project.Check the id against the media list.
media_not_ready409The media item is still processing.Poll it until status is ready, then retry.
media_not_placeable422Audio cannot be put on a screen.Use a video or still.
media_not_video422Only a video takes a new video version; this media item is a still or audio.Pick a video, or upload the file as new media.
video_minutes_exceeded403The upload would take the plan past its video minutes.Remove unused videos or upgrade the plan.
upload_unavailable503The video service could not create an upload.Retry with backoff.
stage_not_found404No stage with that id in this project.Check the id against the stages list.
stage_has_no_known_screens422No screen on this stage can be verified yet.In Preshow, set a default screen on the stage or make one comp on it.
unknown_screen422The screen is not one of the stage’s known_screens.Use a name from known_screens; `detail` lists them.
comp_not_found404No comp with that id in this project.Check the id against the comps list.
webhook_endpoint_not_found404No webhook endpoint with that id for this key.Endpoints are visible only to the person who added them, in their workspace.
webhook_url_not_allowed422The URL is not https, or points somewhere private (localhost, an internal network).Use an https URL on the public internet.
webhook_endpoint_limit409The workspace already has 10 webhook endpoints, or you already added 5 to this team workspace.Delete one you no longer use.
invalid_idempotency_key400The Idempotency-Key header is empty, too long or not plain ASCII.Use a UUID.
idempotency_key_reused422This Idempotency-Key was already used for a different request.Use a new key for each distinct request.
idempotency_in_progress409The first request with this Idempotency-Key is still running.Retry in a moment; you will get its result.
not_found404No endpoint at that path.Check the path against this page.
unsupported_protocol_version400An MCP client sent an MCP-Protocol-Version this server does not speak.Update the client; `detail` lists the supported versions.
method_not_allowed405The endpoint exists but not for that HTTP method.Use the method in the Allow header.
rate_limited429Too many requests for this key, or for all of its account’s keys and apps together, in the current minute.Wait the Retry-After seconds, then retry.
internal_error500Something failed on our side. Under an Idempotency-Key the same failure is replayed, since the request may have got partway.Retry with backoff; with an Idempotency-Key, check whether it took effect and retry with a new key. Quote the request_id if you contact us.

Rate limits

Each key may make 120 requests per 60 seconds, and one account's keys and connected apps together 600. Every response says where you stand, in the IETF RateLimit and RateLimit-Policy headers (r is requests remaining, t seconds until the window resets). Over the limit you get 429 with a Retry-After header: wait that long, then retry.

RateLimit-Policy: "per-key";q=120;w=60
RateLimit: "per-key";r=117;t=42

Versioning

The version is in the path (/v1). Within a version we only add: new endpoints, new optional parameters, new fields in responses. Write clients that ignore fields they do not know. Anything that would break an existing client ships as a new version, with the old one kept running while you move.