Reelspan has official clients for Node.js (with TypeScript types) and Python. Both cover every endpoint, have no dependencies, retry what is safe to retry, and never publish a post twice.
| Node.js | Python | |
|---|---|---|
| Package | reelspan on npm |
reelspan on PyPI |
| Runs on | Node 20+, Deno, Bun, edge runtimes; ESM and CommonJS | Python 3.10+, standard library only |
| Licence | MIT | MIT |
Install
npm install reelspanpip install reelspanConfigure
Both clients read the key from REELSPAN_API_KEY, so most code needs no options:
import { Reelspan } from 'reelspan';
const reelspan = new Reelspan({
apiKey: process.env.REELSPAN_API_KEY, // the default
baseUrl: 'https://api.reelspan.com', // or REELSPAN_BASE_URL
timeoutMs: 120_000, // per attempt
maxRetries: 2,
headers: { 'x-my-app': 'scheduler' }, // sent on every request
});from reelspan import Reelspan
reelspan = Reelspan(
api_key=None, # None reads REELSPAN_API_KEY
base_url="https://api.reelspan.com", # or REELSPAN_BASE_URL
timeout=120, # seconds per attempt
max_retries=2,
headers={"X-My-App": "scheduler"}, # sent on every request
)Without a key, the constructor throws. Use a test key (rs_test_…) to run everything without publishing; see Test mode.
Node also takes fetch (your own, for proxies or tests) and directUploadBytes; Python takes direct_upload_bytes. Files at or above that size (90 MB by default) go to storage in parts, as described under Upload media.
Files
Anywhere a call takes a file:
- Node:
{ data, name, type }with the bytes, aBloborFile, or a public https URL as a string. - Python: a path, a
(name, bytes)or(name, bytes, content_type)tuple, or a public https URL.
Google Drive and Dropbox share links work as URLs.
Posts
posts publishes and follows up. Every post gets an Idempotency-Key on its own (pass idempotencyKey / idempotency_key to choose it). Network settings go in extra, with the same names as the API fields.
const post = await reelspan.posts.video({
user: 'my-brand',
platforms: ['tiktok', 'youtube'],
title: 'Launch day',
video: { data: await readFile('launch.mp4'), name: 'launch.mp4', type: 'video/mp4' },
extra: { privacy_level: 'PUBLIC_TO_EVERYONE', youtube_title: 'Launch day, in 60 seconds' },
});
await reelspan.posts.photos({ user: 'my-brand', platforms: ['instagram'], title: 'New colours', photos: ['https://example.com/photo1.jpg'], altTexts: ['A red mug'] });
await reelspan.posts.text({ user: 'my-brand', platforms: ['x', 'bluesky'], title: 'We shipped it.' });
await reelspan.posts.document({ user: 'my-brand', platforms: ['linkedin'], title: 'Q3 report', document: 'https://example.com/q3.pdf' });post = reelspan.posts.video(
"my-brand", ["tiktok", "youtube"], "launch.mp4", "Launch day",
extra={"privacy_level": "PUBLIC_TO_EVERYONE", "youtube_title": "Launch day, in 60 seconds"},
)
reelspan.posts.photos("my-brand", ["instagram"], ["photo1.jpg"], "New colours", alt_texts=["A red mug"])
reelspan.posts.text("my-brand", ["x", "bluesky"], "We shipped it.")
reelspan.posts.document("my-brand", "q3.pdf", "Q3 report")Common options on every post: scheduledDate / scheduled_date, timezone, addToQueue / add_to_queue, async / run_async, firstComment / first_comment, repeatEvery and repeatTimes / repeat_every and repeat_times, externalId / external_id, and description.
Then follow up:
const { request_id } = await reelspan.posts.video({ user: 'my-brand', platforms: ['youtube'], video, title: 'Long one', async: true });
const done = await reelspan.posts.wait(request_id); // polls status until every network has finished
await reelspan.posts.retry(request_id, ['youtube']); // sends failed networks again
const status = await reelspan.posts.status({ requestId: request_id });
for await (const row of reelspan.posts.all({ platform: 'tiktok', status: 'failed' })) console.log(row.post_url);request_id = reelspan.posts.video("my-brand", ["youtube"], "long.mp4", "Long one", run_async=True)["request_id"]
done = reelspan.posts.wait(request_id) # polls status until every network has finished
reelspan.posts.retry(request_id, ["youtube"]) # sends failed networks again
status = reelspan.posts.status(request_id=request_id)
for row in reelspan.posts.all(platform="tiktok", status="failed"):
print(row["post_url"])posts.history returns one page; posts.all walks every page for you. See Post status, History and Retry.
Profiles and accounts
profiles manages brands; accounts connects and disconnects networks that use credentials; connect makes hosted links and OAuth starts for people without a Reelspan login.
await reelspan.profiles.create('client-a', 'Client A');
const { profiles } = await reelspan.profiles.list();
await reelspan.accounts.connect('client-a', 'discord', { webhook_url: 'https://discord.com/api/webhooks/…' });
await reelspan.accounts.disconnect('client-a', 'discord');
const { access_url } = await reelspan.connect.link('client-a', { redirect_url: 'https://example.com/done' });
const { authorize_url } = await reelspan.connect.oauthStart('tiktok', { profile: 'client-a' });reelspan.profiles.create("client-a", "Client A")
profiles = reelspan.profiles.list()["profiles"]
reelspan.accounts.connect("client-a", "discord", webhook_url="https://discord.com/api/webhooks/…")
reelspan.accounts.disconnect("client-a", "discord")
access_url = reelspan.connect.link("client-a", redirect_url="https://example.com/done")["access_url"]
authorize_url = reelspan.connect.oauth_start("tiktok", profile="client-a")["authorize_url"]Also: profiles.get, profiles.rename, profiles.delete and connect.validate(token). See Profiles, Connect with credentials and White-label connect.
Scheduled posts
const list = await reelspan.scheduled.list({ profile_username: 'my-brand' });
await reelspan.scheduled.edit('sched_5d0e…', { scheduled_date: '2026-12-01T09:00:00', timezone: 'Europe/London' });
await reelspan.scheduled.cancel('sched_5d0e…', { withLaterRepeats: true });scheduled = reelspan.scheduled.list(profile_username="my-brand")
reelspan.scheduled.edit("sched_5d0e…", scheduled_date="2026-12-01T09:00:00", timezone="Europe/London")
reelspan.scheduled.cancel("sched_5d0e…", with_later_repeats=True)Also scheduled.get(jobId). See Scheduled posts.
Queue
await reelspan.queue.setSettings('my-brand', { timezone: 'Europe/London', slots: [{ hour: 9, minute: 0 }, { hour: 17, minute: 30 }] });
const { next_slot } = await reelspan.queue.nextSlot('my-brand');reelspan.queue.set_settings("my-brand", timezone="Europe/London", slots=[{"hour": 9, "minute": 0}, {"hour": 17, "minute": 30}])
next_slot = reelspan.queue.next_slot("my-brand")["next_slot"]Also queue.settings, queue.preview, queue.markFull / mark_full and queue.markOpen / mark_open. See Queue.
Media and library
media.upload uploads a file once and returns its media_id. Files from 90 MB go straight to storage in parts, three at a time, each part retried on its own; Python reads the file from disk piece by piece. library keeps files to post again.
const media = await reelspan.media.upload(
{ data: await readFile('film.mp4'), name: 'film.mp4', type: 'video/mp4' },
{ kind: 'video', onProgress: (sent, total) => console.log(Math.round((sent / total) * 100), '%') },
);
await reelspan.posts.video({ user: 'my-brand', platforms: ['youtube'], title: 'The film', videoMediaId: media.media_id });
await reelspan.library.keep([media.media_id], 'The film');
const page = await reelspan.library.list({ kind: 'video' });media = reelspan.media.upload("film.mp4", kind="video", on_progress=lambda sent, total: print(f"{sent * 100 // total}%"))
reelspan.posts.video("my-brand", ["youtube"], title="The film", video_media_id=media["media_id"])
reelspan.library.keep([media["media_id"]], "The film")
page = reelspan.library.list(kind="video")Also library.import / import_url (from a share link), library.remove and library.fileUrl / file_url (a short-lived download link). See Upload media and Media library.
Analytics
const totals = await reelspan.analytics.account('my-brand');
const best = await reelspan.analytics.bestTimes('my-brand', { timezone: 'Europe/London' });totals = reelspan.analytics.account("my-brand")
best = reelspan.analytics.best_times("my-brand", timezone="Europe/London")Also analytics.history, analytics.post(requestId) and analytics.report(username, days). See Analytics, Best times to post and Reports.
Approvals
const { approval_link } = await reelspan.approvals.create({ user: 'my-brand', jobIds: ['sched_5d0e…'], title: 'December posts' });
console.log(approval_link.url); // send this to the clientlink = reelspan.approvals.create("my-brand", ["sched_5d0e…"], title="December posts")["approval_link"]
print(link["url"]) # send this to the clientAlso approvals.list and approvals.delete. See Approval links.
AI captions
Each call uses one AI credit.
const captions = await reelspan.ai.writeCaptions({ text: 'We opened a second shop in Leeds', platforms: ['instagram', 'x', 'linkedin'] });captions = reelspan.ai.write_captions(text="We opened a second shop in Leeds", platforms=["instagram", "x", "linkedin"])Also ai.captionsFromVideo / captions_from_video and ai.rewriteCaptions / rewrite_captions. See AI captions.
Pinterest boards
const { board } = await reelspan.pinterest.createBoard('my-brand', 'Spring range');board = reelspan.pinterest.create_board("my-brand", "Spring range")["board"]Webhooks
webhooks sets the account webhook and reads its deliveries; webhooks.profile sets a profile's own.
await reelspan.webhooks.set('https://example.com/reelspan', ['upload_completed']);
await reelspan.webhooks.profile.set('client-a', 'https://client-a.example.com/hooks');
const { deliveries } = await reelspan.webhooks.deliveries();reelspan.webhooks.set("https://example.com/reelspan", ["upload_completed"])
reelspan.webhooks.profile.set("client-a", "https://client-a.example.com/hooks")
deliveries = reelspan.webhooks.deliveries()["deliveries"]Also get, delete, test, redeliver(deliveryId) and rotateSecret / rotate_secret. To check that a delivery came from Reelspan, use verifyWebhook / verify_webhook with the raw body and the two signature headers:
import express from 'express';
import { verifyWebhook } from 'reelspan';
const app = express();
app.post('/reelspan', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = await verifyWebhook(req.body.toString(), req.get('x-reelspan-signature'), req.get('x-reelspan-timestamp'), process.env.REELSPAN_WEBHOOK_SECRET);
if (!ok) return res.sendStatus(401);
res.sendStatus(200);
// handle JSON.parse(req.body) after answering
});import os
from flask import Flask, request
from reelspan import verify_webhook
app = Flask(__name__)
@app.post("/reelspan")
def reelspan_hook():
ok = verify_webhook(request.get_data(), request.headers.get("X-Reelspan-Signature"), request.headers.get("X-Reelspan-Timestamp"), os.environ["REELSPAN_WEBHOOK_SECRET"])
if not ok:
return "", 401
return "", 200Both reject a timestamp more than 5 minutes away from now (change it with the last argument). See Webhooks.
Anything else
request calls any endpoint with your key, the retries and the error handling:
const me = await reelspan.request('GET', '/v1/me');
await reelspan.request('POST', '/v1/posts/retry', { json: { request_id: requestId, fields: { tiktok_title: 'Shorter caption' } } });me = reelspan.request("GET", "/v1/me")
reelspan.request("POST", "/v1/posts/retry", json_body={"request_id": request_id, "fields": {"tiktok_title": "Shorter caption"}})me() and platforms() are shortcuts for the account and the list of networks.
Retries
Both clients retry on their own, with exponential backoff and jitter (about 0.5 s, 1 s, 2 s, up to 8 s), or after the Retry-After the API sent (capped at 60 seconds):
- What is retried: network errors and timeouts, and answers
408,409,429and5xx. - When:
GET,PUTandDELETEalways;POSTandPATCHonly with an idempotency key. Posts and the multipart upload calls carry one, so they are safe. A429is retried for any method. - How many times:
maxRetries/max_retries(default 2), so up to three attempts. Set it per call in Node with{ maxRetries }.
The last rate limit the API reported is on reelspan.rateLimit (Node) or reelspan.rate_limit (Python): limit, remaining and reset (Unix seconds).
Errors
An error answer raises ReelspanError with the HTTP status, the error_code, the full body and, when the API sent Retry-After, how many seconds it asked you to wait. A request that never got an answer raises ReelspanConnectionError.
import { ReelspanError, ReelspanConnectionError } from 'reelspan';
try {
await reelspan.posts.text({ user: 'my-brand', platforms: ['x'], title: 'Hi' });
} catch (err) {
if (err instanceof ReelspanError) console.log(err.status, err.code, err.message, err.retryAfter, err.body);
else if (err instanceof ReelspanConnectionError) console.log('no answer:', err.message);
else throw err;
}from reelspan import ReelspanError, ReelspanConnectionError
try:
reelspan.posts.text("my-brand", ["x"], "Hi")
except ReelspanError as e:
print(e.status, e.code, e, e.retry_after, e.body)
except ReelspanConnectionError as e:
print("no answer:", e)isRateLimited / is_rate_limited is true for a 429. A network that fails inside a post is not an exception: it is a result with success: false in results. See Errors.