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
/apiare 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
403on 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 asGET /api/workspacesandGET /api/runs/recent, return only the scoped workspace's entries rather than a403. 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.