Developer Platform API reference

Build workspace integrations with the versioned Playflow Developer Platform API. This reference follows the contracts in the OpenAPI specification.

Controlling an embedded player? Use the browser-side Player API. It does not use API keys.

Base URL
https://getplayflow.co/api

Building an event-driven integration? See the webhooks reference for signed delivery, retries, and endpoint management.

Authentication and API keys

Create a key in Settings → API keys in the Playflow app. Keys are scoped to one workspace. The read permission supports safe read-only jobs; write supports integrations that create or change data.

ScopePrefixAllowed operations
readpf_read_GET, HEAD and OPTIONS, except the members endpoint, which requires write scope.
writepf_live_Read operations plus write operations.

The secret is shown once in the create response. Store it in a secret manager immediately. The API never returns the secret again, including when you list keys.

Authenticated request
curl https://getplayflow.co/api/v1/workspace \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"

Do not put a key in browser JavaScript, source control, URLs or logs. A revoked, expired or malformed key returns 401.

MCP transport and OAuth

Playflow also exposes a stateless Streamable HTTP MCP transport at POST /api/mcp. MCP clients authenticate through Playflow's OAuth authorization-code flow with PKCE S256. The authorization server supports authorization-code and refresh-token grants and the openid, profile, email, and offline_access scopes. Client ID Metadata Documents are supported; dynamic client registration is disabled.

