On this page
Quick startYour first video in three steps
- Create a key. In your uploadly dashboard → API keys → Create key. Name it after where it lives (“n8n”, “Make”). It is shown once — keep it like a password.
- Send a video. POST the channel, the title and a link to the file (a presigned S3 / R2 link, Dropbox, a Drive direct link…). We copy the file, then it joins your queue.
- Hear back. Read
GET /videos/{id}, or set a webhook: we tell your workflow when it is scheduled, live, checked — or if something needs a look.
# 1. your key (dashboard → API keys → Create key; it is shown once)
export UPLOADLY_KEY="upl_…"
# 2. send a video: a link to the file, the channel, the title, when it goes out
curl -X POST https://uploadly.io/api/v1/videos \
-H "Authorization: Bearer $UPLOADLY_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel": "@deepseafiles",
"title": "The Deepest Wreck Ever Found",
"description": "In 2019 a submarine reached…",
"tags": ["ocean", "shipwreck"],
"video_url": "https://my-bucket.s3.amazonaws.com/final.mp4?X-Amz-Signature=…",
"thumbnails": ["https://my-bucket.s3.amazonaws.com/thumb.jpg"],
"publish": { "at": "17:00" },
"external_id": "airtable-rec42"
}'
# 3. read its status (or set a webhook, below)
curl https://uploadly.io/api/v1/videos/vid_3a5dd5bbaeabc46297f9c9a6 -H "Authorization: Bearer $UPLOADLY_KEY"The answer is the video, fetching_file while we copy its file, then waiting in the queue:
{
"id": "vid_3a5dd5bbaeabc46297f9c9a6",
"external_id": "airtable-rec42",
"channel": { "id": 12, "name": "Deep Sea Files", "handle": "@deepseafiles" },
"title": "The Deepest Wreck Ever Found",
"status": "fetching_file",
"publish": {
"mode": "schedule",
"at": "2026-10-28T16:00:00Z",
"tz": "Europe/Prague",
"window": null,
"from_channel": false,
"style_fixed": false,
"deadline": "2026-10-28T14:00:00Z"
},
"youtube_url": null,
"checked": null,
"problem": null,
"issues": [],
"file": { "state": "copying", "name": "video.mp4", "size": null, "received": 0 },
"thumbnails": [{ "n": 1, "state": "copying" }],
"created": "2026-10-27T19:04:19Z",
"updated": "2026-10-27T19:04:19Z"
}Two ways inBuilt into Frontier — or from anything else
Built in, one click
Add Send to uploadly after Make the video in a Frontier workflow and connect it with the address and key from your dashboard. No API, no code.
Your own workflow
n8n, Make, Zapier, your own scripts, a GPT action — anything that can make an HTTP request. Same queue, same uploaders, same checks.
Both land in the same queue: your uploader sees an API video exactly like one Frontier sent — the file, the thumbnails, the title, the description, the time — and works it the same way.
AuthenticationKeys & rate limits
Send your key in the Authorization header on every call:
Authorization: Bearer upl_…- Make as many keys as you like (up to 20), one per tool, each with a name. The dashboard lists them by name with the first characters (
upl_z6YR…) and when each was last used. - A key is shown once, when it is made. We keep only a fingerprint of it, so we cannot show it again — make a new one and revoke the old.
- Revoke a key in the dashboard: it stops working at once (
401). - 60 requests a minute per key, 120 per account (all your keys together); 5,000 a day per key, 10,000 per account (UTC days). Above that you get
429—rate_limitedordaily_quota— withRetry-After(seconds). Every answer carriesX-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset(unix time) andX-RateLimit-Day-Remaining. - Poll at most every 5 minutes — a video changes a few times a day, not a minute. Better: set a webhook and we call you; no polling at all.
- While your service is paused or canceled, keys still read (and can cancel a video or remove the webhook); everything else gets
403 service_off.
BasicsJSON in, JSON out
- Base URL
https://uploadly.io/api/v1. Bodies are JSON (Content-Type: application/json), except file parts and thumbnails, which are raw bytes. - Every time we answer is ISO 8601 in UTC (
2026-10-28T16:00:00Z). What you send can be ISO, unix seconds or a clock time — see When it goes out. - Safe retries: send your own
external_id(your Airtable record, your run id). The same request again returns the same video with200andIdempotent-Replayed: true— never a duplicate upload. The sameexternal_idwith other fields is a409 idempotency_conflict(nothing changes; the answer’svideois the one it belongs to) — a new video needs a newexternal_id. - Errors have one shape:
error(a sentence you can show),code(for your logic) and, for422,fields— every field that is wrong and why. An unknown channel also lists yourchannels.
{
"error": "No channel “Ocean” on your account.",
"code": "unknown_channel",
"fields": {
"channel": "No channel “Ocean” on your account.",
"title": "YouTube does not allow < or > in a title."
},
"channels": [
{ "id": 12, "name": "Deep Sea Files", "handle": "@deepseafiles" },
{ "id": 14, "name": "Cold War Stories", "handle": "@coldwarstories" }
]
}| HTTP | code | When |
|---|---|---|
| 401 | unauthorized | No key, a wrong one, or a revoked one. |
| 400 | bad_request | The body is not JSON. |
| 403 | service_off | Your service is paused or canceled (reading, canceling a video and removing the webhook still work). |
| 404 | not_found | No such video on your account. |
| 409 | already_started already_uploaded not_awaiting_parts nothing_to_retry send_again no_webhook too_late | Not possible in the state it is in — the message says why. |
| 409 | idempotency_conflict | That external_id is already another video (in video), sent with other fields. |
| 410 | upload_expired | Parts were not completed within 7 days. |
| 411 / 413 | length_required too_large | A part or thumbnail without Content-Length, or too big: a part over 95 MB, a video over 20 GB, a JSON body over 256 KB (64 KB elsewhere). |
| 422 | validation_failed unknown_channel no_parts missing_part uneven_parts parts_too_small parts_rejected bad_thumbnail | Something you sent is not right: see fields. |
| 429 | rate_limited daily_quota | Over a minute or day limit (see Keys & limits): wait Retry-After seconds — and poll less. |
| 429 | too_many_open_uploads | 10 videos already wait for their parts (or 30 are still being copied from links): complete or cancel one first. |
VideosSend a video
/videos201 Created · the videoOnly title and the file are required. Everything else falls back to your channel’s settings.
| Field | What it is |
|---|---|
channel | Which of your channels: its id (12), @handle, UC… id, youtube.com/@ link or its name. Optional when you have one channel. Your channels → |
title | Required. Up to 100 characters (YouTube’s limit), no < or >. |
description | Up to 5,000 characters. Line breaks are kept. |
tags | A list (["ocean", "shipwreck"]) or one comma-separated string. 500 characters together at most. |
video_url | A direct https link to the video file — we copy it. What works → |
file | "upload" instead of video_url: you send the file to us in parts. Add file_name to keep its extension (.mov, .webm…). |
thumbnails | Up to 3 https image links, or "upload" for each one you will PUT. 2–3 = YouTube’s Test & compare. JPG, PNG or WebP, 2 MB each. |
publish | { "mode", "at", "tz" }. Leave it out and the video takes the channel’s next free slot. Details → |
altered_content | true sets YouTube’s “altered or synthetic content” label (realistic AI). Default false. |
made_for_kids | Default false. |
playlist | A playlist name for this video (else the channel’s). |
category | e.g. Education. |
language | The video’s language, e.g. en. |
pinned_comment | A comment your uploader posts and pins. Up to 4,000 characters. |
notes | Anything your uploader should know, shown on their card. |
external_id | Your own id, up to 200 characters. The same one again returns the same video. |
warnings without stopping anything — an unknown field we ignored, or a channel that has not invited us yet. A scheduled time less than 2 h away is not a warning but a 422: the uploader could not have it in Studio in time.The fileBy link, or in parts
By link — video_url
We copy the file straight into our storage, in the background — your workflow does not wait. Status fetching_file, then waiting. The link must:
- be https, on a public host, and direct: it answers with the file itself, not a web page (we stop with a clear problem if it does). Up to 5 redirects are fine, each one https and public too.
- say how big the file is (
Content-Length). Files over 100 MB are copied in byte ranges (Rangerequests) — S3, R2, GCS and Dropbox links answer them, as do most download servers. Over 1 GB the link must answer them; one that does not is refused with a clear problem. - be a video — MP4, MOV, WebM, MKV, AVI, MPEG, WMV or FLV. We look at its first bytes, not its name: a ZIP or a picture stops with a problem.
- stay valid for a day. We copy at once; if the host is down we try again for about 5 hours. A host that keeps cutting us off without getting further (6 times in a row) stops with a problem — for big files use a presigned S3 / R2 / GCS link, or send the file in parts.
At most 30 of your videos are copied at a time; send more when some are in.
| Where the file is | The link to send |
|---|---|
| S3 / R2 / GCS / B2 | A presigned GET URL (expires in 24 h or more). |
| Dropbox | The share link with ?dl=1 at the end (not ?dl=0). |
| Google Drive | Shared with “Anyone with the link”, then https://drive.usercontent.google.com/download?id=<file id>&export=download&confirm=t |
| Your own server | Any plain https download link. |
If a link fails, the video goes to problem with the reason in problem.message (“The video link refused us (HTTP 403) — is it private or expired?”). Fix it and retry, from a new link if you like.
In parts — file: "upload"
For a file on your computer or server. The answer carries upload.parts_url and upload.complete_url — use them as given (they point straight at our upload servers).
/videos/{id}/parts/{n}raw bytes, n = 1, 2, 3…/videos/{id}/completeput it together- Every part but the last is the same size, between 5 MB and 95 MB (64 MB is a good choice). The last may be smaller. A small file is one part.
- Send
Content-Length. Parts can go in parallel, and a part sent again replaces the old one. - Complete within 7 days. A missing or uneven part (or a last part bigger than the others) gets a
422saying which. The whole file is at most 20 GB. - At most 10 of your videos wait for their parts at a time. On complete we check it is a video by its first bytes.
# 1. ask for an upload
curl -X POST https://uploadly.io/api/v1/videos -H "Authorization: Bearer $UPLOADLY_KEY" -H "Content-Type: application/json" \
-d '{"channel": "@deepseafiles", "title": "The Deepest Wreck Ever Found", "file": "upload", "file_name": "final.mp4"}'
# → "upload": { "parts_url": "https://…/api/v1/videos/vid_…/parts/{n}", "complete_url": "https://…/complete", … }
# 2. send the file in 64 MB parts (every part but the last the same size, 5–95 MB)
split -b 64m final.mp4 part_
n=1; for f in part_*; do
curl -sf -T "$f" -H "Authorization: Bearer $UPLOADLY_KEY" "https://…/api/v1/videos/vid_…/parts/$n" || exit 1
n=$((n+1))
done
# 3. put it together → status "waiting" (in the queue)
curl -X POST "https://…/api/v1/videos/vid_…/complete" -H "Authorization: Bearer $UPLOADLY_KEY"Thumbnails
Links in thumbnails are copied with the video. For "upload" ones — or to change one later, until an uploader starts the video:
/videos/{id}/thumbnails/{n}n = 1–3 · JPG, PNG or WebP · 2 MBcurl -X PUT https://uploadly.io/api/v1/videos/vid_…/thumbnails/1 \
-H "Authorization: Bearer $UPLOADLY_KEY" --data-binary @thumb-a.jpgThe video joins the queue once its file and every thumbnail are in.
PublishingWhen it goes out
Each channel has a publishing style, set in your dashboard: scheduled in YouTube Studio for its time, or made public by hand inside a window (e.g. between 12:00 and 17:00) — uploaded as Private first. publish follows it unless you say otherwise.
| publish | What happens |
|---|---|
| left out | The channel’s next free slot: its publish time (e.g. 17:00 in its time zone) on the first day at least 3 h ahead with room left (one video per slot a day), in the channel’s style. |
{ "at": … } | At that time, in the channel’s style. If you later switch the channel’s style, this video follows. |
{ "mode": "schedule", "at": … } | Scheduled in Studio for exactly that time — whatever the channel’s style. The uploader is done 2 h before, so the time must be at least 2 h ahead. |
{ "mode": "by_hand", "at": … } | Uploaded as Private, made public by hand inside the channel’s window around that time. publish.window says from when to when. |
{ "mode": "now" } | Public as soon as it is up (within 12 h). |
{ "mode": "private" } "unlisted" | Uploaded and left private / unlisted (within 24 h). |
at takes any of these — tz (an IANA zone like America/New_York) only matters when the time has no offset; it defaults to the channel’s zone:
- "2026-10-28T17:00:00+01:00"
- ISO 8601 with an offset (or
Z). - "2026-10-28T17:00"
- ISO without an offset: 17:00 in
tz. - "2026-10-28"
- A date: the channel’s publish time that day.
- "17:00"
- The next 17:00 in
tz(today, or tomorrow when it has passed — for a scheduled video, when it is less than 2 h away). - 1793289600
- Unix seconds (milliseconds work too).
When the file comes late
The time counts from when the file is in, not when you sent the request. If it arrives too late for its time (scheduled: less than 2 h 30 min before it; by hand: less than 30 min before the window closes), we move the video instead of missing the slot — and tell you in publish.moved and with the video.moved webhook:
| The time was… | Then |
|---|---|
the channel’s next free slot (no publish) | It takes the next free slot from now. |
| yours, and it has passed (or is minutes away) | It goes up as soon as possible instead (mode becomes now; publish.moved.to is null). |
| yours, still at least 30 min ahead (scheduled) | It stays; the uploader does it in the time left. publish.late_file is true. |
StatusWhere is my video?
/videos/{id}one video/videos?status=&channel=&external_id=&limit=&cursor=newest firstThe list takes any status below, a channel (id, @handle or name), your external_id, a limit (1–100, default 25), and cursor = the next_cursor of the page before (null on the last page).
{
"id": "vid_3a5dd5bbaeabc46297f9c9a6",
"status": "checked",
"youtube_url": "https://youtu.be/Xb7hGq2LmT0",
"checked": {
"public": true,
"channel_ok": true,
"title_ok": true,
"public_at": "2026-10-28T16:00:41Z",
"at": "2026-10-28T16:10:02Z"
},
"problem": null,
…
}checked is the last automatic check on YouTube: public, on the right channel (by its UC id), under the title you sent, and when it went public. problem is set when the status is problem; issues lists anything else still open.
StatusesEvery status, in order
| status | Means | Webhook |
|---|---|---|
| fetching_file | We are copying the file from your link, or waiting for your parts / thumbnails. | — |
| waiting | In the queue. An uploader takes it in time for its slot. | — |
| uploading | An uploader is putting it up in YouTube Studio right now. | — |
| scheduled | Uploaded and scheduled in Studio for its time. | video.scheduled |
| waiting_window | Uploaded as Private; made public by hand inside its window. | video.scheduled |
| live | On YouTube — public, or private / unlisted when you asked for that. The check follows within minutes. | video.live |
| checked | We checked it on YouTube: public (as asked), on the right channel, the right title, at the right time. | video.checked (with video.live first if it was not sent) |
| problem | Something needs a look. problem.code file is yours to fix (a link that failed); the rest — access, a claim, a late upload — are ours, and we tell you. | video.problem |
| canceled | Canceled before an uploader started it. Its files are deleted. | video.canceled |
ChangesCancel & retry
/videos/{id}cancel — until an uploader starts it/videos/{id}/retry{ "video_url"?, "thumbnails"? } — after a file problemCancel works while it is waiting or still fetching its file; once an uploader started it you get 409 — write to us. Retry copies the file again after a link problem, from new links if you send them. Want to send a canceled video again? Use a new external_id.
AccountAccount & channels
/meyour account, plan, key and limits/channelsid, name, handle, yt_id, publish_style, publish_time, window, tz{
"channels": [
{
"id": 12, "name": "Deep Sea Files", "handle": "@deepseafiles", "yt_id": "UCq1…",
"publish_style": "by_hand", "publish_time": "17:00",
"window": { "from": "12:00", "to": "17:00" }, "tz": "Europe/Prague",
"per_day": 1, "access": "ok", "playlist": null
}
]
}Channels are added and set up in your dashboard (style, time, window). access is ok once we accepted your YouTube invitation — before that, videos wait.
WebhooksWe call you when it changes
/webhook{ "url" } → a new signing secret, shown once/webhookthe URL and events/webhookstop/webhook/testa signed “ping” now/webhook/deliveriesthe log: every try and your answer (30 days)curl -X PUT https://uploadly.io/api/v1/webhook -H "Authorization: Bearer $UPLOADLY_KEY" \
-H "Content-Type: application/json" -d '{"url": "https://n8n.example.com/webhook/uploadly"}'
# → { "url": "…", "secret": "whsec_…", "events": [ … ], "shown_once": true }
curl -X POST https://uploadly.io/api/v1/webhook/test -H "Authorization: Bearer $UPLOADLY_KEY" # a signed "ping" now
curl https://uploadly.io/api/v1/webhook/deliveries -H "Authorization: Bearer $UPLOADLY_KEY" # what we sent, what you answeredEvents: video.scheduled, video.live, video.checked, video.problem, video.canceled — and video.moved when a late file moved its time (see above). Each is a POST with JSON — data.video is the same object GET /videos/{id} returns. A webhook is the way to hear back: no polling, no requests used.
POST /webhook/uploadly HTTP/1.1
Content-Type: application/json
Uploadly-Event: video.live
Uploadly-Delivery: evt_q3Xc1y0bTt9mN2aK
Uploadly-Signature: t=1793289641,v1=5f2b0c9e…a41d
{
"id": "evt_q3Xc1y0bTt9mN2aK",
"type": "video.live",
"created": "2026-10-28T16:00:41Z",
"data": {
"video": { "id": "vid_3a5dd5bbaeabc46297f9c9a6", "status": "live", "youtube_url": "https://youtu.be/Xb7hGq2LmT0", … }
}
}- Answer any 2xx within 10 seconds. Do slow work after answering.
- Otherwise we try again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h — then the delivery is marked failed. Redirects are not followed: give the final URL.
- An event can come twice: use its
id(also inUploadly-Delivery) to skip ones you already handled. Status changes are noticed within about 10 minutes. - A new
PUT /webhookreplaces the URL and the secret.
SecurityCheck the signature
Every webhook carries Uploadly-Signature: t=<unix time>,v1=<signature>. The signature is the hex HMAC-SHA256 of <t>.<raw body> with your whsec_… secret. Compute it over the raw body, compare in constant time, and reject anything older than 5 minutes.
import crypto from 'node:crypto';
import express from 'express';
// t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> with your whsec_ secret
function verify(secret, rawBody, header, tolerance = 300) {
const parts = Object.fromEntries(String(header).split(',').map(p => p.split('=')));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return want.length === parts.v1.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1));
}
const app = express();
// the RAW body: a parsed and re-serialised JSON would not match the signature
app.post('/webhook/uploadly', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(process.env.UPLOADLY_WEBHOOK_SECRET, req.body.toString('utf8'), req.get('uploadly-signature'))) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
if (event.type === 'video.live') console.log('Live:', event.data.video.youtube_url);
res.sendStatus(200); // answer within 10 s — do slow work after this
});
app.listen(3000);import hashlib, hmac, json, os, time
from flask import Flask, request, abort
def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/webhook/uploadly")
def uploadly():
# request.get_data(): the raw bytes, exactly as signed
if not verify(os.environ["UPLOADLY_WEBHOOK_SECRET"], request.get_data(), request.headers.get("Uploadly-Signature")):
abort(400)
event = json.loads(request.get_data())
if event["type"] == "video.problem":
print("Problem:", event["data"]["video"]["problem"]["message"])
return "", 200LimitsThe numbers
| Limit | |
|---|---|
| Requests | 60 a minute per key · 120 per account · 5,000 a day per key · 10,000 per account (UTC) |
| Polling | at most every 5 minutes a video — or a webhook |
| Video file | up to 20 GB · a real video file (checked by its first bytes) |
| Parts | 5–95 MB each (all but the last the same size, the last not bigger); complete within 7 days |
| Open at once | 10 videos waiting for parts · 30 being copied from links |
| Thumbnails | up to 3 · JPG, PNG or WebP · 2 MB each |
| Title · description | 100 · 5,000 characters |
| Tags | 500 characters together |
| Pinned comment · notes | 4,000 · 2,000 characters |
| external_id | 200 characters |
| Links | https, public, direct, with Content-Length; byte ranges over 1 GB; up to 5 redirects; valid for a day |
| Scheduled time | at least 2 h ahead |
| Files | deleted 24 h after the upload |
| Webhooks | 2xx within 10 s · 8 tries over 2 days · log kept 30 days |
Recipen8n
Add an HTTP Request node after the step that gives you the file link (S3, Drive, your render):
| Setting | Value |
|---|---|
| Method | POST |
| URL | https://uploadly.io/api/v1/videos |
| Authentication | Generic Credential Type → Header Auth → a credential with Name Authorization, Value Bearer upl_… |
| Send Body | on · Body Content Type JSON · Specify Body Using JSON |
| JSON | below — the expressions take fields from the item before |
{
"channel": "@deepseafiles",
"title": "{{ $json.title }}",
"description": "{{ $json.description }}",
"tags": {{ JSON.stringify($json.tags) }},
"video_url": "{{ $json.video_url }}",
"thumbnails": ["{{ $json.thumbnail_url }}"],
"publish": { "mode": "schedule", "at": "{{ $json.publish_at }}", "tz": "Europe/Prague" },
"altered_content": true,
"external_id": "{{ $json.id }}"
}- Hear back: a Webhook node (POST, Respond: Immediately) — put its Production URL into
PUT /webhook. To check the signature, turn on Raw Body in its options and verify in a Code node with the Node.js function. - Or poll: Wait 10 min → HTTP Request
GET https://uploadly.io/api/v1/videos/{{ $json.id }}→ IF status ischecked. - Map
external_idto your row / run id: n8n retries then never upload twice.
RecipeMake
Add the HTTP → Make a request module:
| Setting | Value |
|---|---|
| URL | https://uploadly.io/api/v1/videos |
| Method | POST |
| Headers | Name Authorization · Value Bearer upl_… |
| Body type | Raw · Content type JSON (application/json) |
| Request content | the JSON from the n8n recipe, with Make’s mapped values in place of the {{ }} expressions |
| Parse response | Yes — the next modules get id, status, publish.at… |
Hear back with Webhooks → Custom webhook: copy its address into PUT /webhook, then route on type (video.checked, video.problem…). Turn on Get request headers if you check the signature.
RecipeZapier
Action: Webhooks by Zapier → Custom Request.
| Setting | Value |
|---|---|
| Method | POST |
| URL | https://uploadly.io/api/v1/videos |
| Data | the JSON body (map your fields into it) |
| Headers | Authorization = Bearer upl_… · Content-Type = application/json |
Trigger on our events with Webhooks by Zapier → Catch Hook (or Catch Raw Hook to get the raw body and headers for the signature), and set its URL with PUT /webhook.
RecipePython
import os, requests
API = "https://uploadly.io/api/v1"
H = {"Authorization": f"Bearer {os.environ['UPLOADLY_KEY']}"}
# by link: we copy the file ourselves
r = requests.post(f"{API}/videos", headers=H, json={
"channel": "@deepseafiles",
"title": "The Deepest Wreck Ever Found",
"video_url": "https://my-bucket.s3.amazonaws.com/final.mp4?X-Amz-Signature=…",
"publish": {"mode": "schedule", "at": "2026-10-28T17:00:00", "tz": "Europe/Prague"},
"external_id": "wreck-001", # safe to retry: the same id returns the same video
})
if r.status_code == 422:
print(r.json()["fields"]) # which field, and why
r.raise_for_status()
video = r.json()
print(video["id"], video["status"])
# from a file on this computer: in 64 MB parts
def send_file(path: str, **fields) -> dict:
v = requests.post(f"{API}/videos", headers=H,
json={**fields, "file": "upload", "file_name": os.path.basename(path)}).json()
up, n = v["upload"], 1
with open(path, "rb") as f:
while chunk := f.read(64 * 1024 * 1024):
requests.put(up["parts_url"].replace("{n}", str(n)), headers=H, data=chunk).raise_for_status()
n += 1
return requests.post(up["complete_url"], headers=H).json()
print(send_file("final.mp4", channel="@deepseafiles", title="The Deepest Wreck Ever Found")["status"]) # waitingRecipeNode.js
import { open } from 'node:fs/promises';
import { basename } from 'node:path';
const API = 'https://uploadly.io/api/v1';
const H = { authorization: `Bearer ${process.env.UPLOADLY_KEY}` };
const PART = 64 * 1024 * 1024;
async function api(method, path, body) {
const r = await fetch(path.startsWith('http') ? path : API + path, {
method, headers: body ? { ...H, 'content-type': 'application/json' } : H, body: body && JSON.stringify(body),
});
const data = await r.json();
if (!r.ok) throw Object.assign(new Error(data.error), data); // data.code, data.fields
return data;
}
// by link
const video = await api('POST', '/videos', {
channel: '@deepseafiles', title: 'The Deepest Wreck Ever Found',
video_url: 'https://my-bucket.s3.amazonaws.com/final.mp4?X-Amz-Signature=…',
publish: { at: '17:00' }, external_id: 'wreck-001',
});
// from a file, in parts
async function sendFile(file, fields) {
const v = await api('POST', '/videos', { ...fields, file: 'upload', file_name: basename(file) });
const fh = await open(file);
const { size } = await fh.stat();
for (let n = 1, off = 0; off < size; n++, off += PART) {
const buf = Buffer.alloc(Math.min(PART, size - off));
await fh.read(buf, 0, buf.length, off);
const r = await fetch(v.upload.parts_url.replace('{n}', n), { method: 'PUT', headers: H, body: buf });
if (!r.ok) throw new Error((await r.json()).error);
}
await fh.close();
return api('POST', v.upload.complete_url);
}
// wait for it (or use a webhook)
for (;;) {
const v = await api('GET', `/videos/${video.id}`);
if (['checked', 'problem', 'canceled'].includes(v.status)) { console.log(v.status, v.youtube_url, v.problem); break; }
await new Promise(r => setTimeout(r, 5 * 60 * 1000));
}Machine-readableOpenAPI & GPT actions
The whole API as OpenAPI 3.1, no key needed: https://uploadly.io/api/v1/openapi.json. Import it into Postman, Insomnia, n8n or your code generator.
A GPT that schedules your videos: in a custom GPT → Actions → Import from URL → paste the address above; Authentication API Key, Auth Type Bearer, your upl_… key. Every call has an operationId (createVideo, getVideo, listChannels…).
Using Frontier? It’s built in — no API needed.
Add Send to uploadly after Make the video in your Frontier workflow and connect it with the address and key from your dashboard. Frontier sends the file, the thumbnails and the time; your uploader does the rest.