API v1 · for your own workflow

Send videos from any workflow.A real person uploads each one.

Create a key in your dashboard and POST a video with a link to its file. It joins your upload queue, a real person uploads it in YouTube Studio on time, and we check it once it’s live. Your workflow hears back by status or webhook.

Quick start →OpenAPI 3.1
BASE https://uploadly.io/api/v1
On this page

Quick startYour first video in three steps

  1. 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.
  2. 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.
  3. 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.
curl
# 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:

201 Created
{
  "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

Frontier

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.

uploadly API

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:

header
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_limited or daily_quota — with Retry-After (seconds). Every answer carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (unix time) and X-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 with 200 and Idempotent-Replayed: true — never a duplicate upload. The same external_id with other fields is a 409 idempotency_conflict (nothing changes; the answer’s video is the one it belongs to) — a new video needs a new external_id.
  • Errors have one shape: error (a sentence you can show), code (for your logic) and, for 422, fields — every field that is wrong and why. An unknown channel also lists your channels.
422 Unprocessable Entity
{
  "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" }
  ]
}
HTTPcodeWhen
401unauthorizedNo key, a wrong one, or a revoked one.
400bad_requestThe body is not JSON.
403service_offYour service is paused or canceled (reading, canceling a video and removing the webhook still work).
404not_foundNo such video on your account.
409already_started already_uploaded not_awaiting_parts nothing_to_retry send_again no_webhook too_lateNot possible in the state it is in — the message says why.
409idempotency_conflictThat external_id is already another video (in video), sent with other fields.
410upload_expiredParts were not completed within 7 days.
411 / 413length_required too_largeA 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).
422validation_failed unknown_channel no_parts missing_part uneven_parts parts_too_small parts_rejected bad_thumbnailSomething you sent is not right: see fields.
429rate_limited daily_quotaOver a minute or day limit (see Keys & limits): wait Retry-After seconds — and poll less.
429too_many_open_uploads10 videos already wait for their parts (or 30 are still being copied from links): complete or cancel one first.

VideosSend a video

POST/videos201 Created · the video

Only title and the file are required. Everything else falls back to your channel’s settings.

FieldWhat it is
channelWhich of your channels: its id (12), @handle, UC… id, youtube.com/@ link or its name. Optional when you have one channel. Your channels →
titleRequired. Up to 100 characters (YouTube’s limit), no < or >.
descriptionUp to 5,000 characters. Line breaks are kept.
tagsA list (["ocean", "shipwreck"]) or one comma-separated string. 500 characters together at most.
video_urlA 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…).
thumbnailsUp 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_contenttrue sets YouTube’s “altered or synthetic content” label (realistic AI). Default false.
made_for_kidsDefault false.
playlistA playlist name for this video (else the channel’s).
categorye.g. Education.
languageThe video’s language, e.g. en.
pinned_commentA comment your uploader posts and pins. Up to 4,000 characters.
notesAnything your uploader should know, shown on their card.
external_idYour own id, up to 200 characters. The same one again returns the same video.
Warnings come back in 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 (Range requests) — 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 isThe link to send
S3 / R2 / GCS / B2A presigned GET URL (expires in 24 h or more).
DropboxThe share link with ?dl=1 at the end (not ?dl=0).
Google DriveShared with “Anyone with the link”, then https://drive.usercontent.google.com/download?id=<file id>&export=download&confirm=t
Your own serverAny 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).

PUT/videos/{id}/parts/{n}raw bytes, n = 1, 2, 3…
POST/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 422 saying 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.
curl — in parts
# 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:

PUT/videos/{id}/thumbnails/{n}n = 1–3 · JPG, PNG or WebP · 2 MB
curl
curl -X PUT https://uploadly.io/api/v1/videos/vid_…/thumbnails/1 \
  -H "Authorization: Bearer $UPLOADLY_KEY" --data-binary @thumb-a.jpg

The 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.

publishWhat happens
left outThe 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?

GET/videos/{id}one video
GET/videos?status=&channel=&external_id=&limit=&cursor=newest first

The 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).

GET /videos/{id} — checked
{
  "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

fetching_file→waiting→uploading→scheduled/waiting_window→live→checked
statusMeansWebhook
fetching_fileWe are copying the file from your link, or waiting for your parts / thumbnails.—
waitingIn the queue. An uploader takes it in time for its slot.—
uploadingAn uploader is putting it up in YouTube Studio right now.—
scheduledUploaded and scheduled in Studio for its time.video.scheduled
waiting_windowUploaded as Private; made public by hand inside its window.video.scheduled
liveOn YouTube — public, or private / unlisted when you asked for that. The check follows within minutes.video.live
checkedWe 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)
problemSomething 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
canceledCanceled before an uploader started it. Its files are deleted.video.canceled

ChangesCancel & retry

DELETE/videos/{id}cancel — until an uploader starts it
POST/videos/{id}/retry{ "video_url"?, "thumbnails"? } — after a file problem

Cancel 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

GET/meyour account, plan, key and limits
GET/channelsid, name, handle, yt_id, publish_style, publish_time, window, tz
GET /channels
{
  "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

PUT/webhook{ "url" } → a new signing secret, shown once
GET/webhookthe URL and events
DELETE/webhookstop
POST/webhook/testa signed “ping” now
GET/webhook/deliveriesthe log: every try and your answer (30 days)
curl
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 answered

Events: 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.

what your server gets
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 in Uploadly-Delivery) to skip ones you already handled. Status changes are noticed within about 10 minutes.
  • A new PUT /webhook replaces 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.

Node.js (Express)
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);
Python (Flask)
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 "", 200

LimitsThe numbers

Limit
Requests60 a minute per key · 120 per account · 5,000 a day per key · 10,000 per account (UTC)
Pollingat most every 5 minutes a video — or a webhook
Video fileup to 20 GB · a real video file (checked by its first bytes)
Parts5–95 MB each (all but the last the same size, the last not bigger); complete within 7 days
Open at once10 videos waiting for parts · 30 being copied from links
Thumbnailsup to 3 · JPG, PNG or WebP · 2 MB each
Title · description100 · 5,000 characters
Tags500 characters together
Pinned comment · notes4,000 · 2,000 characters
external_id200 characters
Linkshttps, public, direct, with Content-Length; byte ranges over 1 GB; up to 5 redirects; valid for a day
Scheduled timeat least 2 h ahead
Filesdeleted 24 h after the upload
Webhooks2xx 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):

SettingValue
MethodPOST
URLhttps://uploadly.io/api/v1/videos
AuthenticationGeneric Credential Type → Header Auth → a credential with Name Authorization, Value Bearer upl_…
Send Bodyon · Body Content Type JSON · Specify Body Using JSON
JSONbelow — the expressions take fields from the item before
JSON (with n8n expressions)
{
  "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 is checked.
  • Map external_id to your row / run id: n8n retries then never upload twice.

RecipeMake

Add the HTTP → Make a request module:

SettingValue
URLhttps://uploadly.io/api/v1/videos
MethodPOST
HeadersName Authorization · Value Bearer upl_…
Body typeRaw · Content type JSON (application/json)
Request contentthe JSON from the n8n recipe, with Make’s mapped values in place of the {{ }} expressions
Parse responseYes — 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.

SettingValue
MethodPOST
URLhttps://uploadly.io/api/v1/videos
Datathe JSON body (map your fields into it)
HeadersAuthorization = 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

requests
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"])   # waiting

RecipeNode.js

Node 18+ (fetch built in)
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.

Join the waitlist