Discovery and canonical /oauth2/* routes are mounted at the site origin. Consent also has the API-prefixed alias shown below. These URLs use Playflow's configured canonical public origin rather than the incoming Host header.

Method and pathPurpose
GET /.well-known/oauth-authorization-serverRFC 8414 authorization-server metadata.
GET /.well-known/oauth-authorization-server/api/authIssuer-qualified alias of the authorization-server metadata.
GET /.well-known/oauth-protected-resourceRFC 9728 metadata for the MCP protected resource.
GET /.well-known/oauth-protected-resource/api/mcpResource-qualified alias advertised by the MCP challenge.
GET /oauth2/authorizeStarts authorization and redirects signed-in users through consent.
POST /oauth2/tokenExchanges an authorization code or refresh token.
POST /oauth2/revokeRevokes an OAuth token.
ALL /oauth2/*Provider-owned OAuth routes. Discovery advertises the supported endpoints.
GET /oauth/consentRenders consent for an authenticated user; unauthenticated users are redirected to /login with the OAuth continuation preserved.
POST /oauth2/consentSubmits the same-origin decision with the single-use consent nonce.
POST /api/auth/oauth2/consentAPI-prefixed alias for the same consent submission and nonce checks.
POST /api/mcpAuthenticated MCP JSON-RPC transport.

A request to POST /api/mcp without a valid bearer token returns 401 and a WWW-Authenticate: Bearer challenge. Its resource_metadata value points to /.well-known/oauth-protected-resource/api/mcp on the canonical public origin. GET /api/mcp and DELETE /api/mcp return JSON 405 method_not_allowed responses with Allow: POST.

Workspace

GET /v1/workspace

Returns the workspace associated with the bearer key. Params: none. 200 response: includes playbackOptions, the workspace defaults for autoplay, loop, rememberSound, titleMode, titleFadeSeconds, centerPlay and centerReplay. The title fields are nested as playbackOptions.titleMode and playbackOptions.titleFadeSeconds. Video overrides do not change these workspace values.

A logo destination is not a top-level workspace field in this projection: it is returned per video under effectiveIdentity.logoLinkUrl, with a nullable video override at identityOverrides.logoLinkUrl.

The workspace domain policy is returned as playbackDomainMode (anywhere or only_these), playbackAllowedDomains, playbackBlockedDomains, and playbackWatchEnabled. The optional, read-only playbackDomainSync reports status as pending, syncing, synced, or failed, plus optional error and updatedAt. Its absence means there is no sync record to report.

cURL
curl https://getplayflow.co/api/v1/workspace \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"
200 OK
{
  "publicId": "ws_2Kd8",
  "name": "Acme Studio",
  "plan": "growth",
  "playbackDomainMode": "only_these",
  "playbackAllowedDomains": ["example.com"],
  "playbackBlockedDomains": ["ads.example.com"],
  "playbackWatchEnabled": true,
  "playbackDomainSync": { "status": "synced", "updatedAt": "2026-09-21T12:00:00Z" },
  "playbackOptions": { "autoplay": "off", "loop": false, "rememberSound": true, "titleMode": "timed", "titleFadeSeconds": 5 }
}

PATCH /v1/workspace

Updates only the workspace playback-domain policy. Use a write-scoped key whose creator currently has workspace.manage permission in the workspace. The body accepts exactly these optional fields: playbackDomainMode, playbackAllowedDomains, playbackBlockedDomains, and playbackWatchEnabled. Unknown fields are rejected. playbackDomainSync is read-only and cannot be sent. Domain arrays contain up to 100 hostnames, each at most 253 characters. Hostnames are normalized, deduplicated, and sorted. Choosing anywhere clears playbackAllowedDomains. A successful request returns the same workspace projection as GET, including sync state when available.

cURL
curl -X PATCH "$BASE/v1/workspace" \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"playbackDomainMode":"only_these","playbackAllowedDomains":["example.com"],"playbackBlockedDomains":["ads.example.com"],"playbackWatchEnabled":true}'

See Where your videos can play for rule precedence, matching, and the watch-page exception.

Videos

GET /v1/videos

Lists videos in newest-first order for the authenticated workspace. Query params: limit is an integer from 1 to 100, default 50; cursor is the opaque cursor returned by the previous response; folder filters by public folder ID; status accepts created, uploading, processing, ready or error; visibility accepts private, unlisted or public; and q searches title and description. Cursors are signed and should be passed back unchanged. Each item exposes resolved effectivePlaybackOptions, including titleMode and titleFadeSeconds, plus effectiveIdentity.logoLinkUrl, playbackExtraAllowedDomains, and playbackBlockedDomains.

Effective identity also includes workspace-only logoLinkTracking, logoLinkReferrer, watermarkLinkTracking, and watermarkLinkReferrer booleans (default false), plus watermarkLinkUrl (HTTPS URL, maximum 2048 characters, or null) and preferredControlForeground (opaque six-digit hex or null). These are not video overrides. A video logo URL override still uses workspace tracking settings. Workspace customization uses nested player color settings for links; the developer workspace summary does not expose that customization object.

controlBackground accepts #RRGGBB or #RRGGBBAA; accent, progress, and foreground remain opaque six-digit colors. Foreground contrast is evaluated against dark and light composited references without removing background alpha. Tracking adds missing utm_source=playflow and utm_medium=video, a known public video ID as utm_content, and optionally a known caller host as utm_referrer. Existing parameters, including empty values, and URL fragments are preserved. Disabled tracking leaves the destination unchanged.

Response fieldType
itemsArray of publicId, title, status, visibility, createdAt, effectivePlaybackOptions, playbackExtraAllowedDomains, and playbackBlockedDomains. Each item also includes effectiveIdentity.
nextCursorString or null. Use it for the next page.
cURL
curl "https://getplayflow.co/api/v1/videos?limit=50&cursor=CURSOR&folder=FOLDER_PUBLIC_ID&status=ready&visibility=public&q=onboarding" \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"
200 OK
{
  "items": [{ "publicId": "vid_2Kd8", "title": "Onboarding tour", "status": "ready", "visibility": "public", "createdAt": "2026-09-08T14:02:11Z", "playbackExtraAllowedDomains": ["client.example"], "playbackBlockedDomains": [], "effectivePlaybackOptions": { "autoplay": "off", "loop": false, "rememberSound": true, "titleMode": "timed", "titleFadeSeconds": 5 }, "effectiveIdentity": { "accent": "#3C6876", "progress": "#9FBDBC", "controlBackground": "#1B2F38", "posterTreatment": "default", "barStyle": "full", "watermarkEnabled": true, "logoEnabled": true, "logoLinkUrl": "https://example.com/" } }],
  "nextCursor": "eyJ..."
}

Stop when nextCursor is null. An invalid or expired cursor returns 400 with invalid_cursor. Do not construct or decode cursors.

GET /v1/videos/{videoId}

Returns the provider-neutral video detail projection. The nested folder is either null or a public folder projection. playbackOptions contains nullable video overrides and effectivePlaybackOptions is the resolved player behavior. playbackExtraAllowedDomains and playbackBlockedDomains contain this video's domain rules. identityOverrides contains nullable per-video visual identity values and effectiveIdentity is the resolved accent, progress, control background, cover treatment, bar style, and workspace-mark visibility. Published videos expose share and embed links on v.getplayflow.co; private videos return null for those links and for a private thumbnail.

List and detail responses include passwordProtected. Neither passwords nor password hashes are returned. PATCH accepts a write-only password string of 1 to 256 characters to set or replace protection on public or unlisted videos; {"password": null} removes it. Setting a password requires the workspace's links.password entitlement. Removal remains available when that entitlement is disabled. Password transitions revoke prior unlock cookies, verify removal of old public objects, and republish the page. See password-protected sharing for cookie restrictions and playback URL expiry.

cURL
curl https://getplayflow.co/api/v1/videos/VIDEO_ID \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"
200 OK
{
  "publicId": "vid_2Kd8",
  "title": "Onboarding tour",
  "description": "A short product tour",
  "metadata": {},
  "status": "ready",
  "visibility": "public",
  "durationSeconds": 92,
  "width": 1920,
  "height": 1080,
  "createdAt": "2026-09-08T14:02:11Z",
  "updatedAt": "2026-09-09T10:18:44Z",
  "folder": { "publicId": "fld_marketing", "name": "Marketing" },
  "shareUrl": "https://v.getplayflow.co/ws_2Kd8/vid_2Kd8",
  "embedUrl": "https://v.getplayflow.co/ws_2Kd8/e/vid_2Kd8",
  "thumbnailUrl": "https://v.getplayflow.co/api/public/videos/vid_2Kd8/thumbnail",
  "plays": 184,
   "completionRate": 0.72,
  "playbackExtraAllowedDomains": ["client.example"],
  "playbackBlockedDomains": ["preview.client.example"],
  "playbackOptions": { "autoplay": null, "loop": null, "rememberSound": null, "titleMode": null, "titleFadeSeconds": null },
  "effectivePlaybackOptions": { "autoplay": "off", "loop": false, "rememberSound": true, "titleMode": "timed", "titleFadeSeconds": 5 },
  "identityOverrides": { "accent": null, "progress": null, "controlBackground": null, "posterTreatment": null, "barStyle": null, "watermarkEnabled": null, "logoEnabled": null, "logoLinkUrl": null },
  "effectiveIdentity": { "accent": "#3C6876", "progress": "#9FBDBC", "controlBackground": "#1B2F38", "posterTreatment": "default", "barStyle": "full", "watermarkEnabled": true, "logoEnabled": true, "logoLinkUrl": "https://example.com/" }
}

To update a video, use the PATCH body reference, including nullable playback and identity overrides and write-only password protection.

Video list and detail responses include server-issued editRevision, read-only replacedAt (the most recent successful file replacement as an ISO date-time, or null) and downloadAvailable. A never-edited video has revision 0; edits advance the revision but do not set replacedAt. These public media-history fields are named exactly editRevision and replacedAt; internal operation IDs and provider snapshots are not exposed. durationSeconds is the current output duration, including edits. A download returns the current revision's original before provider transcoding, not an older retained revision. downloadAvailable indicates current plan and permission eligibility; source availability is verified when downloading.

Replace a video from the tenant app

With videos.update permission, POST /api/videos/{videoId}/replacements uploads a new file while retaining the existing video ID, URLs and embeds. Supply fileName, contentType and contentLength, plus optional analytics (keep or fresh), captions (regenerate or keep) and chapters (keep, regenerate or clear). Defaults are keep, regenerate and keep. The response contains an operation and short-lived provider upload credentials. Complete the upload with POST /api/videos/{videoId}/replacements/{editId}/complete and poll GET /api/videos/{videoId}/replacements/{editId} for readiness or an actionable failure reason. Upload completion does not relink playback. Restore the previous version within 48 hours using POST /api/videos/{videoId}/edits/{editId}/revert while the operation remains the latest revision. See Replacing a video for playback, thumbnail, chapter and analytics behavior. These are authenticated tenant routes, not bearer API v1 endpoints.

POST /v1/videos/{videoId}/download

Original downloads and workspace ZIP exports require Fleet. Marina API keys receive HTTP 403 with an upgrade reason at original-download handoffs, including restored masters and previously issued download URLs. Analytics CSV exports are a separate capability and are not subject to this originals restriction.

After a downgrade, existing API keys, webhook endpoints, videos, and members remain usable even when over the new plan limits. Only creating new resources is refused at capacity. Existing cold master archives remain restorable; restoration is not permission to download original bytes. Long-term retention of new originals is a Fleet feature.

The download operation accepts a read or write key and requires Fleet (scale) plus the key creator's current videos.download permission. Its SignedDownload response contains url and expiresAt (at most 15 minutes). Treat the URL as a bearer secret: do not log, publish or cache it. Redemption rechecks membership, plan, permission, key revocation and source revision.

Captions

Caption endpoints use the owned video ID and a language code. A video has one transcript per language, and that transcript is the source for the caption track. Read requests accept a read-scoped key. Updating or deleting captions requires a write-scoped key.

GET /v1/videos/{videoId}/captions/{lang}

Returns the current transcript for one language as {"caption": Track}. The caption contains language, source, status, version, segmentCount, segments, cachedVtt, generatedAt, createdAt, and updatedAt.

PUT /v1/videos/{videoId}/captions/{lang}

Replaces the current transcript with manually edited segments and returns {"caption": Track}. Send segments and expectedVersion. Each segment contains numeric start and end times in seconds, text, and an optional speaker. Use the version returned by the latest read. A stale version returns 409 so an edit cannot silently overwrite a newer upload or edit.

cURL
curl -X PUT https://getplayflow.co/api/v1/videos/VIDEO_ID/captions/en \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"segments":[{"start":0,"end":2.4,"text":"Welcome to Playflow"}],"expectedVersion":3}'

POST /v1/videos/{videoId}/captions/generate

Queue AI transcription with a write-scoped key at POST /api/v1/videos/:id/captions/generate. Send an optional language code, or omit it to detect the language. The request returns 202 with durable generation status. Repeating a request for the same video and language while generation is active reuses the work.

Generation requires the workspace's captions.ai entitlement and available captions.ai_minutes. An unavailable entitlement or exhausted cap returns a denial with an explanation. Provider configuration errors are reported without starting a job. See workspace Billing for monthly usage.

After successful generation, the editable transcript has source ai, becomes the caption track, and emits video.captions.ready. Provider failures retry after 1, 5, and 30 minutes before stopping. Failed attempts do not deduct generated minutes. The Transcript tab retains progress and failure details across sessions.

GET /v1/videos/{videoId}/captions/generation

Poll GET /api/v1/videos/:id/captions/generation with a read-scoped key for durable generation progress and failure details. Use the returned status after a generation request or when returning to a video in a later session.

DELETE /v1/videos/{videoId}/captions/{lang}

Deletes the current transcript and delivered caption track for that language. Supply the current version with the If-Match header or the expectedVersion query parameter. A successful delete returns 204. It does not delete the video or any other language.

For uploads, downloads, version history, player delivery, and visibility behavior, see Captions and transcripts.

Chapters

Chapters are available on Marina and Fleet. Harbor and trial requests return 403 entitlement_denied. See Chapters for editing, generation, and player delivery.

GET /v1/videos/{videoId}/chapters

Use a read-scoped key. The response contains chapters (null or an object with id, items, source, and version), entitled, configured, and durable generation status. Each ordered item has start in seconds, a title under 60 characters, and optional description.

PUT /v1/videos/{videoId}/chapters

Use a write-scoped key with JSON {"items":[{"start":0,"title":"Introduction"}],"expectedVersion":0}. Use zero for a new track and the current version for updates. An empty list removes all chapter items. Starts must strictly ascend within the video duration. Stale versions return 409 version_conflict. Successful changes publish the chapters VTT and emit video.chapters.ready.

POST /v1/videos/{videoId}/chapters/generate

Use a write-scoped key and JSON {}, or {"language":"en"} when selecting a transcript. A ready transcript and at least 90 seconds of video are required. The response is 202 with durable generation status. Poll GET chapters across sessions; video.chapters.ready signals completion. Provider errors retry after 1, 5, and 30 minutes; invalid output does not use another provider. Missing provider configuration returns 503 llm_not_configured. Concurrent transcript or chapter edits prevent overwriting owner changes.

v1 endpoint index

These 26 v1 operations are the complete public surface. All use the bearer header shown above. Set BASE=https://getplayflow.co/api before running a row. Body names refer to the matching OpenAPI schema.

Method and pathParams or bodycURLResponse
GET /v1/workspaceNonecurl https://getplayflow.co/api/v1/workspace -H "Authorization: Bearer pf_read_YOUR_SECRET"200 DeveloperWorkspace
PATCH /v1/workspaceplaybackDomainMode, playbackAllowedDomains, playbackBlockedDomains, playbackWatchEnabledcurl -X PATCH "$BASE/v1/workspace" -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"playbackDomainMode":"only_these","playbackAllowedDomains":["example.com"]}'200 DeveloperWorkspace
GET /v1/videoslimit, cursor, folder, status, visibility, qcurl "https://getplayflow.co/api/v1/videos?limit=50&cursor=CURSOR&folder=FOLDER_PUBLIC_ID&status=ready&visibility=public&q=onboarding" -H "Authorization: Bearer pf_read_YOUR_SECRET"200 DeveloperVideoList
GET /v1/videos/{videoId}Path videoIdcurl https://getplayflow.co/api/v1/videos/VIDEO_ID -H "Authorization: Bearer pf_read_YOUR_SECRET"200 DeveloperVideoDetail
GET /v1/videos/{videoId}/captions/{lang}Path videoId and langcurl https://getplayflow.co/api/v1/videos/VIDEO_ID/captions/en -H "Authorization: Bearer pf_read_YOUR_SECRET"200 caption transcript response
PUT /v1/videos/{videoId}/captions/{lang}segments, expectedVersioncurl -X PUT https://getplayflow.co/api/v1/videos/VIDEO_ID/captions/en -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"segments":[{"start":0,"end":2.4,"text":"Welcome"}],"expectedVersion":3}'200 caption transcript response
POST /v1/videos/{videoId}/captions/generateOptional languagecurl -X POST https://getplayflow.co/api/v1/videos/VIDEO_ID/captions/generate -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"language":"en"}'202 generation status
GET /v1/videos/{videoId}/captions/generationPath videoIdcurl https://getplayflow.co/api/v1/videos/VIDEO_ID/captions/generation -H "Authorization: Bearer pf_read_YOUR_SECRET"200 generation progress
GET /v1/videos/{videoId}/chaptersPath videoIdcurl "$BASE/v1/videos/VIDEO_ID/chapters" -H "Authorization: Bearer pf_read_YOUR_SECRET"200 chapters and progress
PUT /v1/videos/{videoId}/chaptersPutChaptersRequestcurl -X PUT "$BASE/v1/videos/VIDEO_ID/chapters" -H "Authorization: Bearer pf_write_YOUR_SECRET" -H "Content-Type: application/json" -d '{"items":[{"start":0,"title":"Introduction"}],"expectedVersion":0}'200 saved chapters
POST /v1/videos/{videoId}/chapters/generateGenerateCaptionsRequestcurl -X POST "$BASE/v1/videos/VIDEO_ID/chapters/generate" -H "Authorization: Bearer pf_write_YOUR_SECRET" -H "Content-Type: application/json" -d '{}'202 generation queued
DELETE /v1/videos/{videoId}/captions/{lang}Path videoId and lang; If-Match or expectedVersioncurl -X DELETE https://getplayflow.co/api/v1/videos/VIDEO_ID/captions/en -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "If-Match: 3"204 empty
POST /v1/videos/{videoId}/downloadPath videoId; no bodycurl -X POST https://getplayflow.co/api/v1/videos/VIDEO_ID/download -H "Authorization: Bearer pf_read_YOUR_SECRET"200 SignedDownload
GET /v1/foldersNonecurl "https://getplayflow.co/api/v1/folders" -H "Authorization: Bearer pf_read_YOUR_SECRET"200 nested DeveloperFolder tree
GET /v1/analytics/overviewfrom, to, granularity, videoIds, folderId, threshold, includeTeam, sourcecurl "https://getplayflow.co/api/v1/analytics/overview?from=2026-09-01&to=2026-09-30&threshold=90" -H "Authorization: Bearer pf_read_YOUR_SECRET"200 AnalyticsOverview
GET /v1/analytics/overview/exportfrom, to, granularity, videoIds, folderId, threshold, includeTeam, sourcecurl "https://getplayflow.co/api/v1/analytics/overview/export?from=2026-09-01&to=2026-09-30" -H "Authorization: Bearer pf_read_YOUR_SECRET" -o analytics.csv200 text/csv
GET /v1/analytics/videos/{videoId}videoId, from, to, granularity, videoIds, folderId, threshold, includeTeam, sourcecurl "https://getplayflow.co/api/v1/analytics/videos/VIDEO_ID?from=2026-09-01&to=2026-09-30&source=embed" -H "Authorization: Bearer pf_read_YOUR_SECRET"200 VideoAnalytics
GET /v1/analytics/videos/{videoId}/exportvideoId, from, to, granularity, videoIds, folderId, threshold, includeTeam, sourcecurl "https://getplayflow.co/api/v1/analytics/videos/VIDEO_ID/export?from=2026-09-01&to=2026-09-30" -H "Authorization: Bearer pf_read_YOUR_SECRET" -o video-analytics.csv200 text/csv
GET /v1/membersWrite-scoped keycurl https://getplayflow.co/api/v1/members -H "Authorization: Bearer pf_live_YOUR_SECRET"200 array of name, email, role, status
POST /v1/videos/upload-initIdempotency-Key; title, fileName, contentType, contentLength; optional description, folderIdcurl -X POST https://getplayflow.co/api/v1/videos/upload-init -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Idempotency-Key: upload-20260914-0001" -H "Content-Type: application/json" -d '{"title":"Demo","fileName":"demo.mp4","contentType":"video/mp4","contentLength":104857600}'201 VideoUploadInitialization
POST /v1/videos/{videoId}/upload-completevideoId, UploadSessionReferencecurl -X POST https://getplayflow.co/api/v1/videos/VIDEO_ID/upload-complete -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"idempotencyKey":"upload-20260914-0001"}'200 DeveloperVideoDetail, including both video domain lists
PATCH /v1/videos/{videoId}title, description, visibility, folder, playbackOptions, playbackExtraAllowedDomains, playbackBlockedDomains, identityOverrides, passwordcurl -X PATCH https://getplayflow.co/api/v1/videos/VIDEO_ID -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"playbackExtraAllowedDomains":["client.example"],"playbackBlockedDomains":["preview.client.example"]}'200 DeveloperVideoDetail
DELETE /v1/videos/{videoId}Path videoIdcurl -X DELETE https://getplayflow.co/api/v1/videos/VIDEO_ID -H "Authorization: Bearer pf_live_YOUR_SECRET"204 empty
POST /v1/foldersname, optional parentcurl -X POST https://getplayflow.co/api/v1/folders -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"name":"Marketing","parent":null}'201 DeveloperFolder tree node
PATCH /v1/folders/{folderId}name, optional parentcurl -X PATCH https://getplayflow.co/api/v1/folders/FOLDER_ID -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"name":"Product","parent":"fld_marketing"}'200 DeveloperFolder tree node
DELETE /v1/folders/{folderId}Path folderIdcurl -X DELETE https://getplayflow.co/api/v1/folders/FOLDER_ID -H "Authorization: Bearer pf_live_YOUR_SECRET"204 empty

PATCH /v1/workspace

Body: send any of playbackDomainMode (anywhere or only_these), playbackAllowedDomains, playbackBlockedDomains, and boolean playbackWatchEnabled. Each domain list accepts up to 100 hostnames, each at most 253 characters. Omitted fields are unchanged. Unknown fields are rejected. A successful update returns the DeveloperWorkspace response, including the effective playback domain configuration. See Where videos can play for domain matching and hosted watch-page behavior.

cURL
curl -X PATCH "$BASE/v1/workspace" -H "Authorization: Bearer pf_live_YOUR_SECRET" -H "Content-Type: application/json" -d '{"playbackDomainMode":"only_these","playbackAllowedDomains":["example.com"],"playbackBlockedDomains":[],"playbackWatchEnabled":true}'

PATCH /v1/videos/{videoId}

Body: send any of title, description, visibility, folder, playbackOptions, playbackExtraAllowedDomains, playbackBlockedDomains, identityOverrides, or write-only password (1 to 256 characters; null removes protection). The two domain lists accept up to 100 hostnames each, with a maximum length of 253 characters per hostname. Values are normalized, deduplicated, and sorted. The folder is a public folder ID or null. playbackOptions has nullable autoplay, loop, mode (standard or background; null inherits the workspace), rememberSound, startingQuality, titleMode, titleFadeSeconds, centerPlay, centerReplay, centerPlayPosition, centerReplayPosition, centerPlaySize, and centerReplaySize overrides. Size values are finite numbers from 8 to 40; null inherits the workspace value. See Player API appearance. startingQuality accepts auto, 360, 480, 720, 1080, 1440, or 2160; null inherits the workspace setting. titleMode accepts always, never, or timed; titleFadeSeconds is an integer from 1 to 30. centerPlay and centerReplay each accept auto, show, hide, or null to inherit the workspace default. Auto hides the relevant center button when a visible overlay intersects it; control-bar Play/Replay stays available. identityOverrides has nullable accent, progress, controlBackground, posterTreatment, barStyle, watermarkEnabled, logoEnabled, and logoLinkUrl values. A logo link must be an HTTPS URL of at most 2048 characters. Set an identity field to null to inherit only that field from the workspace.

cURL
curl -X PATCH https://getplayflow.co/api/v1/videos/VIDEO_ID \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Content-Type: application/json" \
   -d '{"title":"Updated tour","visibility":"public","folder":"fld_marketing","playbackExtraAllowedDomains":["client.example"],"playbackBlockedDomains":["preview.client.example"],"playbackOptions":{"titleMode":"timed","titleFadeSeconds":5},"identityOverrides":{"logoLinkUrl":"https://example.com/"}}'
200 OK
{
  "publicId": "vid_2Kd8",
  "title": "Updated tour",
  "description": "A short product tour",
  "metadata": {},
  "status": "ready",
  "visibility": "public",
  "durationSeconds": 92,
  "width": 1920,
  "height": 1080,
  "createdAt": "2026-09-08T14:02:11Z",
  "updatedAt": "2026-09-09T10:18:44Z",
  "folder": { "publicId": "fld_marketing", "name": "Marketing" },
  "shareUrl": "https://v.getplayflow.co/ws_2Kd8/vid_2Kd8",
  "embedUrl": "https://v.getplayflow.co/ws_2Kd8/e/vid_2Kd8",
  "thumbnailUrl": "https://v.getplayflow.co/api/public/videos/vid_2Kd8/thumbnail",
  "plays": 184,
   "completionRate": 0.72,
  "playbackExtraAllowedDomains": ["client.example"],
  "playbackBlockedDomains": ["preview.client.example"],
  "playbackOptions": { "autoplay": null, "loop": null, "rememberSound": null, "titleMode": "timed", "titleFadeSeconds": 5 },
  "effectivePlaybackOptions": { "autoplay": "off", "loop": false, "rememberSound": true, "titleMode": "timed", "titleFadeSeconds": 5 },
  "identityOverrides": { "accent": null, "progress": null, "controlBackground": null, "posterTreatment": null, "barStyle": null, "watermarkEnabled": null, "logoEnabled": null, "logoLinkUrl": "https://example.com/" },
  "effectiveIdentity": { "accent": "#3C6876", "progress": "#9FBDBC", "controlBackground": "#1B2F38", "posterTreatment": "default", "barStyle": "full", "watermarkEnabled": true, "logoEnabled": true, "logoLinkUrl": "https://example.com/" }
}

DELETE /v1/videos/{videoId}

Queues deletion and returns no response body.

cURL
curl -X DELETE https://getplayflow.co/api/v1/videos/VIDEO_ID \
  -H "Authorization: Bearer pf_live_YOUR_SECRET"
204 No Content
(empty response body)

POST /v1/folders

Body: required name and optional parent, a public folder ID or null.

cURL
curl -X POST https://getplayflow.co/api/v1/folders \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"name":"Marketing","parent":null}'
201 Created
{
  "publicId": "fld_marketing",
  "name": "Marketing",
  "parent": null,
  "children": [],
  "childFolderCount": 0,
  "videoCount": 0,
  "createdAt": "2026-09-14T09:00:00Z",
  "updatedAt": "2026-09-14T09:00:00Z"
}

PATCH /v1/folders/{folderId}

Path param: folderId. Body: optional name and parent. Omit a field to leave it unchanged; set parent to null to move the folder to the root.

cURL
curl -X PATCH https://getplayflow.co/api/v1/folders/FOLDER_ID \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"name":"Product","parent":"fld_marketing"}'
200 OK
{
  "publicId": "fld_product",
  "name": "Product",
  "parent": "fld_marketing",
  "children": [],
  "childFolderCount": 0,
  "videoCount": 4,
  "createdAt": "2026-09-01T09:00:00Z",
  "updatedAt": "2026-09-14T09:10:00Z"
}

DELETE /v1/folders/{folderId}

Deletes an empty folder and returns no response body.

cURL
curl -X DELETE https://getplayflow.co/api/v1/folders/FOLDER_ID \
  -H "Authorization: Bearer pf_live_YOUR_SECRET"
204 No Content
(empty response body)

GET /v1/folders: nested tree response

GET /v1/folders returns root nodes with recursive children. Each node uses a public folder ID; parent is the parent public ID or null.

POST/PATCH body: send name and, when nesting the folder, parent as a public folder ID or null.

cURL
curl "https://getplayflow.co/api/v1/folders" \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"
200 OK
[
  {
    "publicId": "fld_marketing",
    "name": "Marketing",
    "parent": null,
    "children": [{
      "publicId": "fld_product",
      "name": "Product",
      "parent": "fld_marketing",
      "children": [],
      "childFolderCount": 0,
      "videoCount": 4,
      "createdAt": "2026-09-01T09:00:00Z",
      "updatedAt": "2026-09-02T09:30:00Z"
    }],
    "childFolderCount": 1,
    "videoCount": 12,
    "createdAt": "2026-09-01T08:00:00Z",
    "updatedAt": "2026-09-02T09:30:00Z"
  }
]

GET /v1/analytics/overview

Query params: granularity=day is the default: from and to are inclusive UTC dates (YYYY-MM-DD), with a maximum 366-day range; omit both for the last 30 days. For granularity=hour, supply ISO datetime bounds covering at most 24 hours; from is inclusive and to is exclusive. videoIds accepts up to 100 comma-separated UUIDs and folderId accepts a UUID. Folder and video selections intersect; folders include direct videos only, not nested folders. threshold accepts 50, 75, 90 or 100 and defaults to the workspace setting, initially 90. includeTeam=true includes in-app playback; the default is false. source filters watch, embed, in-app, api or legacy unknown. Filtering to in-app still requires includeTeam=true.

Metric definitions: Percent watched is credited watch seconds divided by credible video duration, multiplied by 100. Average percent watched is the mean over plays, including zero-watch plays. A play completes only when its reached percentage meets the threshold and credited watch is at least half that threshold's duration. Completion rate is completions divided by plays. Play rate is plays divided by loads. Repeat watching can make percent watched exceed 100 and multiple plays can make play rate exceed 1.

totals and every daily timeline point include plays, viewers, loads, playRate, averagePercentWatched, completions, completionRate, reached, watchTimeSeconds, averageWatchSeconds and engagementCurve. reached has 25, 50, 75 and 100 keys, each a share of plays. The 100 one-percent engagement points contain percent, reachShare and meanPasses. sources groups metrics by source and embedHost. previousPeriod covers the preceding equal-length range. Each topVideos entry includes totals and a daily timeline for sorting and sparklines. definitionUnavailableDays identifies legacy video-days without recomputable retained data; excluded days are not evidence of zero activity. Viewer deduplication is limited by retained attestations, with legacy session fallback. Per-video reports also contain mediaVersions (server-attested file revision, duration, plays and one-second credited traversal bins) and append-only resetMarkers for Start fresh. All-time play, view and watch-time totals remain cumulative; old versions remain viewable. Workspace overviews return empty arrays for these per-video fields.

The authenticated app uses the same report definitions. GET /api/analytics/settings reads completionThreshold; PATCH /api/analytics/settings changes it with workspace.manage permission. App CSV exports under /api/analytics/overview/export and /api/analytics/videos/{videoId}/export accept the same filters and return one row per selected UTC day or hour bucket, including zero-activity buckets.

cURL
curl "https://getplayflow.co/api/v1/analytics/overview?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"
200 OK
{
  "from": "2026-09-01",
  "to": "2026-09-30",
  "totals": { "impressions": 1200, "plays": 840, "uniqueSessions": 720, "watchTimeSeconds": 68400, "completions": 510, "completionRate": 0.61, "averagePercentWatched": 73.5 },
  "timeline": [{ "date": "2026-09-01", "impressions": 40, "plays": 30, "watchTimeSeconds": 2400 }],
  "topVideos": [{ "publicId": "23456789AB", "title": "Onboarding tour", "plays": 184, "completionRate": 0.72, "watchTimeSeconds": 15800 }],
  "referrers": [{ "name": "Direct", "count": 500 }],
  "deviceSplit": [{ "name": "desktop", "count": 620 }]
}

GET /v1/analytics/videos/{videoId}

Path param: videoId is the owned public video ID. Use the same date, granularity, videoIds, folderId, threshold, source and includeTeam parameters as the overview. Filters intersect with the locked path video and cannot broaden its scope. The examples below show selected fields; the OpenAPI contract describes complete metric objects and arrays.

cURL
curl "https://getplayflow.co/api/v1/analytics/videos/VIDEO_ID?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer pf_read_YOUR_SECRET"
200 OK
{
  "publicId": "23456789AB",
  "from": "2026-09-01",
  "to": "2026-09-30",
  "totals": { "impressions": 220, "plays": 184, "uniqueSessions": 160, "watchTimeSeconds": 15800, "completions": 132, "completionRate": 0.72, "averagePercentWatched": 78.3 },
  "engagementHistogram": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  "timeline": [{ "date": "2026-09-01", "impressions": 10, "plays": 8, "watchTimeSeconds": 640 }],
  "referrers": [{ "name": "Direct", "count": 100 }],
  "deviceSplit": [{ "name": "desktop", "count": 140 }],
  "sessions": [{ "startedAt": "2026-09-08T14:03:00Z", "watchSeconds": 86, "maxPercent": 100, "completed": true, "rewatchCount": 1, "deviceType": "desktop", "browser": "Chrome", "os": "macOS", "referrer": "Direct" }]
}

GET /v1/analytics/overview/export

Returns 200 text/csv with the workspace timeline, including zero-activity buckets. It accepts the same from, to, granularity, videoIds, folderId, threshold, includeTeam and source filters as the JSON overview, with the same workspace access checks and rate limits. Each row represents a UTC day or hour according to granularity.

cURL
curl "https://getplayflow.co/api/v1/analytics/overview/export?from=2026-09-17T12:00:00Z&to=2026-09-18T12:00:00Z&granularity=hour" \
  -H "Authorization: Bearer pf_read_YOUR_SECRET" -o analytics.csv

GET /v1/analytics/videos/{videoId}/export

Returns 200 text/csv for the owned public video ID in the path. All overview export filters apply and intersect with that video; folder or video selections cannot expand the export to another video.

cURL
curl "https://getplayflow.co/api/v1/analytics/videos/VIDEO_ID/export?from=2026-09-01&to=2026-09-30&threshold=90" \
  -H "Authorization: Bearer pf_read_YOUR_SECRET" -o video-analytics.csv

GET /v1/members

GET /v1/members requires a write-scoped key and returns only the public member fields below. A read-scoped key returns 403 insufficient_scope. It does not expose internal IDs, groups or invitation metadata.

cURL
curl https://getplayflow.co/api/v1/members \
  -H "Authorization: Bearer pf_live_YOUR_SECRET"
200 OK
[
  {
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "role": "editor",
    "status": "active"
  }
]

Resumable uploads with TUS

Uploads use the TUS resumable upload protocol. Keep the application API key on your server. The upload endpoint and its required headers are short-lived credentials returned by initialization.

1. POST /v1/videos/upload-init

Header: Idempotency-Key, required, 16–128 characters. Body: title, fileName, contentType, contentLength in bytes, and optional description and folderId.

cURL
curl -X POST https://getplayflow.co/api/v1/videos/upload-init \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Idempotency-Key: upload-20260914-0001" \
  -H "Content-Type: application/json" \
  -d '{"title":"Demo","fileName":"demo.mp4","contentType":"video/mp4","contentLength":104857600,"description":"Demo upload","folderId":"FOLDER_ID"}'
201 Created
{
  "videoId": "video_2Kd8",
  "upload": {
    "kind": "tus",
    "endpoint": "https://upload.example/tus/...",
    "headers": { "Authorization": "..." },
    "expiresAt": "2026-09-14T15:00:00Z"
  }
}

2. Create and send TUS chunks

Use the returned endpoint and headers. Create the TUS resource with POST when the provider endpoint requires it, then send each chunk with PATCH. Include Tus-Resumable: 1.0.0, Upload-Length on creation, and the current Upload-Offset on every patch. On interruption, issue HEAD to read Upload-Offset and resume with that byte. Never restart from zero.

TUS create and resume
curl -X POST "$TUS_ENDPOINT" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Length: 104857600"

curl -I "$TUS_ENDPOINT" \
  -H "Tus-Resumable: 1.0.0"
TUS PATCH
curl -X PATCH "$TUS_ENDPOINT" \
  -H "Tus-Resumable: 1.0.0" \
  -H "Upload-Offset: 0" \
  -H "Content-Type: application/offset+octet-stream" \
  --data-binary @chunk-0001.bin

A successful chunk returns 204 and an Upload-Offset header. Continue until the offset equals contentLength. Treat expiresAt as a deadline and restart initialization if credentials expire.

3. POST /v1/videos/{videoId}/upload-complete

Path param: videoId from initialization. Body: idempotencyKey, the same key used for initialization. Call this only after TUS reports the complete length. 200 response: the video record in provider processing state.

cURL
curl -X POST https://getplayflow.co/api/v1/videos/video_2Kd8/upload-complete \
  -H "Authorization: Bearer pf_live_YOUR_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"idempotencyKey":"upload-20260914-0001"}'
200 OK
{
  "publicId": "vid_2Kd8",
  "title": "Demo",
  "description": "",
  "metadata": {},
  "status": "processing",
  "visibility": "private",
  "durationSeconds": null,
  "width": null,
  "height": null,
  "createdAt": "2026-09-14T09:00:00Z",
  "updatedAt": "2026-09-14T09:01:00Z",
  "folder": null,
  "shareUrl": null,
  "embedUrl": null,
  "thumbnailUrl": null,
  "plays": 0,
  "completionRate": 0,
  "playbackOptions": { "autoplay": null, "loop": null, "rememberSound": null, "titleMode": null, "titleFadeSeconds": null },
  "effectivePlaybackOptions": { "autoplay": "off", "loop": false, "rememberSound": true, "titleMode": "timed", "titleFadeSeconds": 5 },
  "identityOverrides": { "accent": null, "progress": null, "controlBackground": null, "posterTreatment": null, "barStyle": null, "watermarkEnabled": null, "logoEnabled": null, "logoLinkUrl": null },
  "effectiveIdentity": { "accent": "#3C6876", "progress": "#9FBDBC", "controlBackground": "#1B2F38", "posterTreatment": "default", "barStyle": "full", "watermarkEnabled": true, "logoEnabled": true, "logoLinkUrl": "https://example.com/" }
}

Adilo import selection

POST /api/integrations/adilo/imports

This workspace endpoint uses the signed-in Playflow session and requires a Fleet owner or admin. Developer Platform bearer keys do not authorize it.

Body: send projectId and optionally folderId, newFolderName, mirrorFolders, and fileIds. Choose either folderId or newFolderName, not both. fileIds is an optional non-empty array of unique Adilo file IDs. When fileIds is absent, Playflow imports every currently eligible file in the project. When it is present, Playflow imports only the selected eligible files.

Playflow re-runs the Adilo preview when this request starts. It does not trust an earlier dry run. If any selected ID is unknown or is no longer eligible, the entire request returns 400 and no import is queued.

Selected files body
{"projectId":"project_123","folderId":"8c82d28e-9089-4a57-a625-8ff9e92a84c4","mirrorFolders":false,"fileIds":["file_123","file_456"]}

Errors

JSON errors use one stable envelope. The nested error.code is for program logic and error.message is safe for logs and operator feedback.

400 Bad Request
{
  "error": {
    "code": "invalid_cursor",
    "message": "The cursor is invalid or expired"
  }
}
StatusTypical codeMeaning
400invalid_requestMalformed input or cursor.
401invalid_api_keyMissing, invalid, expired or revoked key.
403insufficient_scopeA read key attempted a write.
404workspace_not_foundResource is not available in this workspace.
409state conflictUpload or resource state prevents the operation.
429rate_limit_exceededWait for Retry-After.

Rate limits

Every authenticated API response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. The reset value is the number of seconds until the current one-minute window ends. A 429 also includes Retry-After with the number of seconds to wait. Use exponential backoff and honor the larger server value.

Versioning

The public version is part of the URL: /api/v1. The v1 contract is additive, so ignore unknown response fields. The OpenAPI file is the source of truth for paths and schemas.

Starting quality fields

Video playbackOptions.startingQuality is a nullable override: auto, 360, 480, 720, 1080, 1440, or 2160, or null to inherit. Resolved effectivePlaybackOptions.startingQuality contains one of the 7 non-null values. Starts here, then adjusts to the viewer's connection like Auto. Send the override inside playbackOptions in the video PATCH body. Existing values remain valid without migration. See embed.js options for per-embed overrides.

Version-separated analytics fields

A per-video analytics response includes mediaVersions and resetMarkers. Each media version includes mediaVersion, durationSeconds, seconds and plays. A reset marker includes mediaVersion and occurredAt. Workspace overview responses return empty arrays for these fields; use a per-video report for version-specific engagement. These fields support the File version and Before date views.