Uploading a video¶
Videos in PlusPlus are hosted natively — upload your file and PlusPlus handles adaptive streaming, watch-completion tracking, and engagement analytics for you. This is the recommended path for any video you own. (If a video already lives on an external host like YouTube or Vimeo, you can link to it instead, but you'll miss the native playback and analytics.)
Native videos use a three-call direct-upload flow: request an upload URL, push the file straight to storage, then poll until it's ready.
Flow at a glance¶
sequenceDiagram
participant Your app
participant PlusPlus API
participant Storage
Your app->>PlusPlus API: POST /videos/uploads/ { name }
PlusPlus API-->>Your app: 201 { upload_id, upload_url, video_id }
Your app->>Storage: PUT (binary upload)
Storage-->>Your app: 200
loop until ready or errored
Your app->>PlusPlus API: GET /videos/uploads/{upload_id}/status/
PlusPlus API-->>Your app: 200 { status, playback_url }
end
1. Request an upload URL¶
Two modes, depending on whether the video record already exists:
- Create-and-upload — pass
name. PlusPlus creates a hidden video record and returns an upload URL bound to it. It auto-unhides once processing finishes. - Attach to existing — pass
video_id. The upload replaces the source on that record (useful for batch workflows that create placeholders first).
curl -X POST https://acme.plusplus.app/api/v2/videos/uploads/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{ "name": "Q2 All-Hands Recording" }'
{
"upload_id": "BzQ8L7hY9JKLM01jw02HcXdYz3a4bC5dE",
"upload_url": "https://storage.googleapis.com/uploads/…",
"video_id": "01HXY8C5FZ7N4S0M2D3J6Q7K9P",
"expires_at": "2026-04-29T13:00:00Z",
"status": "awaiting_upload"
}
The upload_url is single-use, expires in ~1 hour, and does not carry your bearer token.
2. Upload the file directly¶
Your file goes straight to the video host — not through the PlusPlus API — so multi-GB uploads don't hit our gateway. Use a streaming/resumable upload for large files.
3. Poll until ready¶
curl https://acme.plusplus.app/api/v2/videos/uploads/BzQ8L7hY9JKLM01jw02HcXdYz3a4bC5dE/status/ \
-H "Authorization: Bearer pp_your_token_here"
{
"upload_id": "BzQ8L7hY9JKLM01jw02HcXdYz3a4bC5dE",
"status": "ready",
"playback_url": "https://stream.example.com/abc123.m3u8?token=eyJ…"
}
status |
Meaning |
|---|---|
awaiting_upload |
URL issued; the host hasn't received the file yet. |
processing |
File received; the host is transcoding (seconds to a few minutes). |
ready |
Done. playback_url (a signed URL) is populated. |
errored |
Processing failed. Surface message and request a new upload URL to retry. |
Poll every 3–5 seconds. There's no separate confirm call — PlusPlus links the record to the processed asset once ingest completes, and a create-and-upload video auto-unhides into the catalog.
playback_url is a signed URL valid for one hour — re-fetch status to refresh it. After the upload session expires (~1 hour), the status endpoint returns 404, but the video itself stays available via GET /videos/{video_id}/.
Headless videos and batch imports¶
You can create a video record with metadata only — no upload yet — and attach a source later by passing its video_id to POST /videos/uploads/. Headless records are created is_hidden: true so a placeholder isn't accidentally published. This is the idiomatic pattern for importing a content plan: create all the records first, then attach recordings in a second pass.
Watch-completion tracking¶
Native videos can auto-complete a learner's assignment once they've watched enough of the video. Set watch_completion_threshold (0.0–1.0) on the video; omit it to require manual completion. This is one of the reasons to host natively rather than link out.
Transcripts and captions¶
Native videos are captioned automatically, and you can manage the transcript text yourself — download it, correct it, and re-publish — via the /videos/{video_id}/transcripts/ endpoints. Editing a WebVTT or SubRip transcript also republishes the captions shown on the player. See Managing transcripts for the full round trip.
Alternative: linking an external video¶
If a video already lives on YouTube, Vimeo, or another external host, you can point a video record at it instead of uploading — no upload flow needed:
curl -X POST https://acme.plusplus.app/api/v2/videos/ \
-H "Authorization: Bearer pp_your_token_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Engineering Onboarding Intro",
"link": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}'
A video record is either natively hosted or externally linked, never both. Linked videos depend on the external host for playback and don't get native adaptive streaming, watch-completion tracking, or engagement analytics — prefer native hosting when the video is yours. To convert an external video to native later, call POST /videos/uploads/ with its video_id.
Common pitfalls¶
- The upload URL expired. Valid for ~1 hour. Request a new one and retry.
- Setting
linkon a natively hosted video. Rejected with400— a video is one or the other. - Polling forever. Long videos take minutes. Instead of looping on the status endpoint, subscribe to the
video.ready(andvideo.errored) webhooks to be notified the moment transcoding finishes. - Caching
playback_url. It expires hourly; re-fetch status when you need a fresh one.