F4Stream.com API

Upload videos and manage your library from your own program - a script, a server, an app. Everything the website does with your videos, you can do here.

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, so curl -d is fine.
  • GET https://api.f4stream.com/v1 needs 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:readRead the account: plan, storage and traffic
videos:readList videos and folders
videos:writeRename, hide, clone videos and create folders
uploads:writeUpload videos
analytics:readRead views and earnings

Errors

Every failure comes back in one shape, with a status that says what to do about it.

Response 400
{"error": {"code": "invalid_request", "message": "Send at least one of: title, public, folder_id."}}
400invalid_requestSomething in the request is wrong.
401unauthorizedNo 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.
403forbiddenThe token lacks the scope for that call.
404not_foundNo such video, folder or endpoint.
409conflictNot possible in the current state: the video is still encoding, no storage is free, your plan is full.
429too_many_requestsToo fast. Wait as long as Retry-After says.
500server_errorOur fault, and already in our log. Try again.

Account

MethodPathScopeWhat 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
Response 200
{
  "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

MethodPathScopeWhat 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"
Response 200
{
  "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
Response 200
{
  "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
Response 200
{"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
Response 200
{"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 answers 404.
  • 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
Response 200
{"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.

OptionWhat 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

MethodPathScopeWhat 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
Response 200
{"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
Response 201
{"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

MethodPathScopeWhat 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
Response 201
{
  "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.

Response 201
{
  "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
Response 200
{"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

MethodPathScopeWhat 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
Response 201
{
  "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"
Response 200
{
  "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

MethodPathScopeWhat 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"
Response 200
{
  "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.

Questions?

Our team answers every message on a private ticket page.

Contact us