Puffin Ship API

The Puffin Ship API lets you drive the whole video pipeline from your own code: upload source material, run a template, track progress, and download the finished edit: everything you can do in the app, over HTTP.

  • Base URL: https://app.puffinship.com
  • Format: JSON request and response bodies (Content-Type: application/json), UTF-8.
  • IDs: all resource IDs are UUIDs.

The API is in preview and rolls out alongside self-serve access. Endpoints under /api are stable in behavior but not yet versioned; we'll announce a versioned path before making breaking changes.

Authentication

Developer API access requires the Creator plan or above. On the Free plan, creating an API key is refused with a message pointing you to upgrade. See Plans for what each plan includes.

Authenticate with an API key. Create one in the app under Account Settings → API Keys, then send it as a bearer token on every request:

curl https://app.puffinship.com/api/workspaces \
  -H "Authorization: Bearer psk_your_key_here"

A key inherits your account's access. Two things to know:

  • Scope. A key can be scoped to a single workspace (recommended, since it limits the blast radius if the key leaks) or left account-wide to reach every workspace you belong to. A workspace-scoped key returns 403 on any other workspace. That applies however you address the resource: runs and run groups are reached by their own IDs rather than under a workspace path, and a scoped key is refused on those too. Endpoints that list across workspaces, such as GET /api/workspaces and GET /api/runs/recent, return only the scoped workspace's entries rather than a 403. There is no other workspace to refuse, because none of them appear.
  • Expiry. A key can be given an expiry (30/90/365 days) or left non-expiring. Revoke a key any time from the same screen; revocation takes effect immediately.

The key secret is shown once, at creation. Store it somewhere safe (a secrets manager or CI secret). We only keep a hash and can't show it again.

For security, API keys are limited to the developer surface below. They can not manage the account itself. Changing your password, listing or revoking sessions, issuing or revoking API keys, deleting the account, or any admin action all require signing in interactively.

Errors

Errors use standard HTTP status codes and a JSON body:

{ "error": "not a member of that workspace" }
Status Meaning
400 Malformed request (bad JSON, missing/invalid field)
401 Missing or invalid API key
403 Authenticated, but not allowed (e.g. key scoped to another workspace)
404 Resource not found
412 The template needs a third-party service key you haven't set; see Running a template
429 Rate limited
503 New runs are paused platform-wide; see Availability

Workspaces

A workspace is the container for your files, templates, and runs.

Method Path Description
GET /api/workspaces List the workspaces you can access
POST /api/workspaces Create a workspace: { "name": "..." }
GET /api/workspaces/{id} Get one workspace
PATCH /api/workspaces/{id} Update: { "name"?: "...", "training_opt_out"?: bool } (owner only for training_opt_out)
DELETE /api/workspaces/{id} Delete the workspace (owner only)

Files

Source material (video, audio, images) is uploaded in parts, then completed. The flow is: init → upload each part → complete.

POST /api/workspaces/{id}/files
  { "name": "interview.mp4", "content_type": "video/mp4" }
  → { "file_id", "upload_id", "s3_key" }

POST /api/workspaces/{id}/files/{file_id}/parts?upload_id={upload_id}&part={n}
  body: raw bytes of part n (1-based)
  → { "etag" }

POST /api/workspaces/{id}/files/{file_id}/complete
  { "upload_id": "...", "parts": [ { "part_num": 1, "etag": "..." } ] }
  → { "status": "processing" }   // metadata (duration, dimensions) is probed asynchronously

List files (each includes a presigned thumbnail_url):

GET /api/workspaces/{id}/files

Templates

A template is a saved pipeline: the YAML config that describes your edit as a sequence of processors.

Method Path Description
GET /api/workspaces/{id}/templates List templates
POST /api/workspaces/{id}/templates Create: { "name", "config_yaml" }
GET /api/workspaces/{id}/templates/{tid} Get a template (includes its config_yaml)
PUT /api/workspaces/{id}/templates/{tid} Update name and/or config_yaml
DELETE /api/workspaces/{id}/templates/{tid} Delete

See the configuration reference for the YAML structure and the processor library for every processor and its parameters.

Running a template

Trigger a run, then poll it (or stream events) until it finishes.

POST /api/workspaces/{id}/templates/{tid}/runs
  { "name": "My run" }          // name is required
  → 201, the run object directly: { "id", "status", ... }

Missing service keys. Some processors call third-party services (ElevenLabs, HeyGen, Google, OpenAI) that are billed to you, so you supply those keys. If the template needs one you haven't set, the trigger is rejected with 412 and nothing runs:

{
  "error": "missing API keys required by this pipeline",
  "missing_secrets": [
    { "key": "GOOGLE_API_KEY", "service_name": "Google / Veo3",
      "description": "Required for AI video generation", "needed_by": ["generate_broll"] }
  ]
}

