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 → API keys 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.
comps:writeCreate comps from a project’s media.
webhooks:manageAdd and remove webhook endpoints, e.g. for an automation platform to subscribe itself.

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, … }

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 the key’s owner added to the key’s workspace, in Settings or through the API. 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. 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 failed is not remembered, so retrying it runs it again.

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 → API keys → 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.

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.
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.
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.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.
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 in the current minute.Wait the Retry-After seconds, then retry.
internal_error500Something failed on our side.Retry with backoff; quote the request_id if you contact us.

Rate limits

Each key may make 120 requests per 60 seconds. 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.