Skip to content
reelspanDocs

Get started

SDKs

The official Reelspan clients for Node.js, TypeScript and Python - install, configure, call every endpoint, and get retries, idempotent posts and webhook checks built in.

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 reelspan

Configure

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
});

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, a Blob or File, 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' });

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);

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' });

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 });

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');

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' });

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' });

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 client

Also 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'] });

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');

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();

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
});

Both 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() 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, 429 and 5xx.
  • When: GET, PUT and DELETE always; POST and PATCH only with an idempotency key. Posts and the multipart upload calls carry one, so they are safe. A 429 is 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;
}

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.

Something unclear or missing? Write to [email protected] and we’ll fix the page.