Getting started
Create a token in Profile → API tokens and send it with every call. It is shown once, so copy it then.
curl -H "Authorization: Bearer f4s_…" https://api.f4stream.com/v1/me
- Base address:
https://api.f4stream.com/v1– the API has a domain of its own, separate from the website. JSON in, JSON out; ordinary form fields work too, socurl -dis fine. GET https://api.f4stream.com/v1needs no token and lists every endpoint.- Tokens are sent to the API host only. The website itself does not answer API calls.
- At most 120 calls a minute per token, and 5 tokens per account.
- A token works as your account, so keep it out of anything public. Revoke it the moment it leaks.
Scopes
You choose what a token may do when you create it. Asking for less is safer:
an upload script needs no more than uploads:write.
| account:read | Read the account: plan, storage and traffic |
| videos:read | List videos and folders |
| videos:write | Rename, hide, clone videos and create folders |
| uploads:write | Upload videos |
| analytics:read | Read views and earnings |
Errors
Every failure comes back in one shape, with a status that says what to do about it.
{"error": {"code": "invalid_request", "message": "Send at least one of: title, public, folder_id."}}
| 400 | invalid_request | Something in the request is wrong. |
| 401 | unauthorized | No token, a wrong one, a revoked or expired one, or the account is closed. The answer is the same for all of them on purpose. |
| 403 | forbidden | The token lacks the scope for that call. |
| 404 | not_found | No such video, folder or endpoint. |
| 409 | conflict | Not possible in the current state: the video is still encoding, no storage is free, your plan is full. |
| 429 | too_many_requests | Too fast. Wait as long as Retry-After says. |
| 500 | server_error | Our fault, and already in our log. Try again. |
Account
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /me | account:read | The account: plan, storage bought and used, traffic bought and used. |
Storage tells you what your plan includes, what you bought on top and what is in use;
traffic tells you what is left of the Premium Traffic you bought and how much went out in the last 30 days.
null anywhere means "no limit".
curl -H "Authorization: Bearer $TOKEN" https://api.f4stream.com/v1/me
{
"id": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b",
"username": "studio",
"email": "[email protected]",
"display_name": "Studio",
"created_at": "2026-09-01T08:00:00+00:00",
"plan": {"key": "premium_plus", "name": "Premium Plus", "premium": true, "expires_at": "2026-12-01T23:59:59+00:00"},
"storage": {"used_bytes": 812345678, "videos": 42, "included_bytes": 6442450944,
"bought_bytes": 2199023255552, "total_bytes": 2205465706496, "free_bytes": 2204653360818},
"traffic": {"enabled": true, "left_bytes": 1099511627776, "used_30d_bytes": 32212254720,
"ad_free_for_everyone": true},
"limits": {"max_height": 1080, "storage_days": null, "remote_uploads": 40,
"ad_free": true, "priority_support": true}
}
Videos
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /videos | videos:read | Your videos. page, limit (up to 200), q, folder_id, filter. |
| GET | /videos/{id} | videos:read | One video. |
| PATCH | /videos/{id} | videos:write | Rename (title), hide or show (public), move (folder_id). |
| POST | /videos/{id}/clone | videos:write | Add a video to your list: one of yours, or another account's public one when its owner allows cloning. {id} may be the code of its link. |
| DELETE | /videos/{id} | videos:write | Delete a video. |
Listing
Choose your own page size with limit (up to 200).
filter takes all, ready, processing or failed.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.f4stream.com/v1/videos?page=1&limit=100&filter=ready"
{
"data": [{"id": "…", "code": "a1b2c3", "title": "Sea", "state": "ready", "public": true,
"folder_id": 3, "size_bytes": 734003200, "duration_sec": 612, "width": 1920, "height": 1080,
"views": 128, "created_at": "2026-09-20T10:11:12+00:00",
"watch_url": "…/w/a1b2c3", "embed_url": "…/e/a1b2c3", "thumbnail_url": "…", "error": null}],
"paging": {"page": 1, "per_page": 100, "total": 42, "pages": 1}
}
state goes uploading → processing → ready, or failed.
One video
curl -H "Authorization: Bearer $TOKEN" https://api.f4stream.com/v1/videos/$ID
{
"id": "0199b7c1-2d3e-7f40-9a1b-2c3d4e5f6a7b",
"code": "k3x9qa7b",
"title": "Sea",
"description": "",
"state": "ready",
"public": true,
"folder_id": 3,
"size_bytes": 734003200,
"duration_sec": 612,
"width": 1920,
"height": 1080,
"views": 128,
"created_at": "2026-09-20T10:11:12+00:00",
"watch_url": "https://play.example.com/w/k3x9qa7b",
"embed_url": "https://play.example.com/e/k3x9qa7b",
"thumbnail_url": "https://…/thumbnail.jpg?…",
"error": null
}
error holds the reason when state is failed.
Every call that answers with a video (list, change, clone) uses this same shape.
Renaming, hiding, moving
One endpoint; send only the fields you want changed. POST works
for clients that cannot send PATCH.
curl -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"A better name"}' https://api.f4stream.com/v1/videos/$ID
curl -X PATCH -H "Authorization: Bearer $TOKEN" -d 'public=false' https://api.f4stream.com/v1/videos/$ID
curl -X PATCH -H "Authorization: Bearer $TOKEN" -d 'folder_id=3' https://api.f4stream.com/v1/videos/$ID
{"id": "0199b7c1-…", "code": "k3x9qa7b", "title": "A better name", "state": "ready", "public": true, …}
Deleting
curl -X DELETE -H "Authorization: Bearer $TOKEN" https://api.f4stream.com/v1/videos/$ID
{"deleted": true, "id": "0199b7c1-2d3e-7f40-9a1b-2c3d4e5f6a7b"}
Cloning to your account
Adds an existing video to your own list, subtitles included. Nothing is copied
byte for byte: one file is kept and your entry points at it, which is why it is instant. It still counts
towards your storage. {id} is the video's id or simply the code of its link
(…/w/a1b2c3).
- Your own videos: always.
- Another account's video: only a public one, and only when its owner turned on
Settings → Video & player → Cloning. Otherwise the answer is
403 forbidden("The owner of this video does not allow it to be cloned."); a private video answers404. - Cloning the same video twice gives back the clone you already have (
"created": false).
curl -X POST -H "Authorization: Bearer $TOKEN" -d 'title=My copy' \
https://api.f4stream.com/v1/videos/a1b2c3/clone
{"created": true, "id": "…", "code": "x9y8z7", "title": "My copy", "state": "ready", …}
Custom player
The player of embed_url can be adjusted from the address itself,
so each site that embeds a video may give it its own look. Add the options to the end of the URL.
| Option | What it does |
|---|---|
| poster | The picture shown in the player before the viewer presses play, instead of
the video's own thumbnail. An absolute https:// (or http://) address of an image,
up to 1000 characters. A value that is not such an address is ignored and the thumbnail stays. |
<iframe src="…/e/a1b2c3?poster=https://cdn001.imggle.net/cover-player.jpg"
width="640" height="360" frameborder="0" allowfullscreen></iframe>
When the image address has a query string of its own (? or &),
URL-encode it first, e.g. ?poster=https%3A%2F%2Fcdn.example.com%2Fcover.jpg%3Fv%3D2.
A wide image in the video's proportions (16:9 for most videos) fills the player best.
Folders
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /folders | videos:read | Your folders and how many videos each holds. |
| POST | /folders | videos:write | Create one (name). |
| PATCH | /folders/{id} | videos:write | Rename one. |
| DELETE | /folders/{id} | videos:write | Delete one. Its videos stay, they just leave the folder. |
curl -H "Authorization: Bearer $TOKEN" https://api.f4stream.com/v1/folders
{"data": [{"id": 3, "name": "Clips", "videos": 18}, {"id": 7, "name": "Holiday", "videos": 0}]}
curl -X POST -H "Authorization: Bearer $TOKEN" -d 'name=Holiday' https://api.f4stream.com/v1/folders
{"id": 7, "name": "Holiday", "videos": 0}
Renaming (PATCH /folders/{id} with name) answers
{"id": 7, "name": "Summer 2026"}; deleting answers {"deleted": true, "id": 7}.
Move a video into a folder with PATCH /videos/{id} and folder_id
(0 takes it out of every folder).
Uploading
| Method | Path | Scope | What it does |
|---|---|---|---|
| POST | /uploads | uploads:write | Start an upload; answers with the address to send the file to. |
| POST | /uploads/{id}/resign | uploads:write | A fresh ticket when a long upload outlives the first. |
| POST | /uploads/{id}/complete | uploads:write | The file is there; encoding starts. |
| DELETE | /uploads/{id} | uploads:write | Give up on an unfinished upload. |
The file never passes through the API: you ask for a ticket, then send the bytes straight to one of our storage machines over tus, exactly as the website does. A 40 GB file therefore never touches the web server.
1. Ask for a ticket
SHA=$(sha256sum video.mp4 | cut -d' ' -f1)
SIZE=$(stat -c%s video.mp4)
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d "{\"name\":\"video.mp4\",\"size\":$SIZE,\"fingerprint\":\"$SHA\"}" \
https://api.f4stream.com/v1/uploads
{
"duplicate": false,
"video_id": "…",
"link": "https://play.example.com/w/k3x9qa7b",
"endpoint": "https://st1.example.com/files/",
"headers": {"X-F4S-Upload-Token": "…"},
"metadata": {"filetype": "video/*", "title": "video", "media_id": "…"},
"expires_at": "2026-09-25T10:00:00+00:00",
"max_part_bytes": 16777216
}
fingerprint is the SHA-256 of the whole file. It is what makes a
second upload of the same video free: if we already have it, the answer is a duplicate and there is nothing
left to send. link is the video's share link in both cases.
{
"duplicate": true,
"video_id": "0199b7c1-2d3e-7f40-9a1b-2c3d4e5f6a7b",
"link": "https://play.example.com/w/k3x9qa7b",
"state": "ready",
"message": "This video already exists on the platform and has been added to your list – no upload needed."
}
An account may have 20 unfinished uploads at a time, counting the
website and the API together. Starting one more answers 429 until one of them is complete or
cancelled with DELETE /uploads/{id}; asking again for a file you already started (same
fingerprint) is always allowed, so it resumes.
2. Send the file
Use any tus client with the endpoint, headers and
metadata from step 1. Send it in parts of at most max_part_bytes (16 MB):
our storage hostnames sit behind a proxy that refuses a single request body over 100 MB.
If the transfer outlives expires_at, call /uploads/{id}/resign for a fresh ticket
and carry on from where you were.
3. Say it is done
curl -X POST -H "Authorization: Bearer $TOKEN" https://api.f4stream.com/v1/uploads/$VIDEO_ID/complete
{"state": "processing",
"video": {"id": "0199b7c1-…", "code": "k3x9qa7b", "title": "video", "state": "processing"}}
Then poll GET /videos/{id} until state is ready.
/uploads/{id}/resign answers with a fresh ticket in the shape of step 1;
DELETE /uploads/{id} answers {"cancelled": true}.
Uploading from a link
| Method | Path | Scope | What it does |
|---|---|---|---|
| POST | /uploads/remote | uploads:write | Send links and we fetch the files (urls). |
| GET | /uploads/remote | uploads:write | How those links are getting on. |
| DELETE | /uploads/remote/{id} | uploads:write | Stop one of them. |
Send links instead of bytes and we fetch each one for you. A direct link works, and so does the page of a file host you have an account for (Settings → File hosts).
curl -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"urls":["https://example.com/a.mp4","https://example.com/b.mp4"]}' \
https://api.f4stream.com/v1/uploads/remote
{
"queued": 2,
"uploads": [
{"id": 412, "url": "https://example.com/a.mp4", "status": "queued", "code": "m4p7r2wq",
"watch_url": "https://play.example.com/w/m4p7r2wq", "embed_url": "https://play.example.com/e/m4p7r2wq"},
{"id": 413, "url": "https://example.com/b.mp4", "status": "queued", "code": "h8d2k5vn",
"watch_url": "https://play.example.com/w/h8d2k5vn", "embed_url": "https://play.example.com/e/h8d2k5vn"}
],
"links": [ … your latest links, as GET /uploads/remote lists them … ]
}
The links are final the moment you send the URLs.
watch_url and embed_url can be published straight away: until the file is in, the page
says the video is still being uploaded, then it plays like any other. The code is kept for that link even if you
stop it and start it again.
Following them
curl -H "Authorization: Bearer $TOKEN" "https://api.f4stream.com/v1/uploads/remote?limit=20"
{
"data": [{
"id": 412, "url": "https://example.com/a.mp4",
"status": "downloading", "label": "Downloading", "open": true,
"size_bytes": 1073741824, "downloaded_bytes": 536870912, "percent": 50,
"title": "a", "video_id": "0199b7d2-…", "code": "m4p7r2wq",
"watch_url": "https://play.example.com/w/m4p7r2wq", "embed_url": "https://play.example.com/e/m4p7r2wq",
"last_error": null, "created_at": "2026-10-10T08:00:00+00:00"
}]
}
status goes queued → downloading →
done, or ends as failed (last_error says why) or cancelled.
Once done, the video follows the normal encoding: watch its state with
GET /videos/{video_id}. Stopping one: DELETE /uploads/remote/{id} answers
{"cancelled": true, "id": 412}.
Analytics
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /analytics | analytics:read | Views and earnings. range, or range=custom&from=&to=, and video_id. |
curl -H "Authorization: Bearer $TOKEN" \
"https://api.f4stream.com/v1/analytics?range=30d"
{
"range": {"key": "30d", "from": "2026-09-11", "to": "2026-10-10"},
"totals": {"views": 15230, "paid_views": 12904, "adblock_views": 1120, "proxy_views": 306,
"vip_views": 0, "earned_usd": 41.287, "quality_score": 85},
"by_day": [{"day": "2026-09-11", "views": 480, "paid": 402, "earned": 1.31}, …],
"countries": [{"country": "US", "views": 5210, "paid": 4890, "earned": 22.005}, …],
"referrers": [{"host": "myblog.example", "views": 9120}, {"host": "direct", "views": 2210}, …],
"videos": [{"video_id": "0199b7c1-…", "code": "k3x9qa7b", "title": "Sea", "deleted": false,
"views": 3120, "paid": 2980, "earned": 9.84}, …]
}
Up to 10 rows each in countries, referrers and
videos, the busiest first; by_day has every day of the range, zeros included.
range takes today, yesterday,
7d, 30d, 90d, 1y, or custom with
from and to as YYYY-MM-DD. The answer repeats the period it covers,
because a range that is too long or backwards is corrected rather than refused.