Set the missing keys as workspace or template secrets, then retry. To check ahead of time without triggering a run:

POST /api/workspaces/{id}/templates/{tid}/runs/preflight
  → { "missing_secrets": [ ... ] }   // empty array means good to go

Tracking a run

GET /api/runs/{rid}            → { "run", "steps", "reviews" }
GET /api/runs/{rid}/events     → Server-Sent Events stream of live progress

A run moves through these states:

Status Meaning
queued Accepted, waiting for a worker
running Processing steps
awaiting_review Paused at a review gate (a step with review: true); needs a decision
awaiting_recording Paused for a webcam recording (the av_recorder step)
done Finished successfully: exports are ready
failed Stopped without finishing (see error and the run's steps for detail)

The terminal states are done and failed. Those are the only two a run can end in, so polling for anything else will wait forever.

failed covers every way a run stops short, not just a crash. Cancelling a run (POST /api/runs/{rid}/cancel) ends it as failed with error set to cancelled by user, and rejecting a review gate ends it as failed with an error naming the rejected step. Read error when you need to tell a genuine error from a deliberate stop.

The SSE stream (/events) also accepts the key as a ?token= query parameter, since EventSource can't send headers: GET /api/runs/{rid}/events?token=psk_....

Review gates

If a step has review: true, the run pauses at awaiting_review and exposes a pending review you resolve:

POST /api/runs/{rid}/reviews/{review_id}
  { "action": "approved" }                     // approve as-is
  { "action": "rejected" }                      // reject → run ends as "failed"
  { "action": "edited", "response": { ... } }   // approve with an edited payload
  { "action": "regenerate" }                    // ask the step to produce new output
  → { "status": "ok" }

Exports

Once a run is done, download its outputs. Export endpoints stream the file directly (with a Content-Disposition: attachment header); they do not redirect to a presigned URL.

Method Path Output
GET /api/runs/{rid}/export/fcpxml Final Cut Pro XML bundle (.fcpxmld)
GET /api/runs/{rid}/export/srt Subtitles (.srt)
GET /api/runs/{rid}/export/video Rendered video (see below)

The FCPXML export downloads as a zipped .fcpxmld bundle: unzip it, then open the result in Final Cut Pro.

The rendered video is produced on demand:

POST /api/runs/{rid}/video/render     → starts a render
GET  /api/runs/{rid}/video/status     → { "status": "none|pending|rendering|done|failed", "url"?, "error"?, ... }
GET  /api/runs/{rid}/export/video     → the finished file, once status is "done"

status is none when no render has been requested yet; url (a presigned link) is present once it is done.

A minimal end-to-end example

KEY="psk_your_key_here"
BASE="https://app.puffinship.com/api"

# 1. Pick a workspace and template you already have.
WS=$(curl -s $BASE/workspaces -H "Authorization: Bearer $KEY" | jq -r '.[0].id')
TPL=$(curl -s $BASE/workspaces/$WS/templates -H "Authorization: Bearer $KEY" | jq -r '.[0].id')

# 2. Trigger a run (name is required; the run object is returned directly).
RID=$(curl -s -X POST $BASE/workspaces/$WS/templates/$TPL/runs \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"name":"api run"}' | jq -r '.id')

# 3. Poll until it reaches a terminal state.
while :; do
  S=$(curl -s $BASE/runs/$RID -H "Authorization: Bearer $KEY" | jq -r '.run.status')
  echo "status: $S"
  case "$S" in done|failed) break;; esac
  sleep 5
done

# 4. If it finished, download the Final Cut Pro export.
[ "$S" = "done" ] && curl -s $BASE/runs/$RID/export/fcpxml -H "Authorization: Bearer $KEY" -o edit.fcpxmld

Availability

GET /system/status reports whether the platform is accepting new runs. It needs no authentication.

{ "mode": "normal", "message": "", "until": null }

mode is normal, over_capacity, or maintenance. When it is not normal, starting a run returns 503 with a Retry-After header and a body naming the reason:

{
  "code": "maintenance",
  "message": "Scheduled maintenance is in progress. New runs are paused.",
  "until": "2026-07-25T09:00:00Z"
}

Runs already in progress are unaffected, and every other endpoint keeps working. A client that starts runs on a schedule should treat 503 as retryable and honour Retry-After rather than retrying immediately.

Rate limits & fair use

The API shares your account's run allowance: your plan's run limit (see Plans) applies whether a run is started from the app or through the API. A run refused for being over the limit returns 429. Automated clients should poll runs on a sensible interval (a few seconds) rather than in a tight loop, and prefer the SSE /events stream for live progress.