Working example
This is the acceptance scenario of the API, the one that must run end to end against the live server: a video from disk becomes a scheduled YouTube post, and the script waits until it is live or failed. The same six calls, in three languages; pick the tab you write in. Response codes and bodies are the ones in openapi.json.
Before you start
Section titled “Before you start”- An API key with
writein the environment asDROPSLATE_API_KEY. - A connected YouTube channel (
GET /accountsshows its id andstatus: "ok"). - A video file,
launch.mp4, within YouTube’s limits (Media requirements).
The six calls
Section titled “The six calls”| # | Call | Answer |
|---|---|---|
| 1 | GET /accounts |
200 - the account id to target |
| 2 | POST /media/uploads {filename, mime, size} |
201 - {mediaId, uploadUrl, expiresAt} |
| 3 | PUT uploadUrl with the bytes |
200 - the URL is signed on its own; no Authorization header |
| 4 | GET /media/{id} until status is ready |
200 - uploading → processing → ready, or failed with failReason |
| 5 | POST /posts/plan, then POST /posts with confirm: true |
200 with rows, then 201 with posts[] |
| 6 | GET /posts/{id} until status is live or failed |
200 - externalUrl, or error |
set -eKEY="$DROPSLATE_API_KEY"; API="https://api.dropslate.top/v1"; AUTH="Authorization: Bearer $KEY"; JSON="Content-Type: application/json"FILE=./launch.mp4
# 1) The channel to publish toACCOUNT_ID=$(curl -s "$API/accounts" -H "$AUTH" | jq -r '.accounts[] | select(.platform=="youtube" and .status=="ok") | .id' | head -1)
# 2) Reserve the uploadTICKET=$(curl -s -X POST "$API/media/uploads" -H "$AUTH" -H "$JSON" \ -d "{\"filename\":\"launch.mp4\",\"mime\":\"video/mp4\",\"size\":$(stat -c%s "$FILE")}")MEDIA_ID=$(echo "$TICKET" | jq -r .mediaId); UPLOAD_URL=$(echo "$TICKET" | jq -r .uploadUrl)
# 3) Send the bytes (Content-Length must equal the size above)curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --data-binary @"$FILE" > /dev/null
# 4) Wait for processinguntil STATUS=$(curl -s "$API/media/$MEDIA_ID" -H "$AUTH" | jq -r .status); [ "$STATUS" = "ready" ]; do [ "$STATUS" = "failed" ] && { curl -s "$API/media/$MEDIA_ID" -H "$AUTH" | jq -r .failReason; exit 1; } sleep 5done
# 5) Plan, then createBODY=$(jq -n --arg a "$ACCOUNT_ID" --arg m "$MEDIA_ID" '{ targets:{accountIds:[$a]}, mediaIds:[$m], title:"Launch day", text:"We are live. Details in the pinned comment.", when:{publishAt:"2026-09-29T18:00", timezone:"Europe/Kiev"}, perPlatform:{youtube:{visibility:"unlisted", category:"28"}} }')PLAN=$(curl -s -X POST "$API/posts/plan" -H "$AUTH" -H "$JSON" -d "$BODY")[ "$(echo "$PLAN" | jq -r .ok)" = "true" ] || { echo "$PLAN" | jq -r .table; exit 1; }POST_ID=$(echo "$BODY" | jq '.+{confirm:true}' | curl -s -X POST "$API/posts" -H "$AUTH" -H "$JSON" -d @- | jq -r '.posts[0].id')
# 6) Wait for the network's answeruntil POST=$(curl -s "$API/posts/$POST_ID" -H "$AUTH"); S=$(echo "$POST" | jq -r .status); [ "$S" = "live" ] || [ "$S" = "failed" ]; do sleep 30; doneecho "$POST" | jq '{status, externalUrl, error, statusLine}'// Node 20+, no dependencies.import { readFile, stat } from 'node:fs/promises';
const API = 'https://api.dropslate.top/v1';const headers = { Authorization: `Bearer ${process.env.DROPSLATE_API_KEY}`, 'Content-Type': 'application/json' };const file = './launch.mp4';const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function api(method, path, body) { const res = await fetch(`${API}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.code}: ${json.message} ${json.hint ?? ''}`); return json;}
// 1) The channelconst { accounts } = await api('GET', '/accounts');const account = accounts.find((a) => a.platform === 'youtube' && a.status === 'ok');
// 2) + 3) Reserve the upload and send the bytesconst { size } = await stat(file);const { mediaId, uploadUrl } = await api('POST', '/media/uploads', { filename: 'launch.mp4', mime: 'video/mp4', size });await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'video/mp4' }, body: await readFile(file) });
// 4) Wait for processinglet media;do { await sleep(5000); media = await api('GET', `/media/${mediaId}`); if (media.status === 'failed') throw new Error(media.failReason);} while (media.status !== 'ready');
// 5) Plan, then createconst body = { targets: { accountIds: [account.id] }, mediaIds: [mediaId], title: 'Launch day', text: 'We are live. Details in the pinned comment.', when: { publishAt: '2026-09-29T18:00', timezone: 'Europe/Kiev' }, perPlatform: { youtube: { visibility: 'unlisted', category: '28' } },};const plan = await api('POST', '/posts/plan', body);if (!plan.ok) throw new Error(plan.table);const { posts } = await api('POST', '/posts', { ...body, confirm: true });
// 6) Wait for the network's answerlet post;do { await sleep(30000); post = await api('GET', `/posts/${posts[0].id}`);} while (post.status !== 'live' && post.status !== 'failed');console.log(post.statusLine, post.externalUrl ?? post.error);# Python 3.10+, requests.import os, time, requests
API = "https://api.dropslate.top/v1"H = {"Authorization": f"Bearer {os.environ['DROPSLATE_API_KEY']}"}FILE = "./launch.mp4"
def api(method, path, **kw): r = requests.request(method, API + path, headers=H, timeout=30, **kw) if not r.ok: e = r.json() raise RuntimeError(f"{r.status_code} {e['code']}: {e['message']} {e.get('hint', '')}") return r.json()
# 1) The channelaccount = next(a for a in api("GET", "/accounts")["accounts"] if a["platform"] == "youtube" and a["status"] == "ok")
# 2) + 3) Reserve the upload and send the bytessize = os.path.getsize(FILE)ticket = api("POST", "/media/uploads", json={"filename": "launch.mp4", "mime": "video/mp4", "size": size})with open(FILE, "rb") as f: requests.put(ticket["uploadUrl"], data=f, headers={"Content-Type": "video/mp4"}, timeout=600).raise_for_status()
# 4) Wait for processingwhile True: media = api("GET", f"/media/{ticket['mediaId']}") if media["status"] == "ready": break if media["status"] == "failed": raise RuntimeError(media["failReason"]) time.sleep(5)
# 5) Plan, then createbody = { "targets": {"accountIds": [account["id"]]}, "mediaIds": [ticket["mediaId"]], "title": "Launch day", "text": "We are live. Details in the pinned comment.", "when": {"publishAt": "2026-09-29T18:00", "timezone": "Europe/Kiev"}, "perPlatform": {"youtube": {"visibility": "unlisted", "category": "28"}},}plan = api("POST", "/posts/plan", json=body)if not plan["ok"]: raise RuntimeError(plan["table"])post_id = api("POST", "/posts", json={**body, "confirm": True})["posts"][0]["id"]
# 6) Wait for the network's answerwhile True: post = api("GET", f"/posts/{post_id}") if post["status"] in ("live", "failed"): break time.sleep(30)print(post["statusLine"], post.get("externalUrl") or post.get("error"))What each answer means
Section titled “What each answer means”- Step 2,
201.uploadUrlis single-use and valid for one hour (expiresAt);mediaIdis already the file’s id and does not change.413-class problems are reported here as402 quota_exceededwhen the plan’s per-file or storage limit is exceeded. - Step 3. The
PUTneeds noAuthorizationheader - the URL is signed - andContent-Lengthmust equalsize; a mismatch answerssize_mismatch, a secondPUTanswersalready_uploaded. - Step 4. A video takes seconds to a minute in
processingwhile its duration and a thumbnail are read.failednames the reason, usually a format the kind does not accept. - Step 5,
200then201. The plan’srows[].issuesis wherecaption_too_long,aspect_unsupportedormedia_not_readyappear;quota.requestedis what this request costs against the month.POST /postsanswers409 confirm_requiredwithout the flag and402 quota_exceededpast the limit. - Step 6. A post scheduled ahead stays
queueduntil its time; poll every minute at most.livecarriesexternalUrland, for YouTube,notesin the last event when a thumbnail or playlist was skipped;failedcarrieserrorand, if the worker will retry on its own, aretriedByWorkerevent with the time.
Common errors
Section titled “Common errors”| Error | Cause | Fix |
|---|---|---|
size_mismatch on the PUT |
Content-Length differs from size in step 2. |
Send the exact byte count; reserve a new upload if the file changed. |
already_uploaded |
A second PUT to the same URL. |
The first one succeeded; continue with GET /media/{id}. |
media_not_ready in the plan |
Step 4 was skipped. | Poll to ready first. |
youtube_title_required in the plan |
No title for a YouTube target. |
Add one. |
quota_exceeded on POST /media/uploads |
The file is over the plan’s per-file limit, or storage is full. | Free is 500 MB per file and 2 GB; see Plans & pricing. |
Related
Section titled “Related”- Quickstart - the same flow without a file.
- Uploading, Post statuses
- OpenAPI reference