Videos API
This is the API reference for video resources. For an end-user-friendly walkthrough, see the Videos guide.
Video uploads return immediately with status: processing; transcoding (HLS variant generation, thumbnail extraction) happens asynchronously via Bunny.net (overseas) or Aliyun VOD (mainland). Poll GET /v1/videos/{id} until status: ready to get the playback URL.
Endpoint summary
| Method | Path | Auth | Tier | Description |
|---|---|---|---|---|
POST | /v1/videos | Bearer | upload | Upload a new video |
GET | /v1/videos | Bearer | read | List videos with cursor pagination |
GET | /v1/videos/{id} | Bearer (or public if isPublic) | read | Get one video’s metadata + playback URL |
PATCH | /v1/videos/{id} | Bearer | mutation | Update title / tags / isPublic / thumbnail |
DELETE | /v1/videos/{id} | Bearer | mutation | Delete a video and all transcoded variants |
GET | /v1/videos/{id}/status | Bearer | read | Lightweight status poll (returns just {status, progress}) |
POST | /v1/videos/{id}/thumbnail | Bearer (multipart) | mutation | Replace auto-thumbnail with a custom image |
The unified /v1/media endpoint (v0.12+) covers cross-type listing; videos can also be listed via that endpoint with ?type=video.
Upload
curl -X POST https://api.picora.me/v1/videos \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -F file=@trip.mp4 \ -F title="Italy 2026 trip" \ -F tags='["travel","2026"]' \ -F isPublic=trueRequest
| Field | Type | Required | Description |
|---|---|---|---|
file | binary (multipart) | yes | Video file. Accepted formats: MP4, MOV, MKV, WebM, AVI |
title | string | no | Display title (defaults to filename) |
tags | string[] (JSON) | no | Up to 10 tags, ≤ 32 chars each |
isPublic | boolean | no | Default true; false requires signed URLs to play |
Per-plan size and duration limits:
| Plan | Max file size | Max duration |
|---|---|---|
| pro | 500 MB | 30 minutes |
| pro_plus | 2 GB | 2 hours |
Video uploads require pro or pro_plus plan; trial accounts get 403 PLAN_REQUIRED.
Response (201, status=processing)
{ "success": true, "data": { "id": "abc123def45", "title": "Italy 2026 trip", "filename": "trip.mp4", "sizeBytes": 104857600, "status": "processing", "isPublic": true, "tags": ["travel", "2026"], "createdAt": "2026-04-27T08:00:00.000Z", "updatedAt": "2026-04-27T08:00:00.000Z" }}The response is sent as soon as the original file is stored, before transcoding finishes. Continue with status polling below.
Errors
| Code | Status | When |
|---|---|---|
PLAN_REQUIRED | 403 | Trial / none plan trying to upload video |
QUOTA_EXCEEDED (meta.type=media_storage) | 403 | Combined video+audio storage exhausted |
VALIDATION_ERROR | 422 | File format unsupported / file too large / duration too long / tags malformed |
RATE_LIMITED | 429 | Upload tier rate limit hit (60/min) |
List
curl "https://api.picora.me/v1/videos?limit=20&tag=travel" \ -H "Authorization: Bearer sk_live_YOUR_KEY"Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
cursor | string | "" | Pagination cursor; from previous nextCursor |
limit | int | 20 | 1-50 |
tag | string | — | Filter by tag (single, exact match) |
status | enum | — | processing / ready / failed; multiple via comma |
isPublic | bool | — | Filter by visibility |
Response
{ "success": true, "data": { "items": [ { "id": "abc123def45", "title": "Italy 2026 trip", "thumbnail_url": "https://media.picora.me/thumbnails/abc123def45.jpg", "duration_seconds": 245, "sizeBytes": 104857600, "status": "ready", "isPublic": true, "tags": ["travel", "2026"], "createdAt": "2026-04-27T08:00:00.000Z" } ], "nextCursor": "eyJpZCI6Il..." }}Note: playback_url is not included in list responses — only single-item GET /v1/videos/{id} includes it (it requires reading per-video edge config and adds latency).
Get one
curl https://api.picora.me/v1/videos/abc123def45 \ -H "Authorization: Bearer sk_live_YOUR_KEY"Response (status=ready)
{ "success": true, "data": { "id": "abc123def45", "title": "Italy 2026 trip", "filename": "trip.mp4", "sizeBytes": 104857600, "duration_seconds": 245, "playback_url": "https://video.picora.me/abc123def45/playlist.m3u8", "direct_url": "https://video.picora.me/abc123def45/original.mp4", "thumbnail_url": "https://media.picora.me/thumbnails/abc123def45.jpg", "status": "ready", "isPublic": true, "tags": ["travel", "2026"], "createdAt": "2026-04-27T08:00:00.000Z", "updatedAt": "2026-04-27T08:05:23.000Z" }}playback_url is the HLS .m3u8 playlist — the recommended way to embed. direct_url is the original MP4, useful for downloads but lacks adaptive bitrate.
Anonymous access (public videos)
If isPublic: true, GET /v1/videos/{id} works without the Authorization header. Use this for sharing links to non-authenticated viewers.
For private videos, generate a signed URL via /v1/videos/{id}/sign (TTL configurable, default 1 hour).
Status polling {#get-status}
Lightweight endpoint to poll transcoding progress without fetching full metadata:
curl https://api.picora.me/v1/videos/abc123def45/status \ -H "Authorization: Bearer sk_live_YOUR_KEY"Response
{ "success": true, "data": { "status": "processing", "progress": 0.42, "eta_seconds": 120 }}progress is 0.0–1.0. eta_seconds is best-effort, may be null if Bunny / Aliyun doesn’t report.
Poll every 5–10 seconds (don’t hammer; transcoding is minutes-scale). When status becomes ready, fetch full metadata via GET /v1/videos/{id} for the playback_url.
failed status
When transcoding fails (corrupt source, unsupported codec, etc.), status: "failed" with an error reason in the full metadata. The original file is retained for 7 days for support investigation, then auto-deleted.
Update
curl -X PATCH https://api.picora.me/v1/videos/abc123def45 \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated title", "isPublic": false, "tags": ["family"] }'Updatable fields: title / isPublic / tags. Cannot update file content (would require re-upload + new transcoding).
Replace thumbnail
curl -X POST https://api.picora.me/v1/videos/abc123def45/thumbnail \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -F file=@cover.jpgRecommended size: 1280 × 720 (16:9), JPEG / PNG / WebP, ≤ 2 MB. Custom thumbnails count against your image storage quota (typical impact <50 KB).
To revert to auto-extracted thumbnail (taken at the 5-second mark), DELETE /v1/videos/{id}/thumbnail.
Delete
curl -X DELETE https://api.picora.me/v1/videos/abc123def45 \ -H "Authorization: Bearer sk_live_YOUR_KEY"Effects:
- Database row removed from
med_media - Bunny.net / Aliyun VOD asked to delete all transcoded variants and original (asynchronous, may take minutes)
quota:media_storage:{userId}andquota:video_storage:{userId}decremented- Thumbnail file in object storage removed
- CDN cache purged (best-effort)
Deletion is permanent — there’s no trash / restore.
Bandwidth degradation behavior
When the user’s monthly video bandwidth quota is exhausted, playback (not the API) auto-degrades:
| Bandwidth used | Status | API behavior | Playback behavior |
|---|---|---|---|
| 0–100% | ok | normal | All quality variants served |
| 100–120% | degraded | normal API responses with degraded status flag | HLS playlist served only contains 360p tracks |
| > 120% | suspended | normal API responses with suspended status flag | playback_url returns HTTP 451 — server side; clients receive friendly JSON body |
This degradation is implemented at the playback edge, not the API layer. See Bandwidth & degradation for the user-facing perspective.
Webhooks
Bunny.net / Aliyun VOD send webhooks to /webhooks/bunny and /webhooks/aliyun-vod when transcoding completes. The Picora API verifies provider signatures, then:
- Sets
status: readyin the database - Populates
playback_urlandduration_seconds - Generates and stores the auto-thumbnail
- Increments
quota:video_storage:{userId}(storage was reserved at upload time; this is a no-op typically)
Webhook delivery failures are detected by a hourly reconciliation cron — see admin observability.
Related
- Videos guide (user-facing)
- Bandwidth & degradation
- Errors reference
- Plan comparison
- Media unified list (
/v1/media) — for cross-type queries