# Introduction > Reelspan is one API that publishes your videos, photos and text to 22 social networks, schedules them, and tells you how each post did. Reelspan turns "post this everywhere" into one HTTP request. You send a video (or photos, or text) once, name the networks, and Reelspan uploads it to each one in the format that network wants, then reports a result per network: the live post's URL, or exactly why it failed. ## What you can do - **Publish** videos, photo carousels, text and LinkedIn documents to TikTok, YouTube, Instagram, Facebook, X, Threads, LinkedIn, Pinterest, Bluesky, Reddit, Google Business, Telegram, Discord, Slack, Mastodon, Nostr, Lemmy, Dev.to, Hashnode, WordPress, Whop and Listmonk. - **Schedule** a post for later, drop it in a profile's posting queue, or repeat it every day, week or month. - **Follow up**: check status, retry only the networks that failed, add a first comment, get a webhook when each post goes live. - **Measure**: account and post analytics, daily history, best times to post, and branded reports. - **Manage many brands**: each *profile* holds one account per network. Connect your own accounts in the dashboard, or send your clients a white-label link to connect theirs. ## How it works 1. **Get an API key** in the dashboard (Developers → API keys). Use a test key (`rs_test_…`) to try everything without publishing. 2. **Create a profile** and **connect accounts** to it, in the dashboard or through the API. 3. **Post** with `POST /v1/posts/video` (or `photos`, `text`, `document`), naming the profile as `user` and the networks in `platform[]`. 4. **Read the result** in the response, poll `GET /v1/posts/status`, or receive a webhook. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/text \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"user": "my-brand", "platform": ["x", "bluesky", "threads"], "title": "Hello from Reelspan"}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); // reads REELSPAN_API_KEY await reelspan.posts.text({ user: 'my-brand', platforms: ['x', 'bluesky', 'threads'], title: 'Hello from Reelspan' }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() # reads REELSPAN_API_KEY reelspan.posts.text("my-brand", ["x", "bluesky", "threads"], "Hello from Reelspan") ``` ## Ways to use it | You are… | Start with | | --- | --- | | Writing code | The [Quickstart](/quickstart), then the [Node.js or Python SDK](/sdks) | | Using a no-code tool | [n8n](/integrations/n8n), [Make](/integrations/make), [Zapier](/integrations/zapier) or [Airtable](/integrations/airtable) | | Building with AI agents | The [MCP server](/integrations/mcp), or one of the [agent frameworks](/integrations/langchain) | | Moving from Upload-Post | [Migrate from Upload-Post](/migrate-from-upload-post): the same request shapes | ## Good to know - **Base URL:** `https://api.reelspan.com/v1`. Every request carries `Authorization: Bearer `. - **Per-network results:** one network failing never fails the others. Each has its own `success`, `url` and `error_code`. - **Safe retries:** send an `Idempotency-Key` header and a retried request never posts twice. The SDKs do it for you. - **Machine-readable:** the [OpenAPI spec](https://api.reelspan.com/openapi.json), [llms.txt](/llms.txt) and every page as Markdown (add `.md` to any URL). Source: https://docs.reelspan.com/introduction --- # Quickstart > Go from no API key to a live post in a few minutes. Create a key and a profile, connect an account, publish, and read each network's result. This guide takes you from nothing to a published post. You need a Reelspan account; everything else happens here. ## 1. Create an API key In the dashboard, open **Developers → API keys** and create a key. Copy it straight away: Reelspan keeps only a hash, so the key is shown once. - A **live key** starts with `rs_live_` and publishes for real. - A **test key** starts with `rs_test_`. Every request goes through the same checks, but nothing reaches the networks and nothing counts against your plan (up to 500 test posts a day). Use one while you build. See [Test mode](/guides/test-mode). Keep the key in an environment variable, never in code you commit. The SDKs read `REELSPAN_API_KEY` on their own: ```bash export REELSPAN_API_KEY="rs_test_…" ``` Check it works: ```bash title="cURL" curl https://api.reelspan.com/v1/me \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); // reads REELSPAN_API_KEY console.log(await reelspan.me()); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() # reads REELSPAN_API_KEY print(reelspan.me()) ``` The answer names your plan and this month's usage. A `401` means the key is wrong, expired or revoked. The SDKs install with `npm install reelspan` and `pip install reelspan`; see [SDKs](/sdks). ## 2. Create a profile A profile is one brand or client. It holds one connected account per network, and every post names it as `user`. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/profiles \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"username": "my-brand", "display_name": "My Brand"}' ``` ```javascript title="Node.js" await reelspan.profiles.create('my-brand', 'My Brand'); ``` ```python title="Python" reelspan.profiles.create("my-brand", "My Brand") ``` The username can't be changed later; the display name can. See [Profiles](/api/profiles). ## 3. Connect an account Pick whichever fits: - **In the dashboard:** open **Accounts**, choose the profile and connect a network. This is the quickest way to connect your own accounts. - **With a connect link:** for a client who should connect their own accounts without a Reelspan login. The link works for 48 hours. See [White-label connect](/white-label). - **With credentials:** networks that use a token or webhook instead of a sign-in (Discord, Telegram, Slack, Mastodon, Bluesky and others) can be connected straight from your code. See [Connect with credentials](/api/connect-credentials). ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/connect-links \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"username": "my-brand"}' ``` ```javascript title="Node.js" const { access_url } = await reelspan.connect.link('my-brand'); ``` ```python title="Python" access_url = reelspan.connect.link("my-brand")["access_url"] ``` Send `access_url` to whoever owns the accounts. [Connecting accounts](/connecting-accounts) covers every option. > [!TIP] > No account connected yet? Post to `sandbox` instead of a real network. It needs no connection, reads the media like a real network would and answers with a pretend URL, so you can try the whole flow first. With a live key a sandbox post still counts as an upload. ## 4. Publish your first post Send the video once, with the profile in `user` and one `platform[]` field per network. Reelspan uploads it to each network in the format that network wants. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=tiktok" \ -F "platform[]=youtube" \ -F "platform[]=instagram" \ -F "title=Golden hour, no filter" \ -F "video=@launch.mp4" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; import { readFile } from 'node:fs/promises'; const reelspan = new Reelspan(); const post = await reelspan.posts.video({ user: 'my-brand', platforms: ['tiktok', 'youtube', 'instagram'], title: 'Golden hour, no filter', video: { data: await readFile('launch.mp4'), name: 'launch.mp4', type: 'video/mp4' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() post = reelspan.posts.video("my-brand", ["tiktok", "youtube", "instagram"], "launch.mp4", "Golden hour, no filter") ``` The fields you'll use most: | Field | What it does | | --- | --- | | `user` | The profile's username. Required. | | `platform[]` | One per network. Required. | | `video` | The file, or a public https URL (Google Drive and Dropbox share links work). | | `title` | The caption. `{network}_title` (for example `youtube_title`) overrides it on one network. YouTube needs a title. | | `description` | The longer text, where a network has one. | | `first_comment` | Posted as the first comment right after the post goes live. | | `scheduled_date` | An ISO 8601 time up to 365 days ahead, with `timezone` (IANA, default UTC). | | `add_to_queue` | `true` takes the profile's next free queue slot. | Photos, text and LinkedIn documents work the same way: [Post photos](/api/post-photos), [Post text](/api/post-text), [Post a document](/api/post-document). Every field for videos is on [Post a video](/api/post-video). ## 5. Read the result The request waits up to 59 seconds for the networks and answers with one result each. One network failing never stops the others: ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "results": { "tiktok": { "success": true, "url": "https://www.tiktok.com/@mybrand/video/7441…", "post_id": "7441…" }, "youtube": { "success": true, "url": "https://youtube.com/shorts/aB3…", "post_id": "aB3…" }, "instagram": { "success": false, "skipped": true, "skip_reason": "profile_platform_not_configured", "failure_stage": "profile_platform_validation", "error": "Profile my-brand has no Instagram account configured", "error_code": "profile_platform_mapping_invalid" } }, "usage": { "count": 2, "limit": null, "last_reset": "2026-11-01T00:00:00.000Z" } } ``` Here Instagram was skipped because the profile has no Instagram account; a skipped network isn't charged. If a network takes longer, or you sent `async_upload=true`, the answer has a `message` and the `request_id` instead of `results`, and the work carries on in the background. Poll the status every 5 seconds or more: ```bash title="cURL" curl "https://api.reelspan.com/v1/posts/status?request_id=9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" const status = await reelspan.posts.wait(post.request_id); // polls until every network has finished ``` ```python title="Python" status = reelspan.posts.wait(post["request_id"]) # polls until every network has finished ``` Or skip polling and let a [webhook](/webhooks) tell your server as each network finishes. If something failed, [Errors](/errors) explains the code, and [Retry](/api/retry) sends only the failed networks again without uploading the video twice. ## Next steps - [API overview](/api/overview): base URL, errors, rate limits, idempotency and ids. - [Scheduling and repeats](/guides/scheduling), then [Scheduled posts](/api/scheduled-posts) to edit or cancel them. - [Webhooks](/webhooks) for results without polling. - [Upload media](/api/upload-media) to send a file once and post it many times, or files over 100 MB. - The [MCP server](/integrations/mcp) if you'd rather have an AI agent do the calling. Source: https://docs.reelspan.com/quickstart --- # API overview > How the Reelspan API works - base URL, API keys, multipart and JSON bodies, response and error shapes, status codes, idempotency, rate limits, paging and ids. The Reelspan API is plain HTTPS and JSON. This page covers the rules every endpoint shares; each endpoint's own page covers its fields. ## Base URL ```text https://api.reelspan.com/v1 ``` Every endpoint lives under `/v1`. The machine-readable [OpenAPI document](https://api.reelspan.com/openapi.json) describes all of them, and the [API reference](/reference) is built from it. > [!NOTE] > Send API keys to `api.reelspan.com`. The dashboard's own address (`app.reelspan.com`) answers a request made with an API key with **421** and `error_code: "USE_API_HOST"`, naming the right address. ## Authentication Send your key in the `Authorization` header on every request: ```bash curl https://api.reelspan.com/v1/me \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` `Authorization: Apikey $REELSPAN_API_KEY` works too, as Upload-Post clients send it. Create keys in the dashboard under **Developers → API keys**. A key can be limited in two ways: - **Scopes:** `publish`, `read`, `profiles`, `analytics` and `webhooks`. A call outside the key's scopes gets **403** with `error_code: "INSUFFICIENT_SCOPE"`. - **Profiles:** a key limited to some profiles gets **403** `PROFILE_FORBIDDEN` for any other profile, and posts or jobs of other profiles answer **404** as if they didn't exist. A missing, wrong, revoked or expired key gets **401**. After 10 failed attempts from one IP address in 5 minutes, that address gets **429** with `error_code: "AUTH_RATE_LIMITED"` and `Retry-After: 300`. More in [Authentication](/authentication). ## Request formats | Endpoint | Body | | --- | --- | | `POST /v1/posts/video`, `/photos`, `/document` | `multipart/form-data`: files and form fields together | | `POST /v1/posts/text`, `POST /v1/youtube/thumbnail` | JSON or `multipart/form-data` | | `POST /v1/media/upload` | `multipart/form-data`, one field named `file` | | Everything else | JSON body (`Content-Type: application/json`) or query parameters | In multipart forms: - **Lists** repeat the field with `[]` in its name: `-F "platform[]=tiktok" -F "platform[]=youtube"`. - **Booleans** are the strings `true` or `false`. - **Media** can be a file or a public https URL in the same field, or the `media_id` of a file you [uploaded earlier](/api/upload-media). In JSON (text posts), send the list as `"platform": ["x", "bluesky"]`; `"platform[]"` works too. ## Responses A successful answer has `"success": true` and the data at the top level: ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "results": { "…": "…" } } ``` An error has `"success": false`, a readable `message`, and usually a machine-readable `error_code` to branch on. Some errors add fields, such as `usage` on a monthly-limit error or `invalid_platforms` when none of the networks can be used: ```json { "success": false, "message": "This API key does not have the \"publish\" scope", "error_code": "INSUFFICIENT_SCOPE" } ``` An unexpected server error is `{ "success": false, "error": "Internal server error" }` with **500**. [Errors](/errors) lists the codes. ## Per-network results A post to several networks never fails as a whole because one network did. When a post finishes within the request, `results` holds one object per network: ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "results": { "tiktok": { "success": true, "url": "https://www.tiktok.com/@mybrand/video/7441…", "post_id": "7441…" }, "x": { "success": true, "url": "https://x.com/mybrand/status/18…", "post_id": "18…", "warnings": ["The text was posted as a thread of 2 posts."] }, "pinterest": { "success": false, "error": "…", "error_code": "…" } }, "usage": { "count": 41, "limit": null, "last_reset": "2026-11-01T00:00:00.000Z" } } ``` - `success`, `url` and `post_id` say where it went live. - `error` and `error_code` say why it didn't. - `warnings` lists anything changed on the way (links removed on X, a long text split into a thread, a first comment that couldn't be posted). - `skipped: true` with a `skip_reason` means the network was never tried, for example because the profile has no account on it. Skipped networks aren't charged. The top-level `success` only says the request was accepted, so always read each network's own `success`. [Post status](/api/post-status) returns the same results as a list. ## Status codes | Code | Meaning | | --- | --- | | `200` | Done. For a post: finished (with `results`) or moved to the background (with `request_id`). | | `201` | Created (a profile, an approval link). | | `202` | Scheduled or queued: the answer carries a `job_id`. | | `400` | Something in the request is wrong; `message` says what. | | `401` | No key, or the key is invalid, revoked or expired. | | `403` | The key lacks the scope or the profile, or the workspace is suspended. | | `404` | The profile, post, job or file doesn't exist (or the key can't see it). | | `409` | A conflict: the same `Idempotency-Key` still running, a `request_id` already used, a scheduled post already publishing, nothing to retry, or an account that must be reconnected (`reauth_required`). | | `413` | The file is larger than your plan allows. | | `421` | An API key was sent to the dashboard's address instead of `api.reelspan.com`. | | `429` | Rate limit, monthly upload limit, or the daily test-post limit. | | `500` | Reelspan's fault. Safe to retry with an `Idempotency-Key`. | | `502` | A network refused or didn't answer: a lookup (Pinterest boards, TikTok creator info, Facebook Page details…) or a change to a live post ([unpublish](/api/unpublish), [edit](/api/edit-post), [repost](/api/repost)). `error_code` says why. | ## Idempotency Networks drop, and a retried POST could publish twice. Send an `Idempotency-Key` header (any string up to 200 characters, such as a UUID) on the four publish endpoints, and a repeat with the same key returns the first request's result instead of posting again: ```bash curl -X POST https://api.reelspan.com/v1/posts/text \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Idempotency-Key: 6f1c2a7e-weekly-tip-48" \ -H "Content-Type: application/json" \ -d '{"user": "my-brand", "platform": ["x"], "title": "Weekly tip"}' ``` - A key is remembered for 24 hours, per workspace and per kind of post (video, photos, text, document). - A replay answers with the first request's current state, the header `Idempotent-Replayed: true` and `"idempotent_replay": true` in the body. - While the first request with a key is still open (including its wait of up to 59 seconds for results), a second one gets **409** `IDEMPOTENCY_KEY_IN_USE`. Wait a few seconds and send it again. - `X-Idempotency-Key` works as another name for the header. - `X-Request-Id` is a third name, used only when neither of the others is sent, and it also becomes the post's `request_id` (below). You can also pick the post's id yourself: send `request_id` (8 to 100 letters, digits, `_` or `-`), as a field or as the `X-Request-Id` header, and you can poll it even if the answer never arrived. A header in any other shape (a proxy's own trace id, say) is ignored. Reusing a `request_id` gets **409** `REQUEST_ID_IN_USE`. The SDKs add an `Idempotency-Key` to every post for you. See [Retrying safely](/guides/idempotency). ## Rate limits Each workspace has a request limit per minute and per 10 minutes, set by its plan ([Plans and limits](/limits/plans)). Every answer to an API-key request carries the window closest to running out: | Header | Meaning | | --- | --- | | `X-RateLimit-Limit` | Requests allowed in the window | | `X-RateLimit-Remaining` | Requests left in it | | `X-RateLimit-Reset` | When it resets, in Unix seconds | Past the limit you get **429** with `error_code: "RATE_LIMITED"` and a `Retry-After` header in seconds. The monthly upload limit (Free plan) is separate: a post that would go over it gets **429** with your `usage`. See [Rate limits](/rate-limits). ## Paging List endpoints page in one of three ways: | Endpoint | Parameters | Done when | | --- | --- | --- | | [History](/api/history) `GET /v1/posts` | `page` (from 1) and `limit` (10, 20, 50 or 100) | `page × limit ≥ total` | | [Scheduled posts](/api/scheduled-posts) `GET /v1/scheduled` | `offset` and `limit` (1 to 200, default 50) | `offset + limit ≥ total` | | [Media library](/api/library) `GET /v1/library` | `limit` (1 to 100) and `before` | `next_before` is `null`; otherwise pass it as `before` | [Webhook deliveries](/webhooks) and approval links return the newest entries only, without pages. ## Ids | Id | Looks like | What it names | | --- | --- | --- | | `request_id` | `9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1` (or your own) | One post request, across all its networks. Use it for status, retry, history and post analytics. | | `job_id` | `sched_5d0e…` | A scheduled or queued post. Use it to read, edit or cancel it. A scheduled post has a `request_id` too. | | `media_id` | `med_…` | A file uploaded with [Upload media](/api/upload-media), to post or keep in the library. | | `external_id` | yours | Any id from your own system (up to 255 characters), sent with a post (as a field or the `X-External-Id` header) and returned in history, status and webhooks. | ## Test keys A test key (`rs_test_…`) runs every check a live key does, then simulates the post: each network comes back `success: true` with a `post_id` that starts with `test_` and no `url`, and status answers carry `"test": true`. Nothing reaches the networks and nothing counts against your plan. A workspace can send 500 test posts a day; past that the answer is **429** `TEST_LIMIT`. A test post still needs the networks connected to the profile, as a real post would. To try things with nothing connected, post to the `sandbox` network. See [Test mode](/guides/test-mode). Source: https://docs.reelspan.com/api/overview --- # 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 ```bash title="Node.js" npm install reelspan ``` ```bash title="Python" pip install reelspan ``` ## Configure Both clients read the key from `REELSPAN_API_KEY`, so most code needs no options: ```javascript title="Node.js" 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 }); ``` ```python title="Python" 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](/guides/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](/api/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. ```javascript title="Node.js" 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' }); ``` ```python title="Python" 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: ```javascript title="Node.js" 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); ``` ```python title="Python" 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](/api/post-status), [History](/api/history) and [Retry](/api/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. ```javascript title="Node.js" 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' }); ``` ```python title="Python" 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](/api/profiles), [Connect with credentials](/api/connect-credentials) and [White-label connect](/white-label). ## Scheduled posts ```javascript title="Node.js" 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 }); ``` ```python title="Python" 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](/api/scheduled-posts). ## Queue ```javascript title="Node.js" 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'); ``` ```python title="Python" 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](/api/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. ```javascript title="Node.js" 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' }); ``` ```python title="Python" 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](/api/upload-media) and [Media library](/api/library). ## Analytics ```javascript title="Node.js" const totals = await reelspan.analytics.account('my-brand'); const best = await reelspan.analytics.bestTimes('my-brand', { timezone: 'Europe/London' }); ``` ```python title="Python" 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](/api/analytics), [Best times to post](/api/best-times) and [Reports](/api/reports). ## Approvals ```javascript title="Node.js" 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 ``` ```python title="Python" link = reelspan.approvals.create("my-brand", ["sched_5d0e…"], title="December posts")["approval_link"] print(link["url"]) # send this to the client ``` Also `approvals.list` and `approvals.delete`. See [Approval links](/api/approval-links). ## AI captions Each call uses one AI credit. ```javascript title="Node.js" const captions = await reelspan.ai.writeCaptions({ text: 'We opened a second shop in Leeds', platforms: ['instagram', 'x', 'linkedin'] }); ``` ```python title="Python" 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](/api/ai-captions). ## Pinterest boards ```javascript title="Node.js" const { board } = await reelspan.pinterest.createBoard('my-brand', 'Spring range'); ``` ```python title="Python" 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. ```javascript title="Node.js" 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(); ``` ```python title="Python" 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: ```javascript title="Node.js" 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 }); ``` ```python title="Python" 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 "", 200 ``` Both reject a timestamp more than 5 minutes away from now (change it with the last argument). See [Webhooks](/webhooks). ## Anything else `request` calls any endpoint with your key, the retries and the error handling: ```javascript title="Node.js" const me = await reelspan.request('GET', '/v1/me'); await reelspan.request('POST', '/v1/posts/retry', { json: { request_id: requestId, fields: { tiktok_title: 'Shorter caption' } } }); ``` ```python title="Python" 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`, `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`. ```javascript title="Node.js" 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; } ``` ```python title="Python" 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](/errors). Source: https://docs.reelspan.com/sdks --- # Migrate from Upload-Post > Move an Upload-Post integration to Reelspan - the endpoint map, the auth header change, the field names that differ, and what you gain on the way. Reelspan takes the same request shapes as Upload-Post: the same form fields (`user`, `platform[]`, `title`, `scheduled_date`, `async_upload`, `{platform}_title` and the rest), the same per-network `results` in the answer, and webhooks with the same event names and payloads. Most integrations move by changing the base URL, the key and the paths. ## 1. Change the base URL and the key | | Upload-Post | Reelspan | | --- | --- | --- | | Base URL | `https://api.upload-post.com/api` | `https://api.reelspan.com/v1` | | Auth header | `Authorization: Apikey ` | `Authorization: Apikey ` or `Authorization: Bearer ` | Reelspan reads both schemes, so the header can stay as it is: only the key changes. Create a key in the dashboard under **Developers → API keys**; see [Authentication](/authentication). ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=tiktok" \ -F "platform[]=youtube" \ -F "title=Launch day" \ -F "video=@launch.mp4" ``` ## 2. Change the paths Paths drop the `/uploadposts` prefix and use `/v1/...` names. Bodies and query parameters stay the same unless a note says otherwise. ### Publishing | Upload-Post | Reelspan | | --- | --- | | `POST /api/upload` | [`POST /v1/posts/video`](/api/post-video) | | `POST /api/upload_photos` | [`POST /v1/posts/photos`](/api/post-photos) | | `POST /api/upload_text` | [`POST /v1/posts/text`](/api/post-text) | | `POST /api/upload_document` | [`POST /v1/posts/document`](/api/post-document) | | `GET /api/uploadposts/status` | [`GET /v1/posts/status`](/api/post-status) | | `GET /api/uploadposts/history` | [`GET /v1/posts`](/api/history) | | `POST /api/uploadposts/posts/retry` | [`POST /v1/posts/retry`](/api/retry) | | `POST /api/uploadposts/posts/unpublish` | [`POST /v1/posts/unpublish`](/api/unpublish) | | `POST /api/uploadposts/posts/edit` | [`POST /v1/posts/edit`](/api/edit-post) | | `POST /api/uploadposts/posts/repost` | [`POST /v1/posts/repost`](/api/repost) | | `POST /api/uploadposts/posts/save` | [`POST /v1/posts/save`](/networks/pinterest#save-a-pin-to-a-board) (Pinterest) | | `POST /api/uploadposts/youtube/thumbnail` | [`POST /v1/youtube/thumbnail`](/api/youtube-thumbnail) | ### Scheduling and the queue | Upload-Post | Reelspan | | --- | --- | | `GET /api/uploadposts/schedule` | [`GET /v1/scheduled`](/api/scheduled-posts) (pages of up to 200; 50 when you send no `limit`) | | `GET /api/uploadposts/schedule/{job_id}` | `GET /v1/scheduled/{job_id}` | | `PATCH /api/uploadposts/schedule/{job_id}` | `PATCH /v1/scheduled/{job_id}` | | `GET /api/uploadposts/schedule/{job_id}/thumbnail` | `GET /v1/scheduled/{job_id}/thumbnail` (a **302** to the image; `?format=json` for the link) | | `GET /api/uploadposts/schedule/{job_id}/preview` | `GET /v1/scheduled/{job_id}/preview` | | `DELETE /api/uploadposts/schedule/{job_id}` | `DELETE /v1/scheduled/{job_id}` | | `GET` / `POST /api/uploadposts/queue/settings` | [`GET` / `POST /v1/queue/settings`](/api/queue) | | `GET /api/uploadposts/queue/preview` | `GET /v1/queue/preview` | | `GET /api/uploadposts/queue/next-slot` | `GET /v1/queue/next-slot` | | `POST` / `DELETE /api/uploadposts/queue/slot-full` | `POST` / `DELETE /v1/queue/slot-full` | ### Profiles and accounts | Upload-Post | Reelspan | | --- | --- | | `GET /api/uploadposts/me` | [`GET /v1/me`](/api/me) | | `GET` / `POST /api/uploadposts/users/preferences` | [`GET` / `POST /v1/preferences`](/api/preferences) (adds `timezone`) | | `POST /api/uploadposts/users` | [`POST /v1/profiles`](/api/profiles) | | `GET /api/uploadposts/users` | `GET /v1/profiles` | | `GET /api/uploadposts/users/{username}` | `GET /v1/profiles/{username}` | | `PATCH /api/uploadposts/users/{username}` | `PATCH /v1/profiles/{username}` | | `DELETE /api/uploadposts/users` | `DELETE /v1/profiles` | | `DELETE /api/uploadposts/users/social` | `DELETE /v1/accounts` (same `profile_username` and `social_platform`) | | `POST /api/uploadposts/users/generate-jwt` | [`POST /v1/connect-links`](/white-label) (same body, same `access_url`, valid 48 hours) | | `GET /api/uploadposts/users/validate-jwt` | `GET /v1/connect-links/validate` | | `POST /api/uploadposts/users/discord/credentials`, `…/telegram/credentials` | [`POST /v1/accounts/{platform}`](/api/connect-credentials), one route for every credential network | | `POST /api/uploadposts/oauth/{platform}/start` | `POST /v1/oauth/{platform}/start` | ### Lookups and analytics | Upload-Post | Reelspan | | --- | --- | | `GET /api/uploadposts/facebook/pages` | [`GET /v1/facebook/pages`](/networks/facebook#choose-the-page) (`profile` is required) | | `POST` / `DELETE /api/uploadposts/users/facebook-page` | `POST` / `DELETE /v1/facebook/page`; the chosen Page is `selected_page_id` in `GET /v1/facebook/pages` | | `GET /api/uploadposts/facebook/page-info` | `GET /v1/facebook/page-info` | | `GET /api/uploadposts/google-business/locations` | [`GET /v1/google-business/locations`](/networks/google-business#choose-the-location) | | `POST` / `DELETE /api/uploadposts/users/google-business-location` | `POST` / `DELETE /v1/google-business/location`; the chosen location is `selected_location_id` in `GET /v1/google-business/locations` | | `GET /api/uploadposts/pinterest/boards` | [`GET /v1/pinterest/boards`](/networks/pinterest) | | `POST /api/uploadposts/pinterest/boards` | `POST /v1/pinterest/boards` (`name`, `description`, `privacy`) | | `GET` / `POST /api/uploadposts/pinterest/boards/{board_id}/sections` | `GET` / `POST /v1/pinterest/boards/{board_id}/sections` | | `GET /api/uploadposts/tiktok/settings` | `GET /v1/tiktok/creator-info` (the list is `privacy_options`, not `privacy_level_options`) | | `GET /api/uploadposts/instagram/publishing_limit`, `…/threads/publishing_limit` | [`GET /v1/publishing-limits`](/api/publishing-limits) (both networks in one call, or `platform=`) | | `GET /api/uploadposts/reddit/detailed-posts` | [`GET /v1/reddit/posts`](/networks/reddit#list-the-accounts-posts) | | `GET /api/analytics/{profile_username}` | [`GET /v1/analytics/{profile_username}`](/api/analytics) (`days` adds `change`) | | `GET /api/uploadposts/total-impressions/{profile_username}` | [`GET /v1/analytics/{profile_username}/impressions`](/api/analytics#total-impressions) | | `GET /api/uploadposts/post-analytics/{request_id}` | `GET /v1/post-analytics/{request_id}` | | `GET /api/uploadposts/post-analytics?platform_post_id=…` | [`GET /v1/post-analytics?platform_post_id=…`](/api/analytics#any-post-by-the-networks-id) (same `platform` and `user`) | ### Webhooks | Upload-Post | Reelspan | | --- | --- | | `GET` / `POST` / `DELETE /api/uploadposts/users/notifications` | [`GET` / `POST` / `DELETE /v1/webhook`](/webhooks) | | `POST /api/uploadposts/users/webhook-secret` | `POST /v1/webhook/secret` | | `GET` / `POST` / `DELETE /api/uploadposts/users/profile-webhook` | `GET` / `POST` / `DELETE /v1/profile-webhook` | ## 3. Fields and headers that differ | Upload-Post | Reelspan | | --- | --- | | `flair_id` (Reddit) | `reddit_flair_id`. `subreddit` works as before, and so does `reddit_subreddit`. | The rest carry over as they are: the `X-Request-Id` and `X-External-Id` headers ([Retrying safely](/guides/idempotency#your-own-request-id)), the `gbp_*` Google Business fields including `EVENT` and `OFFER` posts, `facebook_media_type=VIDEO`, and PDF, PPT, PPTX, DOC and DOCX documents. Network fields that Reelspan doesn't read are ignored, not refused, so check each network's page under [Networks](/networks/tiktok) for the fields it takes. ## 4. Update your webhook receiver Event names (`upload_completed`, `social_account_connected`, `social_account_disconnected`, `social_account_reauth_required`) and payloads are the same; dotted spellings such as `social_account.connected` are accepted when you subscribe. The headers are renamed, and the signature is built the same way, an HMAC-SHA256 of `"{timestamp}.{raw body}"`: | Upload-Post | Reelspan | | --- | --- | | `X-Upload-Post-Signature` | `X-Reelspan-Signature` | | `X-Upload-Post-Timestamp` | `X-Reelspan-Timestamp` | | `X-Upload-Post-Event` | `X-Reelspan-Event` | | `X-Upload-Post-Delivery` | `X-Reelspan-Delivery` | | User-Agent `Upload-Post-Webhooks/1.0` | User-Agent `Reelspan-Webhooks/1.0` | Register the endpoint again on Reelspan: it gets a new signing secret (`whsec_…`). Reelspan sends webhooks only; there is no Telegram notification channel. See [Webhooks](/webhooks). ## What you get on top - **Test keys and a sandbox network.** A `rs_test_` key runs every check and publishes nothing; the `sandbox` network needs no connected account. See [Test mode](/guides/test-mode). - **Upload once, post many times.** [Upload media](/api/upload-media) returns a `media_id` to use as `video_media_id`, `photo_media_ids[]`, `thumbnail_media_id` or `cover_media_id`, and files over 100 MB go straight to storage in parts. - **Repeats.** `repeat_every` (`day`, `week`, `month`) and `repeat_times` schedule a whole series at once; `DELETE /v1/scheduled/{job_id}?series=rest` cancels the rest of it. - **Finer retries.** [Retry](/api/retry) can send only some networks again, change their settings first, and run now or at a set time. - **Webhook deliveries you can see.** Failed deliveries are retried with backoff, every delivery is in a log, and you can redeliver one or send a test event. - **Approval links.** Send scheduled posts to a client, who approves each one from a link before it goes out. See [Approval links](/api/approval-links). - **More analytics.** Daily account history, best times to post and period reports. - **Official SDKs** for Node.js and Python with retries and idempotent posts built in. See [SDKs](/sdks). ## Not available yet These Upload-Post endpoints have no Reelspan equivalent today: TikTok hashtag suggestions; comments, DMs and AutoDMs; cached bulk post analytics; Google Business location info and reviews. Editing or unpublishing YouTube videos and Threads posts isn't possible either: [Unpublish](/api/unpublish) and [Edit a post](/api/edit-post) list the networks that work. If your integration depends on one of these, [tell us](/support). Source: https://docs.reelspan.com/migrate-from-upload-post --- # Post a video > Upload one video and publish it to any of 22 networks at once, now, at a set time, in the queue, or on repeat, with each network's own settings. Send the video once as `multipart/form-data`. Name the profile in `user` and each network in a repeated `platform[]` field. Reelspan converts and uploads it to every network, then answers with one result per network. ## A first video ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Idempotency-Key: launch-2026-12-01" \ -F "user=my-brand" \ -F "platform[]=tiktok" \ -F "platform[]=youtube" \ -F "platform[]=instagram" \ -F "title=Launch day" \ -F "video=@launch.mp4" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; import { readFile } from 'node:fs/promises'; const reelspan = new Reelspan(); const post = await reelspan.posts.video({ user: 'my-brand', platforms: ['tiktok', 'youtube', 'instagram'], title: 'Launch day', video: { data: await readFile('launch.mp4'), name: 'launch.mp4', type: 'video/mp4' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() post = reelspan.posts.video("my-brand", ["tiktok", "youtube", "instagram"], "launch.mp4", "Launch day") ``` `video` can also be a public https URL instead of a file; Google Drive and Dropbox share links work. To post the same file more than once, upload it first with [Upload media](/api/upload-media) and send `video_media_id`. ## The response The request waits up to 59 seconds for every network. Most finish in that time and you get **200** with `results`, keyed by network: ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "results": { "tiktok": { "success": true, "url": "https://www.tiktok.com/@mybrand/video/7441…", "post_id": "7441…" }, "youtube": { "success": true, "url": "https://youtube.com/shorts/aB3…" }, "instagram": { "success": false, "error": "Instagram is still processing the video", "error_code": "processing_timeout" } } } ``` When a network takes longer, the answer is still **200**, but without `results`: it has a `message`, the `request_id` and `total_platforms`, and the work carries on in the background. Read the outcome with [Post status](/api/post-status) or a [webhook](/webhooks). Send `async_upload=true` to get that answer straight away. ```json { "success": true, "message": "Upload initiated successfully in background.", "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "total_platforms": 3 } ``` A scheduled or queued post answers **202** with its `job_id` instead. > [!TIP] > One network failing never fails the others. Retry only the failed ones with [Retry](/api/retry): the video is not uploaded again. ## Captions per network `title` is the caption every network uses. To say something different on one network, send `{network}_title` (for example `tiktok_title`, `instagram_title`, `x_title`), and `{network}_description` where the network has a separate body (YouTube, LinkedIn, Reddit, WordPress…). YouTube needs a title; it uses `youtube_title`, else `title`. ## Network settings Each network's own options are plain form fields, named after the network: TikTok privacy and interactions, the YouTube category, tags and thumbnail, an Instagram Story instead of a Reel, a Pinterest board, a Reddit subreddit, and so on. The full list for each network, with what it does and its limits, is on that network's page under [Networks](/networks/tiktok). ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=tiktok" -F "platform[]=youtube" \ -F "title=How we ship" \ -F "privacy_level=PUBLIC_TO_EVERYONE" \ -F "youtube_title=How we ship, in 60 seconds" \ -F "privacyStatus=public" \ -F "video=@clip.mp4" ``` ## Later, queued or repeated - **At a time:** `scheduled_date` (ISO 8601) with an optional `timezone`. The answer is 202 with a `job_id`; see [Scheduled posts](/api/scheduled-posts). - **In the queue:** `add_to_queue=true` takes the profile's next free slot; see [Queue](/api/queue). - **On repeat:** `repeat_every` (`day`, `week`, `month`) and `repeat_times` (2 to 30) schedule the whole series at once; see [Scheduling and repeats](/guides/scheduling). ## Safe to retry Send an `Idempotency-Key` header, or your own `request_id`. If the network drops after you sent the request, sending it again with the same key returns the first request's result instead of posting twice. See [Retrying safely](/guides/idempotency). ## Limits The video must be within your plan's upload size and each network's own limits (length, size, aspect ratio). Reelspan checks before uploading and says which network can't take it; see [Video requirements](/limits/video). Source: https://docs.reelspan.com/api/post-video --- # Post photos > Publish one photo or a carousel of up to 35 to Instagram, TikTok, Pinterest, X, LinkedIn and more at once, with alt text and per-network crops. Send one photo or a carousel as `multipart/form-data`, in order, with the profile in `user` and each network in a repeated `platform[]` field. Each network gets the photos in the form it takes, and the answer has one result per network. ## A first carousel ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/photos \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Idempotency-Key: spring-range-01" \ -F "user=my-brand" \ -F "platform[]=instagram" \ -F "platform[]=x" \ -F "platform[]=linkedin" \ -F "title=The spring range is here" \ -F "photos[]=@photo1.jpg" \ -F "photos[]=@photo2.jpg" \ -F "alt_text[]=A red mug on a wooden table" \ -F "alt_text[]=The same mug in blue" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; import { readFile } from 'node:fs/promises'; const reelspan = new Reelspan(); const post = await reelspan.posts.photos({ user: 'my-brand', platforms: ['instagram', 'x', 'linkedin'], title: 'The spring range is here', photos: [ { data: await readFile('photo1.jpg'), name: 'photo1.jpg', type: 'image/jpeg' }, { data: await readFile('photo2.jpg'), name: 'photo2.jpg', type: 'image/jpeg' }, ], altTexts: ['A red mug on a wooden table', 'The same mug in blue'], }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() post = reelspan.posts.photos( "my-brand", ["instagram", "x", "linkedin"], ["photo1.jpg", "photo2.jpg"], "The spring range is here", alt_texts=["A red mug on a wooden table", "The same mug in blue"], ) ``` The answer is the same as for a video: one result per network under `results`, or a `request_id` when it runs on in the background. See [Post status](/api/post-status). ## Where the photos come from `photos[]` takes files, public https URLs, or both. You can also pass `photo_media_ids[]`, files you [uploaded earlier](/api/upload-media), to post the same photos again without sending them twice. - Up to **35** items in all. Extra files are dropped with a warning. - Each image can be up to **20 MB**. Supported images: JPEG, PNG, WebP, GIF and HEIC. - When you mix sources, the carousel order is: uploaded files, then `photo_media_ids[]`, then URLs, each in the order you sent them. - At least one photo is required; without one the answer is **400** `MEDIA_REQUIRED`. - One request can fetch at most 100 URLs, counting every network's own photo URLs too. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/photos \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=threads" \ -F "title=From the shoot" \ -F "photos[]=https://example.com/photo1.jpg" \ -F "photos[]=https://example.com/photo2.jpg" ``` ```javascript title="Node.js" await reelspan.posts.photos({ user: 'my-brand', platforms: ['threads'], title: 'From the shoot', photos: ['https://example.com/photo1.jpg', 'https://example.com/photo2.jpg'], }); ``` ```python title="Python" reelspan.posts.photos("my-brand", ["threads"], ["https://example.com/photo1.jpg", "https://example.com/photo2.jpg"], "From the shoot") ``` ## Which networks take photos Instagram, Facebook, TikTok, X, Threads, LinkedIn, Pinterest, Bluesky, Reddit, Google Business, Telegram, Discord, Slack, Mastodon, Nostr, Lemmy, WordPress, Whop and Listmonk. YouTube, Dev.to and Hashnode don't: naming one gets **400** `UNSUPPORTED_POST_TYPE`. How many photos each network shows, and the sizes and shapes it accepts, are on [Photo requirements](/limits/photos). ## Videos in a carousel Instagram and Threads take videos and photos mixed in one carousel: put the video in `photos[]` (or its id in `photo_media_ids[]`) where it should appear. Every other network gets the photos only, and its result carries a warning that the videos were left out. A network left with no photos at all fails with `media_required`. ## A different set per network A network can get its own photos instead of `photos[]`, for example a square crop for one network: - `{network}_photos[]`: public URLs, such as `pinterest_photos[]`. - `{network}_photo_media_ids[]`: uploaded files, such as `instagram_photo_media_ids[]`. ```bash curl -X POST https://api.reelspan.com/v1/posts/photos \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=instagram" -F "platform[]=pinterest" \ -F "title=New in" \ -F "pinterest_board_id=1234567890" \ -F "photos[]=@photo1.jpg" \ -F "pinterest_photos[]=https://example.com/photo1-2x3.jpg" ``` ## Alt text `alt_text[]` gives one text per photo, in order. A single `alt_text` applies to every photo, and a JSON array in `alt_text` works too. `{network}_alt_text` overrides it for one network (for example `x_alt_text`). Alt text is sent to X, Instagram, Facebook, Threads, LinkedIn, Pinterest and Bluesky. ## Captions `title` is the caption on every network. `{network}_title` replaces it on one network (`instagram_title`, `x_title`, `linkedin_title`…), and `{network}_description` sets the longer text where a network has one. A long caption on X or Bluesky is posted as a thread. ## Network settings The fields below are the ones made for photo posts. Every network's full list is on its page under [Networks](/networks/instagram). | Network | Field | What it does | | --- | --- | --- | | Instagram | `media_type=STORIES` | Shares each photo as a Story instead of a feed post. | | Facebook | `facebook_media_type=STORIES` | Shares each photo as its own Page Story. | | TikTok | `privacy_level` | Who can see it, as for videos. | | TikTok | `photo_cover_index` | Which photo (from 0) is the cover. | | TikTok | `auto_add_music` | `true` lets TikTok add music. | | TikTok | `tiktok_description` | The post's description, up to 4,000 characters. Its title is the first 90 characters of the caption. | | Pinterest | `pinterest_board_id` | The board to pin to. Required. | | Reddit | `reddit_subreddit` | The community to post in. Required. | > [!NOTE] > Instagram takes photos from 4:5 to 1.91:1 and converts non-JPEG photos for you. A Pinterest carousel needs every photo in the same shape; use `pinterest_photos[]` to send it its own crops. ## Later, queued or repeated The same as for a video: `scheduled_date` with `timezone`, `add_to_queue=true`, or `repeat_every` with `repeat_times`. A scheduled answer is **202** with a `job_id`. See [Scheduled posts](/api/scheduled-posts), [Queue](/api/queue) and [Scheduling and repeats](/guides/scheduling). Source: https://docs.reelspan.com/api/post-photos --- # Post text > Publish a text-only post to X, Threads, Bluesky, LinkedIn, Facebook, Reddit, Mastodon and more in one call, with JSON or form fields. Long posts become threads. A text post needs no media, so you can send it as JSON (or as form fields, if that is easier). `title` is the text of the post; name the profile in `user` and the networks in `platform`. ## A first text post ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/text \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: launch-note-2026-12-01" \ -d '{ "user": "my-brand", "platform": ["x", "threads", "bluesky", "linkedin"], "title": "We just shipped scheduled reports. Every Monday, in your inbox." }' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const post = await reelspan.posts.text({ user: 'my-brand', platforms: ['x', 'threads', 'bluesky', 'linkedin'], title: 'We just shipped scheduled reports. Every Monday, in your inbox.', }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() post = reelspan.posts.text("my-brand", ["x", "threads", "bluesky", "linkedin"], "We just shipped scheduled reports. Every Monday, in your inbox.") ``` In JSON, `platform` is an array (`"platform[]"` works as the key too). As a form, repeat `-F "platform[]=x"`. A post without `title` gets **400** `TITLE_REQUIRED`. ## Which networks take text X, Threads, Bluesky, LinkedIn, Facebook, Reddit, Google Business, Telegram, Discord, Slack, Mastodon, Nostr, Lemmy, Dev.to, Hashnode, WordPress, Whop and Listmonk. TikTok, Instagram, YouTube and Pinterest have no text posts: naming one gets **400** `UNSUPPORTED_POST_TYPE`, and nothing is posted. ## Long text and threads On **X** a text longer than 280 characters is split into a thread, and on **Bluesky** one longer than 300; each later post replies to the one before. The result carries a warning saying how many posts the thread has. On X, send `x_long_text_as_post=true` to keep it as one long post instead (this needs an X account that can post long text). > [!WARNING] > Links are removed from X posts unless you send `x_keep_links=true`, because X charges for posts with a link. Keeping links uses a monthly allowance that depends on your plan (Free and Starter have none); past it the links are removed and the result says so. ## Title, description and per-network text `title` is the text everywhere. To say something different on one network, send `{network}_title` (`x_title`, `linkedin_title`, `bluesky_title`…). Some networks also read `description` (or `{network}_description`): | Network | Uses `title` for | Uses `description` for | | --- | --- | --- | | LinkedIn, Facebook | the post, when there is no description | the post text, when given | | Reddit | the post title (its first line, up to 300 characters; or `reddit_title`) | the body (`reddit_description`; else the caption) | | Dev.to, Hashnode, WordPress | the article title (`{network}_title`, else the caption's first line) | the article body (else the caption) | | Lemmy, Listmonk | the title | the body | > [!NOTE] > On LinkedIn and Facebook a `description` wins over `title`. Send only `title` for a plain post there, or set `linkedin_description` / `facebook_description` when you mean to. ## Network settings | Network | Field | What it does | | --- | --- | --- | | Facebook | `facebook_link` | A link Facebook shows as a preview card. | | Reddit | `reddit_subreddit` | The community to post in. Required. | | Reddit | `reddit_flair_id`, `reddit_nsfw`, `reddit_spoiler` | Flair and labels. | | X | `reply_settings`, `reply_to_id` | Who can reply; post as a reply. | | Threads | `threads_reply_control`, `threads_topic_tag` | Who can reply; a topic tag. | | LinkedIn | `visibility` | `PUBLIC`, `CONNECTIONS` or `LOGGED_IN`. | | Mastodon | `mastodon_visibility`, `mastodon_spoiler_text` | Visibility; a content warning. | | WordPress | `wordpress_status` | Publish or keep as a draft. | Each network's page under [Networks](/networks/x) lists everything it takes and the values each field allows. ## First comment `first_comment` is posted right after the post goes live: as a reply on X, Bluesky and Threads, as a comment elsewhere. `{network}_first_comment` overrides it for one network. If a network can't take it, the post still counts and its result carries a warning. ## The response Text posts usually finish well inside the request, so the answer is **200** with `results`, one per network: ```json { "success": true, "request_id": "4c7d1e9a0b2f4e6c8a1d3f5b7e9c0a2d", "results": { "x": { "success": true, "url": "https://x.com/mybrand/status/18…", "post_id": "18…" }, "linkedin": { "success": true, "url": "https://www.linkedin.com/feed/update/urn:li:share:72…", "post_id": "urn:li:share:72…" } }, "usage": { "count": 12, "limit": null, "last_reset": "2026-11-01T00:00:00.000Z" } } ``` If one takes longer than 59 seconds, the answer has a `message` and the `request_id` instead, and you follow it with [Post status](/api/post-status) or a [webhook](/webhooks). ## Later, queued or repeated - **At a time:** `scheduled_date` (ISO 8601) and `timezone`; the answer is **202** with a `job_id`. - **In the queue:** `add_to_queue: true` takes the profile's next free slot; see [Queue](/api/queue). - **On repeat:** `repeat_every` (`day`, `week` or `month`) with `repeat_times` (2 to 30); see [Scheduling and repeats](/guides/scheduling). ```javascript title="Node.js" await reelspan.posts.text({ user: 'my-brand', platforms: ['x', 'linkedin'], title: 'Tip of the week: schedule once, post everywhere.', scheduledDate: '2026-12-07T09:00:00', timezone: 'Europe/London', repeatEvery: 'week', repeatTimes: 6, }); ``` ```python title="Python" reelspan.posts.text( "my-brand", ["x", "linkedin"], "Tip of the week: schedule once, post everywhere.", scheduled_date="2026-12-07T09:00:00", timezone="Europe/London", repeat_every="week", repeat_times=6, ) ``` Source: https://docs.reelspan.com/api/post-text --- # Post a document > Publish a PDF, PowerPoint or Word file to LinkedIn as a swipeable document post, from a file, a link or an earlier upload, now or scheduled. LinkedIn shows a document (PDF, PowerPoint or Word) as a carousel readers swipe through, page by page, under the post. Send the file as `multipart/form-data` with the profile in `user` and `platform[]=linkedin`. ## A first document ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/document \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Idempotency-Key: q3-report-post" \ -F "user=my-brand" \ -F "platform[]=linkedin" \ -F "title=Q3 report" \ -F "description=Our quarter in 12 slides: what grew, what didn't, and what's next." \ -F "document=@q3-report.pdf" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; import { readFile } from 'node:fs/promises'; const reelspan = new Reelspan(); const post = await reelspan.posts.document({ user: 'my-brand', platforms: ['linkedin'], title: 'Q3 report', description: "Our quarter in 12 slides: what grew, what didn't, and what's next.", document: { data: await readFile('q3-report.pdf'), name: 'q3-report.pdf', type: 'application/pdf' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() post = reelspan.posts.document( "my-brand", "q3-report.pdf", "Q3 report", description="Our quarter in 12 slides: what grew, what didn't, and what's next.", ) ``` ## The document - **Format:** PDF, PowerPoint (`.pptx`, `.ppt`) or Word (`.docx`, `.doc`). Reelspan reads the type from the file's own bytes, not its name or `Content-Type`. Other files, spreadsheets included, are refused with **400** `UNSUPPORTED_MEDIA`. - **Size:** up to 100 MB. LinkedIn takes up to 300 pages. - **Where from:** a file in `document`, a public https URL in `document` (Google Drive and Dropbox share links work), or `document_media_id` from an [upload](/api/upload-media) made with `kind=document`. Without any of them the answer is **400** `MEDIA_REQUIRED`. ```bash curl -X POST https://api.reelspan.com/v1/posts/document \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=linkedin" \ -F "title=Q3 report" \ -F "document=https://example.com/q3-report.pdf" ``` ## Title and post text The two texts do different jobs: - `title` names the document; LinkedIn shows it on the document itself. It is required (**400** `TITLE_REQUIRED` without it) and is cut to 400 characters. `document_title` names the document differently from the post's `title` if you need both. - The post text above the document is `linkedin_description`, else `description`, else `linkedin_title`, else `title`. ## LinkedIn only Documents go to LinkedIn only. Naming any other network gets **400** `UNSUPPORTED_POST_TYPE` and nothing is posted. The `sandbox` network doesn't take documents either, so to try this without publishing, use a [test key](/guides/test-mode) with a LinkedIn account connected to the profile. ## Settings | Field | What it does | | --- | --- | | `visibility` | `PUBLIC` (the default), `CONNECTIONS`, `LOGGED_IN` or `CONTAINER`, passed to LinkedIn as given. `linkedin_visibility` works too. | | `first_comment` | Posted as the first comment once the post is live. `linkedin_first_comment` works too. | | `target_linkedin_page_id` | Post as a LinkedIn Company Page instead of the member. | > [!NOTE] > Reelspan doesn't have LinkedIn's company-page access yet, so `GET /v1/linkedin/pages` returns an empty list for now and posting as a Company Page may be refused by LinkedIn. Posting as the connected member works. ## The response LinkedIn processes the document before it can be posted, which can take a while for a long document. When it finishes within the request, the LinkedIn result also names the document: ```json { "success": true, "request_id": "a3e0c5d7b9f14e2a8c6b0d4f2e8a1c3b", "results": { "linkedin": { "success": true, "url": "https://www.linkedin.com/feed/update/urn:li:share:72…/", "post_id": "urn:li:share:72…", "document_urn": "urn:li:document:D4E…", "content_type": "application/pdf", "file_size": 2481152, "filename": "q3-report.pdf" } }, "usage": { "count": 5, "limit": null, "last_reset": "2026-11-01T00:00:00.000Z" } } ``` When it takes longer than 59 seconds, or you sent `async_upload=true`, the answer has a `message` and the `request_id`; follow it with [Post status](/api/post-status) or a [webhook](/webhooks). ## Later or repeated `scheduled_date` with `timezone`, `add_to_queue=true`, or `repeat_every` with `repeat_times` work as for every other post; a scheduled answer is **202** with a `job_id`. See [Scheduled posts](/api/scheduled-posts). Source: https://docs.reelspan.com/api/post-document --- # Upload media > Upload a video, photo or document once and get a media_id to post it many times, and send files over 100 MB straight to storage in parts. Upload a file ahead of the post and you get a `media_id`. Use it in place of the file on any post: the same video to several profiles, a retry with new settings, or captions written from it, without sending the bytes again. Files over 100 MB go up in parts, straight to storage. ## Upload a file Send the file in a multipart field named `file`, and say what it is with `kind` in the query: `video` (the default), `photo` or `document`. ```bash title="cURL" curl -X POST "https://api.reelspan.com/v1/media/upload?kind=video" \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "file=@launch.mp4" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; import { readFile } from 'node:fs/promises'; const reelspan = new Reelspan(); const media = await reelspan.media.upload({ data: await readFile('launch.mp4'), name: 'launch.mp4', type: 'video/mp4' }, { kind: 'video' }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() media = reelspan.media.upload("launch.mp4", kind="video") ``` ```json { "success": true, "media_id": "med_Xb7Qk2LmN4pR8sTv0wYz", "kind": "video", "content_type": "video/mp4", "size_bytes": 48211337, "duration_seconds": 42.5 } ``` `duration_seconds` is filled in for videos and `null` for photos and documents. | `kind` | Accepts | Largest file | | --- | --- | --- | | `video` | MP4, MOV, WebM | Your plan's upload limit ([Plans and limits](/limits/plans)) | | `photo` | JPEG, PNG, WebP, GIF, HEIC | 20 MB | | `document` | PDF, PPTX, PPT, DOCX, DOC | 100 MB | Reelspan checks the file's real type from its contents, not its name. A file of the wrong kind gets **400**; one over the limit gets **413** `FILE_TOO_LARGE`. Add `save=true` (and an optional `title`) to the query to keep the file in the [media library](/api/library) right away. ## Post it by media_id Use the id in the field for its role: | Post | Field | | --- | --- | | [Video](/api/post-video) | `video_media_id` | | Video thumbnail (YouTube) | `thumbnail_media_id` (a `photo` upload) | | Video cover (TikTok) | `cover_media_id` (a `photo` upload) | | [Photos](/api/post-photos) | `photo_media_ids[]`, or `{network}_photo_media_ids[]` for one network; videos work here too for Instagram and Threads carousels | | [Document](/api/post-document) | `document_media_id` (a `document` upload) | | [AI captions](/api/ai-captions) | `media_id` | ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=tiktok" \ -F "platform[]=youtube" \ -F "title=Launch day" \ -F "video_media_id=med_Xb7Qk2LmN4pR8sTv0wYz" ``` ```javascript title="Node.js" await reelspan.posts.video({ user: 'my-brand', platforms: ['tiktok', 'youtube'], title: 'Launch day', videoMediaId: media.media_id }); ``` ```python title="Python" reelspan.posts.video("my-brand", ["tiktok", "youtube"], title="Launch day", video_media_id=media["media_id"]) ``` An id of another workspace, or of the wrong kind (a photo as `video_media_id`), gets **400** `MEDIA_NOT_FOUND`. ### How long an upload lasts - An upload that no post uses is deleted after a day. - Once a post that uses it has finished, the file is kept for a few days (7 by default) so you can [retry](/api/retry), then deleted, unless another waiting post still needs it. - Files kept in the [media library](/api/library) stay until you remove them, so keep anything you'll post again later. ## Large files, in parts A single request carries at most 100 MB, so send bigger files straight to storage in parts. It takes three steps. **1. Start the upload** with the kind and the exact size in bytes. You get a `media_id`, the `part_size`, and one signed URL per part: ```json { "success": true, "kind": "video", "media_id": "med_Q9vT3kLw8ZpX2nB6yR1s", "part_size": 8388608, "parts": [ { "part_number": 1, "url": "https://…" }, { "part_number": 2, "url": "https://…" } ], "expires_in": 3600 } ``` Parts are at least 8 MB, and a file is split into 1,000 parts at most. The size is checked against your plan here (**413** if it's over). **2. Upload each part** with a `PUT` to its URL. Part *n* is the bytes from `(n - 1) × part_size`, `part_size` long; the last part is whatever is left. Each URL is signed for its part's exact length, so a body of any other size is refused. Keep the `ETag` header from each answer, exactly as it comes (with its quotes). Parts can go up in parallel and in any order. The URLs work for `expires_in` seconds (an hour). **3. Complete it** with every part's number and ETag. Reelspan then reads the stored file's real size and type; one that isn't the `kind` you declared, or is over your plan's limit, is deleted and refused with **400** or **413**. The answer is the same as a single upload's, and the `media_id` is ready to post. ```bash title="cURL" FILE=film.mp4 SIZE=$(stat -c%s "$FILE") # macOS: stat -f%z API=https://api.reelspan.com/v1 AUTH="Authorization: Bearer $REELSPAN_API_KEY" START=$(curl -s -X POST "$API/media/uploads" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"kind\": \"video\", \"size_bytes\": $SIZE, \"filename\": \"$FILE\"}") MEDIA_ID=$(echo "$START" | jq -r .media_id) PART_SIZE=$(echo "$START" | jq -r .part_size) PARTS='[]' for row in $(echo "$START" | jq -c '.parts[]'); do N=$(echo "$row" | jq -r .part_number) URL=$(echo "$row" | jq -r .url) ETAG=$(dd if="$FILE" bs="$PART_SIZE" skip=$((N - 1)) count=1 2>/dev/null \ | curl -s -X PUT --data-binary @- -D - -o /dev/null "$URL" \ | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r') PARTS=$(echo "$PARTS" | jq --argjson n "$N" --arg e "$ETAG" '. + [{part_number: $n, etag: $e}]') done curl -X POST "$API/media/uploads/$MEDIA_ID/complete" -H "$AUTH" -H "Content-Type: application/json" \ -d "{\"parts\": $PARTS}" ``` ```javascript title="Node.js" // media.upload switches to parts on its own from 90 MB: three at a time, each retried on its own const media = await reelspan.media.upload(file, { kind: 'video', onProgress: (sent, total) => console.log(Math.round((sent / total) * 100), '%'), }); ``` ```python title="Python" # media.upload switches to parts on its own from 90 MB, reading the file from disk piece by piece media = reelspan.media.upload("film.mp4", kind="video", on_progress=lambda sent, total: print(f"{sent * 100 // total}%")) ``` The SDKs also hand a large file to this flow when you pass it straight to `posts.video`. ### Stop an upload To give up on an upload you started, call `POST /v1/media/uploads/{media_id}/abort`. Storage drops the parts sent so far and the `media_id` stops working. An upload that is never completed expires after 24 hours; completing or aborting it after that gets **404** `UPLOAD_NOT_FOUND`, and you start again. ```bash curl -X POST "https://api.reelspan.com/v1/media/uploads/med_Q9vT3kLw8ZpX2nB6yR1s/abort" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` More on big files in [Large files](/guides/large-files). Source: https://docs.reelspan.com/api/upload-media --- # Post status > Check where a post stands on each network - live with its URL, failed with a reason, or still working - by request_id or by a scheduled post's job_id. Every post gets a `request_id`, and a scheduled one a `job_id` too. Pass either to see the post's overall state and each network's result. Use it after a post moved to the background, after a scheduled time, or after a [retry](/api/retry). ## Check a post ```bash title="cURL" curl "https://api.reelspan.com/v1/posts/status?request_id=9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const status = await reelspan.posts.status({ requestId: '9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1' }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() status = reelspan.posts.status(request_id="9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1") ``` For a scheduled post, send `job_id=sched_…` instead (`{ jobId }` in Node, `job_id=` in Python). Without either, the answer is **400**; an id that doesn't exist, or belongs to a profile your key can't use, is **404** with `"status": "not_found"`. ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "job_id": null, "external_id": "campaign-42", "status": "in_progress", "completed": 2, "total": 3, "results": [ { "platform": "tiktok", "success": true, "status": "completed", "message": "Published", "upload_timestamp": "2026-12-01T09:00:41.000Z", "platform_post_id": "7441…", "post_url": "https://www.tiktok.com/@mybrand/video/7441…", "error_message": null, "error_code": null, "warnings": [], "post_id_resolved_late": false }, { "platform": "instagram", "success": false, "status": "skipped", "message": "Profile my-brand has no Instagram account configured", "upload_timestamp": "2026-12-01T09:00:02.000Z", "platform_post_id": null, "post_url": null, "error_message": "Profile my-brand has no Instagram account configured", "error_code": "profile_platform_mapping_invalid", "warnings": [], "post_id_resolved_late": false, "skipped": true, "skip_reason": "profile_platform_not_configured" } ], "last_update": "2026-12-01T09:00:41.000Z" } ``` ## Overall status | `status` | Meaning | | --- | --- | | `pending` | Waiting: scheduled for later, or not started yet. | | `in_progress` | At least one network has started or finished, and at least one hasn't. | | `completed` | Every network has finished and **at least one** went live. | | `failed` | Every network has finished and none went live. | | `cancelled` | A scheduled post that was cancelled. | > [!IMPORTANT] > `completed` doesn't mean every network succeeded. Read each result's `success`, or compare the count of successes with `total`. `completed` and `total` count networks: how many have finished, and how many the post has. A post made with a [test key](/guides/test-mode) also carries `"test": true`. ## Each network's result `results` lists only the networks that have finished, so it grows as the post goes out. A network finishes in one of three ways: - `completed`: live. `post_url` and `platform_post_id` say where. - `failed`: the network refused it, or kept failing. `error_message` and `error_code` say why; see [Errors](/errors). - `skipped`: never tried, with a `skip_reason`. Most often the profile has no account on that network, or it needs reconnecting. A skipped network isn't charged. `warnings` lists what changed on the way, such as links removed on X or a first comment that couldn't be posted. `post_id_resolved_late` is kept for Upload-Post compatibility and is always `false`. While a network is still working it isn't in `results`. A temporary error (a network timing out, a rate limit) is retried on its own a few times, about 15, 30 and 60 seconds apart, before it counts as failed. [History](/api/history) shows networks still working under `in_progress`. ## Polling Poll every 5 seconds or more, and stop once `status` is `completed`, `failed` or `cancelled`. The SDKs do it for you: ```javascript title="Node.js" const done = await reelspan.posts.wait(requestId, { intervalMs: 5000, timeoutMs: 600_000 }); ``` ```python title="Python" done = reelspan.posts.wait(request_id, timeout=600, interval=5) ``` Both return the last status they saw, finished or not, when the timeout passes. > [!TIP] > Rather than polling, set a [webhook](/webhooks): Reelspan sends an `upload_completed` event as each network finishes. ## Lost the answer? If the network dropped before you got a `request_id`, you can still find the post: - Send your own `request_id` (8 to 100 letters, digits, `_` or `-`) with the post, and poll that. - Or send an `external_id` and look it up in [History](/api/history) with `?external_id=`. - Or send an `Idempotency-Key` and repeat the request: it returns the first one's state instead of posting again. See [Retrying safely](/guides/idempotency). Source: https://docs.reelspan.com/api/post-status --- # History > List every post Reelspan has published or tried, one row per network, filtered by network, outcome, profile, id or date, newest first. History has one row per network post: a video sent to three networks is three rows. Use it to build a "recent posts" view, to find failures, or to look up a post by your own id. ## List recent posts ```bash title="cURL" curl "https://api.reelspan.com/v1/posts?profile_username=my-brand&limit=20" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const page = await reelspan.posts.history({ profile_username: 'my-brand', limit: 20 }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() page = reelspan.posts.history(profile_username="my-brand", limit=20) ``` ```json { "history": [ { "profile_username": "my-brand", "platform": "youtube", "media_type": "video", "upload_timestamp": "2026-12-01T09:01:12.000Z", "success": true, "status": "completed", "platform_post_id": "aB3…", "post_url": "https://youtube.com/shorts/aB3…", "error_message": null, "post_title": "Launch day", "post_caption": "Launch day, in 60 seconds", "is_async": false, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "job_id": null, "external_id": "campaign-42", "request_total_platforms": 3, "post_id_resolved_late": false, "video_was_transcoded": false } ], "in_progress": [], "total": 57, "page": 1, "limit": 20 } ``` ## What a row holds - `status` is `completed`, `failed` or `skipped`; `success` is `true` only for `completed`. - `media_type` is `video`, `photo`, `text` or `document`. - `upload_timestamp` is when that network finished. - `post_title` is the post's `title`; `post_caption` is the text this network got (its `{network}_title`, else `title`). - `is_async` is `true` for posts that were scheduled, queued or sent with `async_upload`. - `request_id`, `job_id` and `external_id` tie the row to the post. Pass `request_id` to [Post status](/api/post-status) or [Retry](/api/retry). - `post_id_resolved_late` and `video_was_transcoded` are kept for Upload-Post compatibility and are `false`. `history` holds finished network posts only. Networks still working are listed separately under `in_progress`, with the same fields: up to 100 of them, from posts made in the last 24 hours. The filters below don't apply to `in_progress`. Scheduled posts that haven't run yet are in [Scheduled posts](/api/scheduled-posts) instead. ## Filters | Parameter | What it does | | --- | --- | | `platform` | One network, like `tiktok`. | | `status` | `success` for live posts; `failed` for failed and skipped ones (cancelled scheduled posts show here as skipped). | | `profile_username` | One profile. `profile` works too. | | `request_id`, `job_id`, `external_id` | Exact match: every network of that post. | | `start`, `end` | A time range on when each network post last changed (normally when it finished). Send both, as dates (`2026-12-01`) or date-times with an offset (`2026-12-01T00:00:00Z`). `end` is not included, and the range can be at most 2 months. | Find every network of one post sent with your own id: ```bash curl "https://api.reelspan.com/v1/posts?external_id=campaign-42" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` Wrong filters get **400**: an unknown `status`, a `limit` other than the allowed values, only one of `start` and `end`, or a range that is backwards or longer than 2 months. ## Pages Rows come newest first, by when each network post last changed. `page` starts at 1 and `limit` is 10 (the default), 20, 50 or 100. There are more pages while `page × limit` is less than `total`. The SDKs walk every page for you: ```javascript title="Node.js" for await (const row of reelspan.posts.all({ platform: 'tiktok', status: 'failed' })) { console.log(row.request_id, row.error_message); } ``` ```python title="Python" for row in reelspan.posts.all(platform="tiktok", status="failed"): print(row["request_id"], row["error_message"]) ``` A key limited to some profiles only sees their posts. Source: https://docs.reelspan.com/api/history --- # Retry > Send a post again to only the networks that failed, now or at a set time, with the media it already has, after fixing a caption or setting if needed. When some networks fail, retry just those. Reelspan reuses the media it already stored, so nothing is uploaded again, and the networks that went live are never touched. ## Retry the failed networks ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/retry \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1"}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const retry = await reelspan.posts.retry('9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1'); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() retry = reelspan.posts.retry("9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1") ``` ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "message": "Failed platforms re-enqueued for retry", "platforms": ["instagram"] } ``` `platforms` lists the networks sent again. They start straight away; follow them with [Post status](/api/post-status) or a [webhook](/webhooks), as for a new post. A scheduled post's `job_id` works in place of `request_id`. ## Only some networks Send `platforms` to retry only those. Networks you don't name stay as they are. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/retry \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platforms": ["tiktok"]}' ``` ```javascript title="Node.js" await reelspan.posts.retry('9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1', ['tiktok']); ``` ```python title="Python" reelspan.posts.retry("9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", ["tiktok"]) ``` ## Fix a setting first If a network failed because of what you sent (a caption too long, a privacy level the account can't use, a video too big for Bluesky), change it in `fields` and retry in one call. The names are the same as the post's own fields; an empty string removes a field. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/retry \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platforms": ["tiktok", "bluesky"], "fields": { "privacy_level": "SELF_ONLY", "bluesky_compress": "true", "tiktok_title": "" } }' ``` ```javascript title="Node.js" await reelspan.request('POST', '/v1/posts/retry', { json: { request_id: '9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1', platforms: ['tiktok', 'bluesky'], fields: { privacy_level: 'SELF_ONLY', bluesky_compress: 'true', tiktok_title: '' }, }, }); ``` ```python title="Python" reelspan.request("POST", "/v1/posts/retry", json_body={ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platforms": ["tiktok", "bluesky"], "fields": {"privacy_level": "SELF_ONLY", "bluesky_compress": "true", "tiktok_title": ""}, }) ``` The change is saved on the post, so it also applies to any later retry. The profile (`user`), the networks (`platform[]`) and the media can't be changed: fields for them are ignored. To post different media, make a new post. ## Retry later Send `scheduled_date` (ISO 8601, in the future and within 365 days) to retry at a set time instead of now, with `timezone` (an IANA zone such as `Europe/London`; UTC by default) when the date has no offset. The post becomes a scheduled post again: it gets a `job_id` and shows in [Scheduled posts](/api/scheduled-posts), where you can move, edit or cancel it like any other. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/retry -H "Authorization: Bearer $REELSPAN_API_KEY" -H "Content-Type: application/json" -d '{ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platforms": ["instagram"], "scheduled_date": "2026-11-08T09:00:00", "timezone": "Europe/London" }' ``` ```javascript title="Node.js" await reelspan.request('POST', '/v1/posts/retry', { json: { request_id: '9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1', platforms: ['instagram'], scheduled_date: '2026-11-08T09:00:00', timezone: 'Europe/London' }, }); ``` ```python title="Python" reelspan.request("POST", "/v1/posts/retry", json_body={ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platforms": ["instagram"], "scheduled_date": "2026-11-08T09:00:00", "timezone": "Europe/London", }) ``` ```json { "success": true, "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "message": "Failed platforms scheduled to retry at 2026-11-08T09:00:00.000Z", "platforms": ["instagram"], "job_id": "sched_3c7a1e9b5d2f48a6b0c4e8d2", "scheduled_date": "2026-11-08T09:00:00.000Z" } ``` A post that was scheduled the first time keeps its `job_id`. A date in the past, more than 365 days ahead, not a date, or an unknown `timezone` gets **400**. Unlike a new scheduled post, the workspace's [default time zone](/api/preferences) isn't used here: without `timezone` the date is read in UTC. ## What gets retried - Networks that **failed**, and networks that were **skipped** (for example because no account was connected yet, or it needed reconnecting). Connect or reconnect the account first, then retry. - Not networks that went live, networks still working, or networks of a scheduled post that was cancelled. ## Credits A network that failed was charged when the post was made, so retrying it costs nothing more. A network that was skipped when the post was made was never charged, so retrying it counts as a new upload, as if you had posted to it now. On a plan with a monthly limit, a retry that would go over it gets **429**. ## When a retry is refused | Status | `error_code` | Why | | --- | --- | --- | | `400` | | Neither `request_id` nor `job_id` was sent, or `scheduled_date` or `timezone` is invalid. | | `404` | | No post with that id (or your key can't use its profile). | | `404` | `retry_media_expired` | The post's media has been deleted. Post it again as new. | | `409` | | The post is still scheduled, or was cancelled. | | `409` | `NOTHING_TO_RETRY` | Every network went live or is still working, or none of the `platforms` you named failed. | | `429` | | The retry would go over your monthly upload limit. | > [!NOTE] > A post's media is kept for a few days after it finishes (7 by default), then deleted unless it is in the [media library](/api/library). Retry within that time, or keep the file in the library if you may need it later. Source: https://docs.reelspan.com/api/retry --- # Unpublish a post > Delete a live post from the network itself, on Facebook, LinkedIn, X, Pinterest, Bluesky, Google Business, Discord, Telegram, Mastodon or WordPress. Unpublish deletes a post from the network it went to, so it disappears there too. It works on posts Reelspan published and on posts made outside Reelspan, as long as the profile's connected account may delete them. > [!WARNING] > Deleting can't be undone, except on WordPress, where the post goes to the site's trash. ## Delete a post Reelspan published Name the post by the `request_id` you got when you posted, and the `platform` to delete it from: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/unpublish \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platform": "linkedin"}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); await reelspan.request('POST', '/v1/posts/unpublish', { json: { request_id: '9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1', platform: 'linkedin' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() reelspan.request("POST", "/v1/posts/unpublish", json_body={ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platform": "linkedin", }) ``` ```json { "success": true, "platform": "linkedin", "post_id": "urn:li:share:72…", "message": "Deleted from LinkedIn" } ``` `platform` can be left out when the request went live on one network only. When it went live on several, the answer without it is **400** `PLATFORM_REQUIRED`, naming them. Each call deletes one network's post: to remove a post everywhere, call once per network. For a post Reelspan published, the network's result in [Post status](/api/post-status) then carries `unpublished: true` and `unpublished_at`. ## Delete any post of the account For a post made outside Reelspan, or when you only have the network's id, send `platform`, the profile in `user`, and the network's `post_id`: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/unpublish \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"platform": "x", "user": "my-brand", "post_id": "1854123456789012345"}' ``` ```javascript title="Node.js" await reelspan.request('POST', '/v1/posts/unpublish', { json: { platform: 'x', user: 'my-brand', post_id: '1854123456789012345' }, }); ``` ```python title="Python" reelspan.request("POST", "/v1/posts/unpublish", json_body={"platform": "x", "user": "my-brand", "post_id": "1854123456789012345"}) ``` `profile_username` works in place of `user`. The `post_id` is the one each network uses, the same as `post_id` in a post's results. ## Which networks | Network | `post_id` | Notes | | --- | --- | --- | | Facebook | The post, Reel or video id | Page posts. The Page is found from the post id, or the chosen Page is used. | | LinkedIn | The post URN (`urn:li:share:…`) | A post already gone counts as deleted. | | X | The post id | | | Pinterest | The Pin id | A Pin already gone counts as deleted. | | Bluesky | The `at://` URI, or a `bsky.app/profile/…/post/…` link | Only the connected account's own posts (`post_not_owned` otherwise). | | Google Business | The post's full name, `accounts/…/locations/…/localPosts/…` | A post already gone counts as deleted. | | Discord | The message id | Only messages the connected webhook sent. A message already gone counts as deleted. | | Telegram | The message id | Telegram lets bots delete messages for 48 hours after sending. | | Mastodon | The status id | | | WordPress | The post id | Moved to the site's trash, where it can be restored for 30 days. | Instagram, TikTok, Threads and YouTube posts can't be deleted through Reelspan: the first three have no delete in their APIs (Threads needs a permission Reelspan doesn't hold), and deleting a YouTube video needs a wider permission than the upload access Reelspan asks for. Asking for one of them gets **400** `platform_not_supported`, with the networks that work in `supported_platforms`. ## Errors | Status | `error_code` | Why | | --- | --- | --- | | `400` | `INVALID_REQUEST` | Neither `request_id`, nor `platform` with `post_id`, was sent. | | `400` | `PLATFORM_REQUIRED` | The request went live on several networks; send `platform`. | | `400` | `platform_not_supported` | The network can't delete posts through its API. `supported_platforms` lists the ones that can. | | `400` | `invalid_post_id` | The `post_id` isn't in the network's shape (Bluesky and Google Business check it). | | `400` | `post_not_owned` | Bluesky: the post belongs to another account. | | `404` | `NOT_FOUND` | No request with that `request_id` (or your key can't use its profile). | | `404` | `POST_NOT_FOUND` | The request has no live post on that network, or the network says the post doesn't exist. | | `404` | `profile_platform_mapping_invalid` | The profile has no account on that network, or the account that published the post was disconnected. | | `409` | `reauth_required` | The account must be [reconnected](/connecting-accounts) first. | | `502` | the network's code | The network refused; `error_code` (such as `x_error` or `meta_error`) and `message` say why, and `retryable` says whether trying again may help. | Unpublishing needs a key with the `publish` scope. To change a post instead of deleting it, see [Edit a post](/api/edit-post). Source: https://docs.reelspan.com/api/unpublish --- # Edit a post > Change the text of a post that is already live, and some settings such as a Pin's link or a WordPress status, on the networks that allow edits. Edit changes a post after it went live, where the network allows it. Most networks let you change only the text; a few take more. To change a post that hasn't gone out yet, edit the [scheduled post](/api/scheduled-posts) instead. ## Edit a post Reelspan published Name the post by its `request_id` (and `platform` when it went to several networks) and send the new `text`: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/edit \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platform": "linkedin", "text": "We are hiring two engineers in Lisbon. Details and the application form are in the comments." }' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const edited = await reelspan.request('POST', '/v1/posts/edit', { json: { request_id: '9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1', platform: 'linkedin', text: 'We are hiring two engineers in Lisbon. Details and the application form are in the comments.', }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() edited = reelspan.request("POST", "/v1/posts/edit", json_body={ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platform": "linkedin", "text": "We are hiring two engineers in Lisbon. Details and the application form are in the comments.", }) ``` ```json { "success": true, "platform": "linkedin", "post_id": "urn:li:share:72…", "url": "https://www.linkedin.com/feed/update/urn:li:share:72…/" } ``` `message` and `commentary` work as other names for `text`. For a post made outside Reelspan, name it with `platform`, `user` and the network's `post_id` instead, as on [Unpublish](/api/unpublish#delete-any-post-of-the-account). ## What you can send | Field | What it changes | | --- | --- | | `text` | The post's text: its caption, message or commentary. | | `title` | A title, where the post has one: a Facebook video or Reel, a Pin, a WordPress post. | | `description` | A Pin's description, a Facebook video's description, or a WordPress post's body. | | `fields` | Network settings by their upload field name, as strings, for example `{"pinterest_link": "https://example.com/new"}`. | Send only what changes. A request that changes nothing the network can change gets **400** `text_required`. ## Which networks, and what changes | Network | What can change | Quirks | | --- | --- | --- | | Facebook | A post's text. A Reel's or video's description (`text` or `description`) and title (`title`, up to 255 characters). | | | LinkedIn | The commentary only (up to 3,000 characters). | Media, visibility and the rest stay as they are. | | X | The text. | Needs X Premium on the account; X allows edits within an hour of posting, up to five times. The edit is a **new post with a new id** (see below). | | Pinterest | `title` (100 characters), `description` or `text` (800), and in `fields`: `pinterest_link`, `pinterest_alt_text` (500), `pinterest_board_id` and `pinterest_board_section_id` to move the Pin. | The image or video can't change. | | Google Business | The text (up to 1,500 characters), and the button with `fields.google_business_cta_url` and `fields.google_business_cta` (`gbp_cta_url` and `gbp_cta_type` work too). | | | Discord | The text (2,000 characters; longer is cut, with a warning). | Only messages the connected webhook sent. Attachments stay. | | Telegram | The text of a message, or the caption of a photo, video or album. | Send `fields.telegram_parse_mode` again if the new text uses formatting. Longer text is cut to Telegram's limits. | | Mastodon | The text, and `fields.mastodon_spoiler_text` and `fields.mastodon_sensitive`. | Attachments stay. | | WordPress | `title`, the body (`text` or `description`), and in `fields`: `wordpress_excerpt` and `wordpress_status` (`publish`, `draft`, `private` or `pending`). | A new body replaces the text and keeps the images and video already in the post. | Instagram, TikTok, Threads, YouTube and Bluesky posts can't be edited through Reelspan, and Reddit posts aren't supported either. Asking gets **400** `platform_not_supported`, with the networks that work in `supported_platforms`. ## X: an edit is a new post X makes an edited post a new post that replaces the old one, so the id changes. The answer gives the new id as `post_id`, the old one as `previous_post_id`, and X's list of every version as `edit_history_post_ids`: ```json { "success": true, "platform": "x", "post_id": "1854123456789099999", "url": "https://x.com/mybrand/status/1854123456789099999", "previous_post_id": "1854123456789012345", "edit_history_post_ids": ["1854123456789012345", "1854123456789099999"] } ``` For a post Reelspan published, the stored post follows the new id, so [Post status](/api/post-status) and analytics use it from then on. Links are removed from the new text, as when posting, unless `fields.x_keep_links` is `"true"`; keeping them uses one of your plan's X link posts for the month, and when the allowance is used up they are removed anyway. See [Links in X posts](/networks/x#links-in-x-posts). ## After an edit For a post Reelspan published, the network's result in [Post status](/api/post-status) carries `edited_at` and `edits` (how many times it was edited), and on X `previous_post_ids`. `warnings` in the answer lists anything changed on the way, such as text cut to fit. ## Errors | Status | `error_code` | Why | | --- | --- | --- | | `400` | `INVALID_REQUEST` | Neither `request_id`, nor `platform` with `post_id`, was sent. | | `400` | `PLATFORM_REQUIRED` | The request went live on several networks; send `platform`. | | `400` | `platform_not_supported` | The network can't edit posts through its API. `supported_platforms` lists the ones that can. | | `400` | `text_required` | Nothing the network can change was sent. | | `400` | `invalid_post_id` | The `post_id` isn't in the network's shape (Google Business checks it). | | `404` | `NOT_FOUND`, `POST_NOT_FOUND` | No such request, no live post on that network, or the network says the post doesn't exist. | | `404` | `profile_platform_mapping_invalid` | The profile has no account on that network, or the account that published the post was disconnected. | | `409` | `reauth_required` | The account must be [reconnected](/connecting-accounts) first. | | `502` | the network's code | The network refused, for example an X account without Premium, or an edit window that has closed. `message` carries the network's reason. | Editing needs a key with the `publish` scope. Source: https://docs.reelspan.com/api/edit-post --- # Repost > Share a post again from a profile's account, as a LinkedIn reshare with commentary, an X or Threads repost, a Bluesky repost or a Mastodon boost. Repost shares a post again from the profile's own account on the same network. The post can be one Reelspan published, one the account made elsewhere, or any public post on the network. ## Repost a post Name the post by the network's `post_id` with `platform` and the profile in `user`: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/repost \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"platform": "bluesky", "user": "my-brand", "post_id": "https://bsky.app/profile/partner.bsky.social/post/3kxyz2abcd"}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const repost = await reelspan.request('POST', '/v1/posts/repost', { json: { platform: 'bluesky', user: 'my-brand', post_id: 'https://bsky.app/profile/partner.bsky.social/post/3kxyz2abcd' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() repost = reelspan.request("POST", "/v1/posts/repost", json_body={ "platform": "bluesky", "user": "my-brand", "post_id": "https://bsky.app/profile/partner.bsky.social/post/3kxyz2abcd", }) ``` ```json { "success": true, "platform": "bluesky", "post_id": "https://bsky.app/profile/partner.bsky.social/post/3kxyz2abcd", "repost_id": "at://did:plc:abc123/app.bsky.feed.repost/3kz…", "url": "https://bsky.app/profile/mybrand.bsky.social", "repost_uri": "at://did:plc:abc123/app.bsky.feed.repost/3kz…", "subject": "at://did:plc:xyz789/app.bsky.feed.post/3kxyz2abcd" } ``` To repost something Reelspan published from the same account, send its `request_id` (and `platform` when it went to several networks) instead. `post_id` in the answer is the post that was reposted; `repost_id` is the new repost, where the network gives it one. ## Which networks | Network | What happens | `post_id` | The answer adds | | --- | --- | --- | --- | | LinkedIn | A reshare: a new post on the member's profile that carries the original, with optional `commentary` above it (up to 3,000 characters). | The post URN | `repost_id` and `url` of the new post, `parent_post_id`, `author` | | X | A repost. X counts reposts against the account's daily write limit. | The post id | `reposted: true`; `repost_id` is the original's id, since X gives no new one | | Threads | A repost. | The Threads post id | `repost_id`; `url` is the account's profile | | Bluesky | A repost. | The `at://` URI, or a `bsky.app/profile/…/post/…` link | `repost_uri`, `subject`; `url` is the account's profile | | Mastodon | A boost. | The status id on the account's own server | `repost_id`, `url` of the boosted post | Other networks get **400** `platform_not_supported`, with the networks that work in `supported_platforms`. A LinkedIn reshare with commentary: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/repost \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "platform": "linkedin", "commentary": "In case you missed it last week: our Q3 report." }' ``` For a post Reelspan published, every repost is added to the network's result in [Post status](/api/post-status), under `reposts`. ## Errors | Status | `error_code` | Why | | --- | --- | --- | | `400` | `INVALID_REQUEST` | Neither `request_id`, nor `platform` with `post_id`, was sent. | | `400` | `PLATFORM_REQUIRED` | The request went live on several networks; send `platform`. | | `400` | `platform_not_supported` | The network can't repost through its API. | | `400` | `invalid_post_id` | Bluesky: `post_id` is neither an `at://` URI nor a `bsky.app` post link. | | `404` | `NOT_FOUND`, `POST_NOT_FOUND` | No such request, no live post on that network, or the network can't find the post. | | `404` | `profile_platform_mapping_invalid` | The profile has no account on that network. | | `409` | `reauth_required` | The account must be [reconnected](/connecting-accounts) first. | | `502` | the network's code | The network refused, for example a post already reposted or X's daily limit. `message` carries the network's reason. | Reposting needs a key with the `publish` scope. Source: https://docs.reelspan.com/api/repost --- # YouTube thumbnail > Set a new custom thumbnail on a YouTube video that is already published, from a file, a public link or an earlier upload, by video id or request_id. Changes the thumbnail of a video already on YouTube. To set one while posting, send `thumbnail` with the video instead (see [YouTube](/networks/youtube)). ## Set a thumbnail Send the image as a multipart `thumbnail` file, with the profile in `user` and the YouTube `video_id`: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/youtube/thumbnail \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "video_id=aB3xYz12Q" \ -F "thumbnail=@thumbnail.jpg" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const out = await reelspan.request('POST', '/v1/youtube/thumbnail', { json: { user: 'my-brand', video_id: 'aB3xYz12Q', thumbnail_url: 'https://example.com/thumbnail.jpg' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() out = reelspan.request("POST", "/v1/youtube/thumbnail", json_body={ "user": "my-brand", "video_id": "aB3xYz12Q", "thumbnail_url": "https://example.com/thumbnail.jpg", }) ``` ```json { "success": true, "video_id": "aB3xYz12Q", "width": 1280, "height": 720, "thumbnails": { "default": { "url": "https://i.ytimg.com/vi/aB3xYz12Q/default.jpg", "width": 120, "height": 90 }, "high": { "url": "https://i.ytimg.com/vi/aB3xYz12Q/hqdefault.jpg", "width": 480, "height": 360 }, "maxres": { "url": "https://i.ytimg.com/vi/aB3xYz12Q/maxresdefault.jpg", "width": 1280, "height": 720 } } } ``` `width` and `height` are those of the largest size YouTube made; `thumbnails` lists every size. ## The image Send one of these; when more than one is sent, the file wins, then the URL: - `thumbnail`: the file, in a `multipart/form-data` body. - `thumbnail_url`: a public https URL (in JSON or multipart). - `thumbnail_media_id`: a photo [uploaded earlier](/api/upload-media) with `kind=photo`. It must be JPEG, PNG, GIF or BMP and at most 2 MB. Reelspan doesn't keep the file afterwards; a `thumbnail_media_id` stays in your uploads. ## Which video - `video_id` and `user` (or `profile_username`): any video on the profile's connected YouTube channel, including ones uploaded outside Reelspan. `post_id` works in place of `video_id`. - Or `request_id`: the Reelspan request that published the video. ## Good to know - **A verified channel.** YouTube allows custom thumbnails only on channels verified at [youtube.com/verify](https://www.youtube.com/verify). On other channels YouTube refuses with 403, which comes back as **502** `youtube_forbidden`. - **Shorts** don't show custom thumbnails on YouTube, even when one is set. - Thumbnails are the only change Reelspan can make to a published video: editing or deleting it needs a wider YouTube permission than the upload access Reelspan asks for. ## Errors | Status | `error_code` | Why | | --- | --- | --- | | `400` | `MEDIA_REQUIRED` | No `thumbnail`, `thumbnail_url` or `thumbnail_media_id` was sent. | | `400` | `UNSUPPORTED_MEDIA` | The image isn't JPEG, PNG, GIF or BMP. | | `400` | `MEDIA_NOT_FOUND` | `thumbnail_media_id` isn't an image of this workspace. | | `400` | `INVALID_REQUEST` | Neither `request_id`, nor `video_id` with `user`, was sent, or the body isn't JSON or multipart. | | `404` | `NOT_FOUND`, `POST_NOT_FOUND` | No such request, or it has no live YouTube video. | | `404` | `profile_platform_mapping_invalid` | The profile has no YouTube channel connected. | | `409` | `reauth_required` | The channel must be [reconnected](/connecting-accounts) first. | | `413` | `FILE_TOO_LARGE` | The image is over 2 MB. | | `502` | `youtube_forbidden` | The channel isn't verified for custom thumbnails, or YouTube refused for another reason in `message`. | | `502` | `youtube_…` | YouTube refused; `message` carries its reason. | Setting a thumbnail needs a key with the `publish` scope. Source: https://docs.reelspan.com/api/youtube-thumbnail --- # Publishing limits > See how many more posts a profile's Instagram and Threads accounts may publish in the next 24 hours, read live from each network's own quota. Instagram and Threads cap how many posts an account can publish through their APIs in a moving 24-hour window. This endpoint asks each network how much of that an account has used, so you can hold a post back instead of watching it fail. ```bash title="cURL" curl "https://api.reelspan.com/v1/publishing-limits?profile=my-brand" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { limits } = await reelspan.request('GET', '/v1/publishing-limits', { query: { profile: 'my-brand' } }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() limits = reelspan.request("GET", "/v1/publishing-limits", query={"profile": "my-brand"})["limits"] ``` ```json { "success": true, "limits": { "instagram": { "posts": { "used": 12, "limit": 50, "remaining": 38, "window_seconds": 86400 }, "limit_reached": false, "fetched_at": "2026-11-07T10:15:02.114Z", "cached": false }, "threads": { "posts": { "used": 4, "limit": 250, "remaining": 246, "window_seconds": 86400 }, "replies": { "used": 31, "limit": 1000, "remaining": 969, "window_seconds": 86400 }, "limit_reached": false, "fetched_at": "2026-11-07T10:15:02.380Z", "cached": false } } } ``` ## The answer - **One entry per network** the profile has connected, of Instagram and Threads. A network that isn't connected is left out. `platform=instagram` or `platform=threads` asks for one only; then a network that isn't connected gets **404** `profile_platform_mapping_invalid`. - `posts`: `used` in the window, the network's `limit`, what is `remaining`, and the window's length in `window_seconds` (24 hours). Threads adds `replies`, counted separately; a `first_comment` on Threads is a reply. - `limit_reached` is `true` when no posts are left. - The numbers come from the networks. Instagram reports a quota of 50 posts, and a carousel counts as one. Threads allows 250 posts and 1,000 replies. The profile goes in `profile` (`profile_username` and `user` work too). ## Caching Each account's answer is kept for 60 seconds, so calls in a burst don't each reach the network: `cached` is `true` on those, and `fetched_at` says when the numbers were read. Add `refresh=true` to read them again now. ## When a network doesn't answer A network that fails doesn't fail the call. It is left out of `limits` and listed under `errors` instead: ```json { "success": true, "limits": { "threads": { "posts": { "used": 4, "limit": 250, "remaining": 246, "window_seconds": 86400 }, "replies": { "used": 31, "limit": 1000, "remaining": 969, "window_seconds": 86400 }, "limit_reached": false, "fetched_at": "2026-11-07T10:15:02.380Z", "cached": false } }, "errors": { "instagram": { "error": "The Instagram account needs to be reconnected", "error_code": "reauth_required" } } } ``` `reauth_required` means the account must be [reconnected](/connecting-accounts); any other code is the network's own. Reading limits needs a key with the `read` scope. See [Network posting limits](/limits/network-limits) for the caps on other networks. Source: https://docs.reelspan.com/api/publishing-limits --- # Scheduled posts > List the posts waiting to go out, preview their media, move one to a new time, change its caption, settings or cover, or cancel it and get credits back. A post sent with `scheduled_date`, `add_to_queue` or `repeat_every` waits here until its time, under a `job_id`. Until a minute before it goes out you can read it, edit it, or cancel it. ## List what's scheduled ```bash title="cURL" curl "https://api.reelspan.com/v1/scheduled?profile_username=my-brand" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { scheduled_posts, total } = await reelspan.scheduled.list({ profile_username: 'my-brand' }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() page = reelspan.scheduled.list(profile_username="my-brand") ``` ```json { "scheduled_posts": [ { "job_id": "sched_5d0e8c1a7b3f49e2a6c4d8b0", "request_id": "2f8a6c4e0b1d4f7a9c3e5b7d1a0c2e4f", "scheduled_date": "2026-12-01T09:00:00.000Z", "original_timezone": "Europe/London", "post_type": "video", "profile_username": "my-brand", "platforms": ["tiktok", "youtube"], "title": "Launch day", "description": null, "external_id": null, "queued": false, "caption": null, "platform_content": { "tiktok": { "title": "Launch day", "description": null }, "youtube": { "title": "Launch day, in 60 seconds", "description": null } }, "fields": { "user": "my-brand", "platform[]": ["tiktok", "youtube"], "title": "Launch day", "youtube_title": "Launch day, in 60 seconds", "scheduled_date": "2026-12-01T09:00:00", "timezone": "Europe/London" }, "has_cover": false, "has_preview": true, "thumbnail_url": "https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0/thumbnail", "preview_url": "https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0/preview", "editable": true, "repeat": null, "approval": null } ], "total": 1, "limit": 50, "offset": 0 } ``` Posts come soonest first. Filter with `profile_username`, and with `from` and `to` (date-times with an offset; `to` not included). Page with `limit` (1 to 200, default 50) and `offset`. Each post shows: - `scheduled_date` in UTC, and `original_timezone`, the zone it was scheduled in. - `post_type`: `video`, `photo`, `text` or `document`. - `caption`: the same as `description` (Upload-Post's name for it). - `platform_content`: the title and description each network will get. - `fields`: every field the post was sent with. - `has_cover`: whether a video post has its own cover image. - `has_preview`: whether the post has media to [preview](#see-the-media). - `thumbnail_url` and `preview_url`: links to the [thumbnail and preview](#see-the-media) endpoints, or `null` when the post has no media (a text post has no thumbnail). They are API links, so call them with your key. - `queued`: `true` when it took a [queue](/api/queue) slot. - `editable`: `false` once it is less than a minute from its time. - `repeat`: for one post of a [repeating series](/guides/scheduling), its `series_id`, its `index`, how many `times` and how often (`every`). - `approval`: when it is in an [approval link](/api/approval-links), the link's id and the client's answer (`pending`, `approved` or `changes`, with their comment). ## Read one `GET /v1/scheduled/{job_id}` returns the same fields plus `status` and `form` (the same as `fields`): ```javascript title="Node.js" const post = await reelspan.scheduled.get('sched_5d0e8c1a7b3f49e2a6c4d8b0'); console.log(post.form.privacy_level); ``` ```python title="Python" post = reelspan.scheduled.get("sched_5d0e8c1a7b3f49e2a6c4d8b0") print(post["form"].get("privacy_level")) ``` ## See the media Two endpoints show what a scheduled post will publish, for a calendar or a review screen. `GET /v1/scheduled/{job_id}/thumbnail` gives a small JPEG, 480 px wide: a frame of the video, or the cover or first photo. It answers with a **302** redirect to a signed link valid for an hour; the request itself needs your key, the signed link doesn't. Add `?format=json` to get the link instead, for example to put in an `` tag: ```bash title="cURL" curl "https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0/thumbnail?format=json" -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" const { thumbnail_url } = await reelspan.request('GET', '/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0/thumbnail', { query: { format: 'json' } }); ``` ```python title="Python" thumb = reelspan.request("GET", "/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0/thumbnail", query={"format": "json"}) ``` ```json { "success": true, "thumbnail_url": "https://…", "expires_in": 3600 } ``` The first request makes the image, so it can take a few seconds. A text post, or one with no media, gets **404** `NO_MEDIA`; when no image can be made from the media, the answer is **404** `THUMBNAIL_UNAVAILABLE`. `GET /v1/scheduled/{job_id}/preview` lists every file the post will publish, each with a signed link valid for an hour: ```json { "success": true, "job_id": "sched_5d0e8c1a7b3f49e2a6c4d8b0", "expires_in": 3600, "media": [ { "role": "video", "media_id": "med_…", "kind": "video", "content_type": "video/mp4", "size_bytes": 18350212, "url": "https://…" }, { "role": "cover", "media_id": "med_…", "kind": "photo", "content_type": "image/jpeg", "size_bytes": 241337, "url": "https://…" } ] } ``` `role` is `video`, `photo`, `document`, `cover`, `thumbnail`, or `_photo` for photos sent to one network only. ## Reschedule or edit `PATCH /v1/scheduled/{job_id}` changes the time, the text, any setting and a video's cover. Send only what changes: | Field | What it does | | --- | --- | | `scheduled_date` | The new time, ISO 8601, at least a minute ahead and within 365 days. | | `timezone` | The zone `scheduled_date` is in. Defaults to the zone the post was scheduled in. | | `title`, `description` | The caption and the longer text. `caption` is another name for `description`. | | `platform_content` | Text for one network: `{ "tiktok": { "title": "…" } }` sets `tiktok_title`, `description` sets `tiktok_description`. `null` removes one, so the network falls back to the overall text. Naming a network the post doesn't go to gets **400**. | | `fields` | Any other post field, such as `{ "youtube_title": "…", "privacy_level": "SELF_ONLY" }`. `null` removes one. | | `cover_image_url`, `cover_image_base64`, `cover_media_id` | A new cover for a video post (JPEG, PNG or WebP, up to 20 MB): a public https URL, base64 (a `data:` prefix is fine), or an image [uploaded](/api/upload-media) with `kind=photo`. | | `remove_cover` | `true` removes the cover, so the networks pick a frame themselves. | Send at most one of the four cover fields; more gets **400**. Covers are for video posts only: on a photo, text or document post the answer is **400**. ```bash title="cURL" curl -X PATCH https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0 \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "scheduled_date": "2026-12-02T18:30:00", "timezone": "Europe/London", "fields": { "youtube_title": "Launch day, one day late", "tiktok_title": null } }' ``` ```javascript title="Node.js" await reelspan.scheduled.edit('sched_5d0e8c1a7b3f49e2a6c4d8b0', { scheduled_date: '2026-12-02T18:30:00', timezone: 'Europe/London', fields: { youtube_title: 'Launch day, one day late', tiktok_title: null }, }); ``` ```python title="Python" reelspan.scheduled.edit( "sched_5d0e8c1a7b3f49e2a6c4d8b0", scheduled_date="2026-12-02T18:30:00", timezone="Europe/London", fields={"youtube_title": "Launch day, one day late", "tiktok_title": None}, ) ``` To change only the TikTok caption and the cover: ```bash title="cURL" curl -X PATCH https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0 -H "Authorization: Bearer $REELSPAN_API_KEY" -H "Content-Type: application/json" -d '{ "platform_content": { "tiktok": { "title": "Launch day 🚀 #launch" } }, "cover_image_url": "https://example.com/launch-cover.jpg" }' ``` ```javascript title="Node.js" await reelspan.request('PATCH', '/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0', { json: { platform_content: { tiktok: { title: 'Launch day 🚀 #launch' } }, cover_image_url: 'https://example.com/launch-cover.jpg', }, }); ``` ```python title="Python" reelspan.scheduled.edit( "sched_5d0e8c1a7b3f49e2a6c4d8b0", platform_content={"tiktok": {"title": "Launch day 🚀 #launch"}}, cover_image_url="https://example.com/launch-cover.jpg", ) ``` The answer is the updated post. Some things are fixed once a post is scheduled: - The profile, the networks and the video or photos can't change. Sending `user`, `platform[]`, `video`, `photos[]` or `request_id` in `fields` gets **400**. To change them, cancel the post and make a new one. - A queued post that you give a `scheduled_date` leaves the queue and keeps that exact time. - Editing a post that is in an [approval link](/api/approval-links) sends it back to the client for approval. ## Cancel `DELETE /v1/scheduled/{job_id}` cancels the post, and the upload credits it reserved come back. ```bash title="cURL" curl -X DELETE https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0 \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" await reelspan.scheduled.cancel('sched_5d0e8c1a7b3f49e2a6c4d8b0'); ``` ```python title="Python" reelspan.scheduled.cancel("sched_5d0e8c1a7b3f49e2a6c4d8b0") ``` ```json { "success": true, "message": "Job sched_5d0e8c1a7b3f49e2a6c4d8b0 cancelled and assets deleted.", "credits_refunded": 2 } ``` `credits_refunded` is the number of networks given back (0 for a test post, which never took any). The post's networks show as skipped in [History](/api/history), with the error code `cancelled`. ### Cancel the rest of a series Each post of a repeating series is its own scheduled post with its own `job_id`, so you can edit or cancel one without touching the others. To stop the series from one post onwards, add `series=rest`: that post and every later one still waiting are cancelled. ```bash title="cURL" curl -X DELETE "https://api.reelspan.com/v1/scheduled/sched_5d0e8c1a7b3f49e2a6c4d8b0?series=rest" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" await reelspan.scheduled.cancel('sched_5d0e8c1a7b3f49e2a6c4d8b0', { withLaterRepeats: true }); ``` ```python title="Python" reelspan.scheduled.cancel("sched_5d0e8c1a7b3f49e2a6c4d8b0", with_later_repeats=True) ``` ```json { "success": true, "message": "4 posts of the series cancelled.", "credits_refunded": 8, "cancelled": 4 } ``` Earlier posts of the series are left alone, and so is any that is already publishing or due within a minute. On a post that isn't part of a series, `series=rest` cancels just that post. ## Too late to change Once a post is publishing, or is less than a minute from its time, an edit or a cancel gets **409** with `error_code: "scheduled_post_in_progress"`. Check `editable` first if you are close to the time. A post still waiting for a client's approval is the exception: it can be edited or cancelled even after its time has passed. An unknown `job_id`, or one of a profile your key can't use, gets **404**. Source: https://docs.reelspan.com/api/scheduled-posts --- # Queue > Give each profile a posting schedule of days and times, then add posts with add_to_queue and they take the next free slot. Preview slots and block busy ones. The queue saves you picking a time for every post. Each profile has a weekly schedule of slots; a post sent with `add_to_queue=true` takes the first slot that still has room. ## Add a post to the queue Send any post with `add_to_queue=true` instead of a `scheduled_date`: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=tiktok" \ -F "platform[]=instagram" \ -F "title=Behind the scenes" \ -F "add_to_queue=true" \ -F "video=@launch.mp4" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; import { readFile } from 'node:fs/promises'; const reelspan = new Reelspan(); const post = await reelspan.posts.video({ user: 'my-brand', platforms: ['tiktok', 'instagram'], title: 'Behind the scenes', addToQueue: true, video: { data: await readFile('launch.mp4'), name: 'launch.mp4', type: 'video/mp4' }, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() post = reelspan.posts.video("my-brand", ["tiktok", "instagram"], "launch.mp4", "Behind the scenes", add_to_queue=True) ``` ```json { "success": true, "job_id": "sched_7a2c9e4b1d6f40a8c3e5b9d2", "request_id": "6e3b9d1f7a2c4e8b0d5f3a1c9e7b2d4f", "scheduled_date": "2026-12-01T12:00:00.000Z", "queue_slot": "2026-12-01T12:00:00.000Z", "message": "Post added to queue" } ``` The answer is **202**, and from then on it is an ordinary [scheduled post](/api/scheduled-posts): edit or cancel it by `job_id`. Giving it a `scheduled_date` later takes it out of the queue. - `add_to_queue` and `scheduled_date` can't be used together (**400**). - `max_posts_per_slot` on the post overrides the profile's setting for this one post (1 to 100). - The queue looks 30 days ahead. With no free slot in that time the answer is **400** `QUEUE_FULL`. - Two posts sent at once never take the same last place in a slot. ## The schedule Until you set one, every profile has this schedule: ```json { "timezone": "America/New_York", "slots": [{ "hour": 9, "minute": 0 }, { "hour": 12, "minute": 0 }, { "hour": 17, "minute": 0 }], "days_of_week": [0, 1, 2, 3, 4, 5, 6], "max_posts_per_slot": 1, "full_slots": [] } ``` | Setting | What it is | | --- | --- | | `timezone` | The IANA zone the slots are in. | | `slots` | Up to 24 times of day, as `hour` (0 to 23) and `minute` (0 to 59). | | `days_of_week` | The days the slots run, `0` = Monday to `6` = Sunday. | | `max_posts_per_slot` | How many posts one slot holds, 1 to 100. | | `full_slots` | Slots you marked full by hand (see below). | Read it with `GET /v1/queue/settings?profile_username=my-brand`. Change it with `POST /v1/queue/settings`; send only what changes, and the rest stays: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/queue/settings \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profile_username": "my-brand", "timezone": "Europe/London", "slots": [{ "hour": 8, "minute": 30 }, { "hour": 18, "minute": 0 }], "days_of_week": [0, 1, 2, 3, 4] }' ``` ```javascript title="Node.js" await reelspan.queue.setSettings('my-brand', { timezone: 'Europe/London', slots: [{ hour: 8, minute: 30 }, { hour: 18, minute: 0 }], days_of_week: [0, 1, 2, 3, 4], }); ``` ```python title="Python" reelspan.queue.set_settings( "my-brand", timezone="Europe/London", slots=[{"hour": 8, "minute": 30}, {"hour": 18, "minute": 0}], days_of_week=[0, 1, 2, 3, 4], ) ``` Duplicate slots are dropped and the list is sorted. Posts already in the queue keep their times when the schedule changes. ## How full is each slot `GET /v1/queue/preview` lists the next slots (10 by default, `count` up to 50) and how many posts each holds: ```bash title="cURL" curl "https://api.reelspan.com/v1/queue/preview?profile_username=my-brand&count=3" \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" const preview = await reelspan.queue.preview('my-brand'); ``` ```python title="Python" preview = reelspan.queue.preview("my-brand") ``` ```json { "success": true, "timezone": "Europe/London", "max_posts_per_slot": 1, "slots": [ { "datetime_utc": "2026-12-01T08:30:00.000Z", "datetime_local": "2026-12-01T08:30:00", "available": false, "post_count": 1, "max_posts_per_slot": 1, "is_full": true, "manually_full": false }, { "datetime_utc": "2026-12-01T18:00:00.000Z", "datetime_local": "2026-12-01T18:00:00", "available": true, "post_count": 0, "max_posts_per_slot": 1, "is_full": false, "manually_full": false }, { "datetime_utc": "2026-12-02T08:30:00.000Z", "datetime_local": "2026-12-02T08:30:00", "available": true, "post_count": 0, "max_posts_per_slot": 1, "is_full": false, "manually_full": false } ], "next_available": "2026-12-01T18:00:00.000Z" } ``` `post_count` counts every post of the profile scheduled for exactly that time, queued or not. A slot less than a minute away isn't offered. For just the next free slot, `GET /v1/queue/next-slot` answers `{ "next_slot": { "datetime_utc", "datetime_local", "timezone" } }`, or `"next_slot": null` when nothing is free in the next 30 days. ## Keep a slot free Mark a slot full and the queue skips it, for example to keep a launch slot for one hand-picked post. `slot_datetime` is the slot's time with an offset, as `datetime_utc` shows it: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/queue/slot-full \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "slot_datetime": "2026-12-01T18:00:00Z"}' ``` ```javascript title="Node.js" await reelspan.queue.markFull('my-brand', '2026-12-01T18:00:00Z'); await reelspan.queue.markOpen('my-brand', '2026-12-01T18:00:00Z'); // open it again ``` ```python title="Python" reelspan.queue.mark_full("my-brand", "2026-12-01T18:00:00Z") reelspan.queue.mark_open("my-brand", "2026-12-01T18:00:00Z") # open it again ``` Both answer with `full_slots`, the slots now marked. `DELETE /v1/queue/slot-full` with the same body opens a slot again. Past slots drop off the list on their own. ## Keys and scopes Reading the queue needs the `read` scope; changing the schedule or marking slots needs `profiles`; adding a post needs `publish`. Source: https://docs.reelspan.com/api/queue --- # Approval links > Send scheduled posts to a client as one link they open without an account. Each post waits for their approval, and they can ask for changes instead. An approval link puts a client in the loop before anything goes out. Schedule the posts as usual, put them in a link, and send the link to the client. They see each post as it will appear and approve it or ask for changes. A post in a link doesn't publish until it is approved. ## Create a link Name the profile and the scheduled posts, by `job_ids` or `request_ids`: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/approval-links \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "profile_username": "my-brand", "job_ids": ["sched_5d0e8c1a7b3f49e2a6c4d8b0", "sched_7a2c9e4b1d6f40a8c3e5b9d2"], "title": "December posts", "note": "Two posts for the launch week. Approve each, or tell us what to change.", "expires_in_days": 7 }' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { approval_link } = await reelspan.approvals.create({ user: 'my-brand', jobIds: ['sched_5d0e8c1a7b3f49e2a6c4d8b0', 'sched_7a2c9e4b1d6f40a8c3e5b9d2'], title: 'December posts', note: 'Two posts for the launch week. Approve each, or tell us what to change.', expiresInDays: 7, }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() link = reelspan.approvals.create( "my-brand", ["sched_5d0e8c1a7b3f49e2a6c4d8b0", "sched_7a2c9e4b1d6f40a8c3e5b9d2"], title="December posts", note="Two posts for the launch week. Approve each, or tell us what to change.", expires_in_days=7, )["approval_link"] ``` ```json { "success": true, "approval_link": { "id": "apl_Hk3mP9qT2vX7wZ1bN5cR", "title": "December posts", "note": "Two posts for the launch week. Approve each, or tell us what to change.", "url": "https://app.reelspan.com/approve/…", "expires_at": "2026-12-08T10:15:00.000Z", "created_at": "2026-12-01T10:15:00.000Z", "posts": { "pending": 2, "approved": 0, "changes": 0 } } } ``` Send `url` to the client. It works without a Reelspan account until `expires_at`, so treat it like a password: anyone with the link can answer for the client. - Up to 100 posts per link, all scheduled and all of the named profile. Any other post gets **400** `NOT_SCHEDULED`; an id that doesn't exist gets **404**. - `title` is up to 120 characters (default "Posts to approve"); `note`, shown above the posts, up to 1,000. - `expires_in_days` is 1 to 60, 14 by default. - A post can be in one link at a time. Putting it in a new link takes it out of the old one. The SDKs take `job_ids`; to pass `request_ids`, use `request('POST', '/v1/approval-links', …)`. ## What the client sees The link opens a page with the link's title, your note, and each post in time order: - the networks it goes to, and when, in the time zone the posts were scheduled in; - the video, or up to four of the photos; - the caption; - **Approve** and **Request changes**. Asking for changes needs a comment saying what should change. The client can change an answer until the post goes out. On a plan with white-label, the page shows the profile's logo and says it was sent by the brand; otherwise it says it was sent with Reelspan. It is shown in the profile's white-label language if one is set, else in the browser's. An expired or deleted link shows a short message instead. ## How a post waits - **Pending or changes asked:** the post doesn't publish. When its time comes it keeps waiting, still listed in [Scheduled posts](/api/scheduled-posts). - **Approved before its time:** it publishes at its scheduled time as usual. - **Approved after its time:** it publishes at once. - **Edited:** changing a post with `PATCH /v1/scheduled/{job_id}` sends it back to the client as pending. A waiting post can be edited, and given a new time, even after its original time has passed. - **Cancelled:** cancel it as any scheduled post; the page then shows it as cancelled, with no buttons. ## Follow the answers `GET /v1/approval-links` lists links newest first (up to 100), each with its counts. Filter by `profile_username`: ```javascript title="Node.js" const { approval_links } = await reelspan.approvals.list('my-brand'); for (const l of approval_links) console.log(l.title, l.posts); ``` ```python title="Python" for l in reelspan.approvals.list("my-brand")["approval_links"]: print(l["title"], l["posts"]) ``` Each post's own answer is on the post: `GET /v1/scheduled` and `GET /v1/scheduled/{job_id}` include `approval` with the link's id, the `status` (`pending`, `approved` or `changes`), the client's `comment` and when they decided. Every answer also shows as a notification in the dashboard. ## Delete a link `DELETE /v1/approval-links/{id}` removes the link. Its posts stop waiting: they publish at their scheduled times, and any whose time has already passed publish at once. ```bash title="cURL" curl -X DELETE https://api.reelspan.com/v1/approval-links/apl_Hk3mP9qT2vX7wZ1bN5cR \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" await reelspan.approvals.delete('apl_Hk3mP9qT2vX7wZ1bN5cR'); ``` ```python title="Python" reelspan.approvals.delete("apl_Hk3mP9qT2vX7wZ1bN5cR") ``` > [!WARNING] > When a link expires, the client can no longer open it, but its unanswered posts keep waiting. Put them in a new link, or delete the old one to let them publish. Creating and deleting links needs the `publish` scope; listing them needs `read`. Source: https://docs.reelspan.com/api/approval-links --- # Webhooks > Get a signed POST on your server as each network's post goes live or fails, and when accounts connect or need reconnecting. Events, payloads, signatures, retries. Instead of polling for results, give Reelspan a URL. It sends a signed JSON `POST` there each time a network's post finishes and each time an account is connected, disconnected or needs reconnecting. ## Events | Event | Sent when | | --- | --- | | `upload_completed` | One network's post went live or failed. A post to three networks sends three. | | `social_account_connected` | An account was connected (or reconnected) to a profile. | | `social_account_disconnected` | An account was disconnected from a profile. | | `social_account_reauth_required` | A network signed Reelspan out of an account; posts to it fail until it is reconnected. | Skipped networks (no account connected, for example) don't send `upload_completed`. A test event, `test`, is sent only when you ask for one. ## Set up an endpoint Set the account webhook in the dashboard under **Settings**, or with the API. Your URL must use `https://` and be publicly reachable. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/webhook \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "webhook_url": "https://example.com/reelspan", "webhook_events": ["upload_completed", "social_account_reauth_required"] }' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { notifications } = await reelspan.webhooks.set('https://example.com/reelspan', ['upload_completed', 'social_account_reauth_required']); console.log(notifications.webhook_secret); // keep this to check signatures ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() notifications = reelspan.webhooks.set("https://example.com/reelspan", ["upload_completed", "social_account_reauth_required"])["notifications"] print(notifications["webhook_secret"]) # keep this to check signatures ``` ```json { "success": true, "notifications": { "channels": { "webhook": true }, "webhook_url": "https://example.com/reelspan", "webhook_events": { "upload_completed": true, "social_account_connected": false, "social_account_disconnected": false, "social_account_reauth_required": true }, "webhook_secret": "whsec_…", "consecutive_failures": 0 } } ``` - Leave out `webhook_events` to get all four. It also takes an object of booleans (`{ "upload_completed": true }`) and dotted names (`social_account.connected`). An unknown event name gets **400** `INVALID_WEBHOOK`. - Saving again changes the URL or events and keeps the signing secret. `GET /v1/webhook` reads the settings, secret included; `DELETE /v1/webhook` (or `"channels": { "webhook": false }`) removes the webhook. - The key needs the `webhooks` scope, which is off by default when you create a key in the dashboard. The account webhook also needs a key that isn't limited to some profiles. ### A webhook per profile A profile can have its own endpoint, sent **in addition to** the account one, for that profile's events only. Useful when each client has their own server. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/profile-webhook \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "client-a", "webhook_url": "https://client-a.example.com/hooks"}' ``` ```javascript title="Node.js" const { webhook } = await reelspan.webhooks.profile.set('client-a', 'https://client-a.example.com/hooks'); ``` ```python title="Python" webhook = reelspan.webhooks.profile.set("client-a", "https://client-a.example.com/hooks")["webhook"] ``` It has its own `webhook_secret`, in the answer under `webhook`. Read it with `GET /v1/profile-webhook?profile_username=client-a` and remove it with `DELETE /v1/profile-webhook` and `{"profile_username": "client-a"}` as the body. A key limited to some profiles can manage the webhooks of those profiles. ## Payloads Every delivery is a JSON body with `event`, the event's data and `created_at`. ### upload_completed ```json { "event": "upload_completed", "request_id": "9b1f6c2e8a3d4f50b7e1c9a2d6f3e8b1", "job_id": null, "external_id": "campaign-42", "profile_username": "my-brand", "platform": "tiktok", "media_type": "video", "title": "Launch day", "caption": "Launch day #behindthescenes", "result": { "success": true, "url": "https://www.tiktok.com/@mybrand/video/7441…", "post_id": "7441…", "error": null, "error_code": null }, "created_at": "2026-12-01T09:00:41.512Z" } ``` - `platform` is the one network this event is about; `media_type` is `video`, `photo`, `text` or `document`. - `title` is the post's `title`; `caption` is the text this network got (its `{network}_title`, else `title`). - `result` says how it went: `url` and `post_id` when it went live, `error` and `error_code` when it failed. It can carry more keys from the network, such as `first_comment`. A [test key](/guides/test-mode) post arrives with `"success": true`, `"test": true`, a `post_id` starting with `test_` and no `url`. - `request_id`, `job_id` and `external_id` tie it back to the post you made. ### Account events ```json { "event": "social_account_reauth_required", "platform": "instagram", "account_name": "mybrand", "status": "reauth_required", "profile_username": "my-brand", "reason": "token_refresh_failed", "created_at": "2026-12-01T09:00:41.512Z" } ``` `status` is `connected`, `disconnected` or `reauth_required`. `reason` is there when Reelspan knows it: `manual_disconnect` on a disconnect, `token_refresh_failed` or `permission_revoked` when reconnecting is needed. ### test ```json { "event": "test", "message": "Webhook test from Reelspan", "created_at": "2026-12-01T09:00:41.512Z" } ``` Send one with `POST /v1/webhook/test` (or `reelspan.webhooks.test()`); the answer has its `delivery_id`. It goes to the account webhook. ## Headers | Header | Value | | --- | --- | | `Content-Type` | `application/json` | | `User-Agent` | `Reelspan-Webhooks/1.0` | | `X-Reelspan-Event` | The event name. | | `X-Reelspan-Delivery` | The delivery's id. It stays the same across retries, so use it to ignore a delivery you've already handled. | | `X-Reelspan-Timestamp` | Unix seconds when this attempt was sent. | | `X-Reelspan-Signature` | `sha256=` and the hex HMAC-SHA256 of `"{timestamp}.{raw body}"`, keyed with your signing secret. | ## Check the signature Check every delivery before you trust it: compute the HMAC over the timestamp, a dot and the **raw** body (before any JSON parsing), compare it with the header in constant time, and reject timestamps more than a few minutes old so an old delivery can't be replayed. The SDKs do all of it, with a 5-minute window: ```javascript title="Node.js" 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); const event = JSON.parse(req.body.toString()); // handle the event after answering }); ``` ```python title="Python" 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 event = request.get_json() # queue the event and answer straight away return "", 200 ``` Without the SDK, it is a few lines in any language: ```javascript title="Node.js" import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(rawBody, signature, timestamp, secret) { if (!signature || !/^\d+$/.test(timestamp ?? '')) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older than 5 minutes const expected = 'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'); return signature.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } ``` ```python title="Python" import hashlib import hmac import time def verify(raw_body: bytes, signature: str | None, timestamp: str | None, secret: str) -> bool: if not signature or not timestamp or not timestamp.isdigit(): return False if abs(time.time() - int(timestamp)) > 300: # older than 5 minutes return False signed = f"{timestamp}.".encode() + raw_body expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) ``` > [!WARNING] > Sign the body exactly as it arrived. A framework that parses JSON and you then re-serialise it will change the bytes and the check will fail. ## Answer quickly Answer with any `2xx` within 10 seconds. Anything else counts as a failed attempt: another status, a timeout, a connection error, or a redirect (redirects aren't followed). Do slow work after you answer, and make your handler safe to run twice for the same `X-Reelspan-Delivery`. ## Retries A failed delivery is tried again, up to 8 attempts in all, waiting longer each time: | After attempt | Next attempt in | | --- | --- | | 1 | 1 minute | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 6 hours | | 6 | 12 hours | | 7 | 24 hours | Each attempt has a fresh timestamp and signature. Retries mean deliveries can arrive out of order, so use `created_at` and the post's status, not arrival order. If something keeps failing, the workspace gets a dashboard notification (at most one a day per endpoint). After 20 deliveries in a row have failed every attempt, the endpoint is paused (`GET /v1/webhook` then shows `"channels": { "webhook": false }`) and you get a notification and an email. Fix it, then save the webhook again (`POST /v1/webhook` or the dashboard) to turn it back on. ## Delivery log `GET /v1/webhook/deliveries` lists the workspace's recent deliveries, newest first, for the account and every profile webhook. `limit` is 1 to 200 (default 50). ```javascript title="Node.js" const { deliveries } = await reelspan.webhooks.deliveries(); for (const d of deliveries.filter((d) => d.status === 'failed')) await reelspan.webhooks.redeliver(d.id); ``` ```python title="Python" for d in reelspan.webhooks.deliveries()["deliveries"]: if d["status"] == "failed": reelspan.webhooks.redeliver(d["id"]) ``` ```json { "success": true, "deliveries": [ { "id": "dlv_Wm4Kq8Zt1Rb6Yn3Px9Cv", "event": "upload_completed", "status": "failed", "attempts": 8, "last_status_code": 500, "last_error": "HTTP 500", "created_at": "2026-11-29T09:00:41.512Z", "delivered_at": null } ] } ``` `status` is `pending` (still being tried), `succeeded` or `failed` (every attempt failed). ### Redeliver `POST /v1/webhook/deliveries/{id}/redeliver` sends a delivery again as a fresh series of attempts, with the same payload and delivery id. Test events and redeliveries together are limited to 10 a minute and 100 an hour per workspace; past that the answer is **429** `RATE_LIMITED`. ## Rotate the secret `POST /v1/webhook/secret` gives the account webhook a new signing secret and returns it as `webhook_secret`. The old one stops working at once, so deploy the new secret to your receiver straight after. Without an account webhook the answer is **404**. ```javascript title="Node.js" const { webhook_secret } = await reelspan.webhooks.rotateSecret(); ``` ```python title="Python" webhook_secret = reelspan.webhooks.rotate_secret()["webhook_secret"] ``` Source: https://docs.reelspan.com/webhooks --- # Authentication > Create a Reelspan API key, send it as a Bearer token, limit it to scopes and profiles, set it to expire, and use test keys that never publish. Every request to the Reelspan API carries an API key in the `Authorization` header. A key belongs to one workspace. You can limit what it may do (scopes), which profiles it may use, and when it stops working. ```bash title="cURL" curl https://api.reelspan.com/v1/me \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); // reads REELSPAN_API_KEY const me = await reelspan.me(); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() # reads REELSPAN_API_KEY me = reelspan.me() ``` `GET /v1/me` is the quickest way to check a key: it answers 200 with your plan and usage, or 401. See [Current account](/api/me). ## Create a key Keys are created in the dashboard, under **Developers → API keys**. Only workspace owners and admins can create, rename or revoke them. When you create a key you choose: - **Name**: so you can tell your keys apart, for example `n8n uploader`. - **Test key**: tick it for a `rs_test_` key that never publishes (see [Test keys](#test-keys)). - **Access**: everything, or only some [scopes](#scopes). - **Profiles**: all profiles, or only some (shown when the workspace has more than one profile). - **Expires**: never, in 30 days, in 90 days, or in a year. A live key starts with `rs_live_`, a test key with `rs_test_`. The full key is shown once, when you create it. Reelspan stores only a SHA-256 hash of it, so a lost key can't be shown again: create a new one and revoke the old one. The API keys page lists each key with its first characters, its scopes, when it was last used, how many posts it sent in the last 30 days, and when it expires. ## Send the key Put the key in the `Authorization` header with the `Bearer` scheme. The word `Bearer` is not case-sensitive. ```http Authorization: Bearer rs_live_… ``` The `Apikey` scheme works too, so code written for Upload-Post keeps working with only a new key: ```http Authorization: Apikey rs_live_… ``` Those are the only accepted forms. A key in a query string, in another header, or with any other scheme answers 401. The SDKs read the key from the `REELSPAN_API_KEY` environment variable, or you pass it in: ```javascript title="Node.js" const reelspan = new Reelspan({ apiKey: process.env.REELSPAN_API_KEY }); ``` ```python title="Python" import os from reelspan import Reelspan reelspan = Reelspan(api_key=os.environ["REELSPAN_API_KEY"]) ``` ## Use api.reelspan.com API keys work only on the API host: `https://api.reelspan.com/v1`. The dashboard's address (`app.reelspan.com`) refuses them with **421** and `error_code: USE_API_HOST`, and the answer names the right address in `api_url`. ## Scopes A key can do only what its scopes allow. A key created with **Everything** has no scope list and can do it all. A call outside a key's scopes answers **403** with `error_code: INSUFFICIENT_SCOPE`. | Scope | Allows | | --- | --- | | `publish` | Posting, uploading media, editing and cancelling scheduled posts, retrying, unpublishing, editing and reposting live posts, setting YouTube thumbnails, adding to and removing from the media library, creating and deleting approval links, creating Pinterest boards and board sections, saving Pins, AI captions | | `read` | Post status, history, scheduled posts and their thumbnails and previews, reading queue settings and slots, listing profiles, the media library list and its files, approval links, network lookups (Facebook Pages and Page details, Google Business locations, LinkedIn pages, Pinterest boards and sections, TikTok creator info, Reddit posts), publishing limits | | `profiles` | Creating, renaming and deleting profiles, connecting and disconnecting accounts, connect links, choosing the Facebook Page or Google Business location posts go to, changing queue settings and marking slots full, changing workspace preferences | | `analytics` | Account analytics and their history, total impressions, post analytics, best times and reports | | `webhooks` | Managing the workspace and profile webhooks and their deliveries | When you untick **Everything** in the dashboard, every scope except `webhooks` starts ticked. `GET /v1/me` and `GET /v1/preferences` work with any key, whatever its scopes. ## Keys limited to profiles Give a key only some profiles, for example one key per client. Then: - A call that names another profile answers **403** with `error_code: PROFILE_FORBIDDEN`. - Posts, scheduled jobs and requests of other profiles answer **404**, as if they didn't exist. - `GET /v1/profiles` lists only the key's profiles. - The workspace-wide webhook needs a key without a profile limit; per-profile webhooks work. ## Test keys A test key (`rs_test_…`) runs every check a live key does, and its posts show as done, but nothing reaches the networks and nothing counts against your plan's monthly uploads. A workspace can send up to 500 test posts a day; past that the answer is **429** with `error_code: TEST_LIMIT`. Only posting is simulated. Other calls made with a test key act on your real workspace: creating or deleting profiles, connecting accounts, the media library, and AI captions (which use your AI credits). See [Test mode](/guides/test-mode). ## Expiry and revoking A key with an expiry date stops working at that moment and answers **401** `API key expired`. The dashboard shows which keys expire within 14 days. Revoking a key in the dashboard takes effect on the next request. To rotate a key without downtime: 1. Create the new key. 2. Deploy it everywhere the old one is used. 3. Revoke the old key. ## When authentication fails | Status | Message or `error_code` | What it means | | --- | --- | --- | | 401 | `Authorization header required` | No `Authorization` header was sent. | | 401 | `Invalid API key` | The key is wrong, malformed or revoked. | | 401 | `API key expired` | The key's expiry date has passed. | | 403 | `INSUFFICIENT_SCOPE` | The key lacks the scope this call needs. | | 403 | `PROFILE_FORBIDDEN` | The key is limited to other profiles. | | 403 | `WORKSPACE_SUSPENDED` | The workspace is suspended; contact support. | | 421 | `USE_API_HOST` | The key was sent to the dashboard's address instead of `api.reelspan.com`. | | 429 | `AUTH_RATE_LIMITED` | 10 failed attempts from one IP address within 5 minutes. Wait for `Retry-After` (300 seconds). | Error bodies look like this: ```json { "success": false, "message": "This API key does not have the \"webhooks\" scope", "error_code": "INSUFFICIENT_SCOPE" } ``` Every answer to a valid key also carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. See [Rate limits](/rate-limits). ## Keep keys safe - Keep keys on your server. Never put one in a web page, a mobile app or a public repository. - Store keys in environment variables or a secrets manager, not in code. - Give each integration its own key with only the scopes it needs, so you can revoke one without breaking the others. - Limit keys to the profiles they serve, and set an expiry on keys for short-lived work. - Use a test key while you build, and a live key only in production. - If a key may have leaked, revoke it at once and create a new one. ## AI agents The [MCP server](/integrations/mcp) accepts the same API key. Claude and ChatGPT can also sign in to it with OAuth instead of a key. Source: https://docs.reelspan.com/authentication --- # Current account > Check that an API key works and read the workspace's plan, plus how many uploads it has used this month against the plan's limit. Call `GET /v1/me` to check a key before you do anything else: a valid key answers 200 with the plan and this month's usage, a bad one answers 401. It works with any key, whatever its [scopes](/authentication#scopes). ```bash title="cURL" curl https://api.reelspan.com/v1/me \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const me = await reelspan.me(); console.log(me.plan, me.api_usage); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() me = reelspan.me() print(me["plan"], me["api_usage"]) ``` ## The response ```json { "success": true, "message": "Token is valid", "email": "you@example.com", "plan": "free", "preferences": { "weekStartDay": 1, "timezone": "Europe/London" }, "api_usage": { "count": 4, "limit": 15, "last_reset": "2026-11-01T00:00:00.000Z" } } ``` - `email`: the email of the person who created the key. For a key with no creator, the workspace's first member. - `plan`: the workspace's plan id: `free`, `starter`, `pro`, `agency` or `scale`. - `preferences`: the workspace's [preferences](/api/preferences). `weekStartDay` is `1` when weeks start on Monday (the default) or `0` for Sunday. `timezone` is the zone a post's `scheduled_date` is read in when the post sends no `timezone`, or `null` for UTC. - `api_usage.count`: uploads used this month. Each network a post goes to counts as one upload, so one video sent to TikTok, YouTube and Instagram counts 3. Test-key posts don't count. - `api_usage.limit`: the plan's monthly uploads, or `null` when the plan has no limit. Only the Free plan has one (15 a month). - `api_usage.last_reset`: when the current period began. Usage resets on the first day of each month, at midnight UTC. When a post would go over the limit, the post request answers **429** and nothing is published. See [Plans and limits](/limits/plans). ## Errors A missing, wrong, revoked or expired key answers **401**: ```json { "success": false, "message": "API key expired" } ``` The messages are listed under [When authentication fails](/authentication#when-authentication-fails). Source: https://docs.reelspan.com/api/me --- # Preferences > Read and change the workspace preferences - the day weeks start on, and the default time zone a post's scheduled_date is read in. Preferences are settings for the whole workspace. There are two: | Preference | Values | What it does | | --- | --- | --- | | `weekStartDay` | `1` Monday (the default), `0` Sunday | The first day of the week in calendars. | | `timezone` | an IANA zone such as `Europe/London`, or `null` (the default, UTC) | The zone a post's `scheduled_date` is read in when the post sends no `timezone`. | ## Read them ```bash title="cURL" curl https://api.reelspan.com/v1/preferences \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { preferences } = await reelspan.request('GET', '/v1/preferences'); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() preferences = reelspan.request("GET", "/v1/preferences")["preferences"] ``` ```json { "success": true, "preferences": { "weekStartDay": 1, "timezone": null } } ``` Any key can read them, whatever its scopes. [`GET /v1/me`](/api/me) returns the same `preferences`. ## Change them Send only what changes. The answer has all the preferences as saved: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/preferences \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"timezone": "America/New_York"}' ``` ```javascript title="Node.js" await reelspan.request('POST', '/v1/preferences', { json: { timezone: 'America/New_York' } }); ``` ```python title="Python" reelspan.request("POST", "/v1/preferences", json_body={"timezone": "America/New_York"}) ``` ```json { "success": true, "preferences": { "weekStartDay": 1, "timezone": "America/New_York" } } ``` Send `"timezone": null` to go back to UTC. An unknown zone gets **400** `INVALID_REQUEST`, and a `weekStartDay` other than `0` or `1` gets **400**. Changing preferences needs a key with the `profiles` scope. ## The default time zone With a `timezone` preference, a post that sends `scheduled_date` without its own `timezone` is scheduled in the workspace's zone. With the preference above, this post goes out at 9:00 in New York: ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/posts/video \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -F "user=my-brand" \ -F "platform[]=tiktok" \ -F "title=Launch day" \ -F "scheduled_date=2026-12-01T09:00:00" \ -F "video=@launch.mp4" ``` - A `timezone` sent with the post always wins. - A `scheduled_date` with its own offset (`2026-12-01T09:00:00-05:00` or `…Z`) is that exact moment, whatever the zones say. - Only new posts use the preference. Rescheduling a post with [`PATCH /v1/scheduled/{job_id}`](/api/scheduled-posts#reschedule-or-edit) keeps the zone it was scheduled in, and a [retry later](/api/retry#retry-later) without `timezone` uses UTC. See [Scheduling and repeats](/guides/scheduling) for the rest of scheduling. Source: https://docs.reelspan.com/api/preferences --- # Profiles > Create, list, rename and delete profiles. A profile is the brand or client you post as, with one connected account per network. A profile is the brand or client you post as. It holds at most one connected account per network: one TikTok, one YouTube channel, one Instagram account, and so on. When you post, you name the profile in `user`, and Reelspan publishes through that profile's accounts. Use one profile per brand or per client. An agency with ten clients makes ten profiles, and connects each client's accounts to their own profile. ## Create a profile ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/profiles \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"username": "my-brand", "display_name": "My Brand"}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { profile } = await reelspan.profiles.create('my-brand', 'My Brand'); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() profile = reelspan.profiles.create("my-brand", "My Brand")["profile"] ``` The answer is **201** with the new profile. It has no accounts yet: connect them next, see [Connecting accounts](/connecting-accounts). - `username` is the profile's id in every call. Use 1 to 64 letters, digits, `_`, `@` or `-`. It must be unique in your workspace and can't be changed later. - `display_name` is optional, up to 100 characters. It is how the profile shows in the dashboard. | Error | When | | --- | --- | | 400 `INVALID_USERNAME` | The username has other characters or is longer than 64. | | 400 `INVALID_DISPLAY_NAME` | The display name is over 100 characters or has control characters. | | 403 `PROFILE_LIMIT_REACHED` | The workspace already has as many profiles as its plan allows. | | 409 `PROFILE_EXISTS` | Another profile in the workspace has this username. | ## How many profiles | Plan | Profiles | | --- | --- | | Free | 1 | | Starter | 5 | | Pro | 25 | | Agency | 75 | | Scale | 225 | `GET /v1/profiles` returns your plan's limit as `limit`, next to `plan`. ## List profiles ```bash title="cURL" curl https://api.reelspan.com/v1/profiles \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" const { profiles, limit, plan } = await reelspan.profiles.list(); ``` ```python title="Python" result = reelspan.profiles.list() profiles = result["profiles"] ``` Profiles come oldest first. A key [limited to some profiles](/authentication#keys-limited-to-profiles) sees only those. ## Read one profile ```bash title="cURL" curl https://api.reelspan.com/v1/profiles/my-brand \ -H "Authorization: Bearer $REELSPAN_API_KEY" ``` ```javascript title="Node.js" const { profile } = await reelspan.profiles.get('my-brand'); ``` ```python title="Python" profile = reelspan.profiles.get("my-brand")["profile"] ``` ```json { "success": true, "profile": { "username": "my-brand", "display_name": "My Brand", "created_at": "2026-11-02T09:14:03.120Z", "social_accounts": { "youtube": { "username": "UCx4b2…", "handle": "@mybrand", "display_name": "My Brand", "social_images": "https://yt3.ggpht.com/…", "reauth_required": false, "capabilities": ["video", "schedule", "thumbnail", "playlist"] }, "tiktok": "", "instagram": "", "x": "" } } } ``` `social_accounts` has one entry for every network (shortened above). A network with no account connected is an empty string `""`. A connected one is an object: - `username`: the account's id on the network. - `handle` and `display_name`: how the account is named there. - `social_images`: its avatar, when the network gives one. - `reauth_required`: `true` when the network signed Reelspan out and the account must be connected again before it can post. See [Reconnecting](/connecting-accounts#reconnecting). - `capabilities`: what the connection can do on that network, for example `video`, `reels`, `stories` or `thumbnail`. ## Rename a profile Only the display name can change. Send `null` to clear it. ```bash title="cURL" curl -X PATCH https://api.reelspan.com/v1/profiles/my-brand \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"display_name": "My Brand (EU)"}' ``` ```javascript title="Node.js" await reelspan.profiles.rename('my-brand', 'My Brand (EU)'); ``` ```python title="Python" reelspan.profiles.rename("my-brand", "My Brand (EU)") ``` ## Delete a profile ```bash title="cURL" curl -X DELETE https://api.reelspan.com/v1/profiles \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"username": "my-brand"}' ``` ```javascript title="Node.js" await reelspan.profiles.delete('my-brand'); ``` ```python title="Python" reelspan.profiles.delete("my-brand") ``` > [!WARNING] > Deleting a profile can't be undone. Its connected accounts are disconnected, and its post history and scheduled posts are deleted with it. The posts already live on the networks stay there. To remove just one network from a profile, disconnect it instead: see [Connecting accounts](/connecting-accounts#disconnecting). ## Scopes Listing and reading profiles needs the `read` scope. Creating, renaming and deleting them needs `profiles`. See [Scopes](/authentication#scopes). Source: https://docs.reelspan.com/api/profiles --- # Connecting accounts > Connect social accounts to a profile from the dashboard, through a white-label link for your clients, or through the API, and reconnect or disconnect them. Before Reelspan can post to a network, the profile needs an account connected there. Each profile holds one account per network. There are three ways to connect one: | Way | Who does it | Best for | | --- | --- | --- | | [The dashboard](#in-the-dashboard) | You, signed in to Reelspan | Your own brands | | [A white-label connect link](#with-a-connect-link) | Your client, without a Reelspan login | Agencies and apps whose customers own the accounts | | [The API](#through-the-api) | Your code | Building connecting into your own product | All three save the same kind of connection: once an account is connected, it doesn't matter how. ## How each network connects Ten networks connect with an **OAuth sign-in**: the account owner signs in on the network and allows Reelspan to post. The other twelve connect with **credentials** the owner makes on the network: a bot token, a webhook, an access token or an app password. Reelspan checks credentials with the network before saving them, and stores them encrypted. | Network | `platform` | Connects with | What you need | | --- | --- | --- | --- | | YouTube | `youtube` | OAuth | A Google account with a YouTube channel | | TikTok | `tiktok` | OAuth | A TikTok account | | Instagram | `instagram` | OAuth (Facebook sign-in) | An Instagram Business or Creator account linked to a Facebook Page | | Facebook | `facebook` | OAuth | A Facebook Page you manage | | X | `x` | OAuth | An X account | | Threads | `threads` | OAuth | A Threads account | | LinkedIn | `linkedin` | OAuth | A LinkedIn account (company pages you admin are listed too) | | Pinterest | `pinterest` | OAuth | A Pinterest account | | Reddit | `reddit` | OAuth | A Reddit account | | Google Business | `google_business` | OAuth | A Google account that manages a Business Profile | | Bluesky | `bluesky` | Credentials | Your handle and an app password | | Telegram | `telegram` | Credentials | A bot token, and the channel or group the bot is an admin of | | Discord | `discord` | Credentials | A channel webhook URL | | Slack | `slack` | Credentials | An incoming webhook URL, or a bot token and a channel | | Mastodon | `mastodon` | Credentials | Your server and an access token | | WordPress | `wordpress` | Credentials | Your site, username and an application password | | Dev.to | `devto` | Credentials | An API key | | Hashnode | `hashnode` | Credentials | A personal access token (and the blog, if you have several) | | Lemmy | `lemmy` | Credentials | Your server, the community, and the account's username and password | | Listmonk | `listmonk` | Credentials | Your Listmonk address, an API user and token, and the list | | Whop | `whop` | Credentials | A company API key, and the forum (or the company id) | | Nostr | `nostr` | Credentials | Your private key (`nsec1…`), and optionally relays | The exact fields for each credentials network, with the names the API accepts, are on [Connect with credentials](/api/connect-credentials). ## In the dashboard 1. Open **Accounts** and pick the profile. 2. Click **Connect** next to the network. 3. For an OAuth network, sign in on the network and allow access. You come back to the Accounts page. 4. For a credentials network, a form shows the setup steps for that network and the fields to fill in. Only workspace owners and admins can connect accounts. **Facebook:** if you manage several Pages, the dashboard asks which Page to post to after you connect. You can change it later on the account's page, or choose per post with `facebook_page_id`. ## With a connect link Send your client a link to a hosted page where they connect their own accounts to one of your profiles. They don't need a Reelspan account and never see your API key. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/connect-links \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"username": "my-brand", "platforms": ["tiktok", "instagram", "youtube"]}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { access_url } = await reelspan.connect.link('my-brand', { platforms: ['tiktok', 'instagram', 'youtube'] }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() access_url = reelspan.connect.link("my-brand", platforms=["tiktok", "instagram", "youtube"])["access_url"] ``` The link works for 48 hours. On the page, OAuth networks have a **Connect** button and credentials networks have a short form. Your logo, texts, theme and language are set on the same call; see [White-label connect](/white-label). ## Through the API ### OAuth networks Start the sign-in with `POST /v1/oauth/{platform}/start`, send the person to the `authorize_url` it returns, and they come back to your `redirect_url` with the outcome in the query string. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/oauth/tiktok/start \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile": "my-brand", "redirect_url": "https://example.com/connected"}' ``` ```javascript title="Node.js" const { authorize_url } = await reelspan.connect.oauthStart('tiktok', { profile: 'my-brand', redirectUrl: 'https://example.com/connected' }); ``` ```python title="Python" authorize_url = reelspan.connect.oauth_start("tiktok", profile="my-brand", redirect_url="https://example.com/connected")["authorize_url"] ``` The `authorize_url` is good for 15 minutes and works once. When the person returns, `connect_status` is `success`, `cancelled` or `error`. The whole flow, including starting it with a connect link's token from your client's browser, is on [White-label connect](/white-label#your-own-connect-page). ### Credentials networks Send the fields to `POST /v1/accounts/{platform}`. Reelspan checks them with the network and answers with the connected account. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/accounts/discord \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "webhook_url": "https://discord.com/api/webhooks/123/abc"}' ``` ```javascript title="Node.js" await reelspan.accounts.connect('my-brand', 'discord', { webhook_url: 'https://discord.com/api/webhooks/123/abc' }); ``` ```python title="Python" reelspan.accounts.connect("my-brand", "discord", webhook_url="https://discord.com/api/webhooks/123/abc") ``` Every network's fields are on [Connect with credentials](/api/connect-credentials). ## See what is connected `GET /v1/profiles/{username}` lists every network in `social_accounts`: `""` when nothing is connected, or the account's details. See [Profiles](/api/profiles#read-one-profile). ## Reconnecting Networks sometimes sign Reelspan out: the person changed their password, removed the app's access, or a token couldn't be renewed. Reelspan notices when a post fails for that reason, and then: - the account shows `reauth_required: true` in `social_accounts`; - posts to that network are skipped with `error_code: reauth_required` until it is fixed, while the other networks still post; - the `social_account_reauth_required` [webhook](/webhooks) fires, and the workspace gets a dashboard notification and an email. To fix it, connect the same network again on the same profile, in any of the three ways. Connecting again replaces the old connection on that profile and makes it active; posts that were skipped can then be sent with [Retry](/api/retry). For Facebook, the Page you had chosen is kept if it is still shared. Connecting a different account on a network that already has one also replaces it: a profile never has two accounts on one network. ## Disconnecting ```bash title="cURL" curl -X DELETE https://api.reelspan.com/v1/accounts \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "social_platform": "tiktok"}' ``` ```javascript title="Node.js" await reelspan.accounts.disconnect('my-brand', 'tiktok'); ``` ```python title="Python" reelspan.accounts.disconnect("my-brand", "tiktok") ``` Disconnecting deletes the stored tokens or credentials and fires the `social_account_disconnected` webhook. It is safe to repeat: disconnecting a network that isn't connected also answers 200. Your client can also disconnect from a connect link's page, unless the link is read-only. > [!NOTE] > Disconnecting removes Reelspan's copy of the access. To revoke it on the network's side too, remove Reelspan from the account's connected apps in that network's settings. ## Scopes Connecting and disconnecting through the API, and creating connect links, need a key with the `profiles` scope. See [Authentication](/authentication#scopes). ## Features waiting for a network's approval Some features use a permission a network grants only after it reviews Reelspan's app. They are built and in the API now. Until the network approves, they answer `403` with `error_code: "permission_pending"`. Once approved, Reelspan switches the permission on: new connections ask for it, and accounts connected before then must reconnect once (the call answers `409 permission_reconnect` until they do). [`GET /v1/permissions`](/api/permissions) lists each one with `status: "pending"` or `"live"`. | Feature | Network | Permission | Approval | | --- | --- | --- | --- | | Edit and delete published videos ([edit](/api/edit-post), [unpublish](/api/unpublish-post)), subtitles on upload (`youtube_subtitle_*`), first comments | YouTube | `youtube.force-ssl` | Google OAuth verification | | Delete posts ([unpublish](/api/unpublish-post)) | Threads | `threads_delete` | Meta App Review | | Change a Page's about, website, phone, emails and hours (`PATCH /v1/facebook/page-info`) | Facebook | `pages_manage_metadata` | Meta App Review | | First comments | Facebook | `pages_manage_engagement`, `pages_read_user_content` | Meta App Review | | First comments | Instagram | `instagram_manage_comments` | Meta App Review | | Company pages: list, choose and post or reshare as one (`/v1/linkedin/pages`, `target_linkedin_page_id`) | LinkedIn | `r_organization_admin`, `w_organization_social`, `r_organization_social` | LinkedIn Community Management API | | Every post of the account in [`GET /v1/reddit/posts`](/api/reddit-posts), not only those made through Reelspan | Reddit | `history` | None (granted on consent) | These need API access that the networks haven't opened to Reelspan's apps yet. They aren't in the API until that access exists: - **TikTok:** music (trending, search, `tiktok_music_*`), locations (`tiktok_location_*`), `tiktok_is_ads_only` and `tiktok_tto_invite_link`, comments and audience insights. TikTok's public Content Posting API has none of them. - **Comments, direct messages and auto-replies** on Instagram, Facebook, Threads, YouTube, TikTok and LinkedIn: each needs its network's comment or messaging permission. A few Upload-Post fields have no counterpart in the networks' public APIs, so Reelspan doesn't offer them: YouTube `allowedCountries` and `blockedCountries` (read-only in the YouTube API), X Articles, and `pinterest_ai_disclosures`. Source: https://docs.reelspan.com/connecting-accounts --- # Connect with credentials > Connect Bluesky, Telegram, Discord, Slack, Mastodon, WordPress, Dev.to, Hashnode, Lemmy, Listmonk, Whop or Nostr to a profile with a token, key or webhook. Twelve networks connect with credentials the account owner makes on the network, instead of an OAuth sign-in. Send them to `POST /v1/accounts/{platform}` with the profile in `profile_username`. Reelspan checks them with the network first, saves them encrypted, and answers with the account it connected. For the other ten networks, use the OAuth sign-in instead: see [Connecting accounts](/connecting-accounts#oauth-networks). ## Examples ### Telegram Create a bot with @BotFather, add it to your channel as an admin that can post, then send its token and the channel. A public channel is its `@name`; a private one is its numeric id, which starts with `-100`. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/accounts/telegram \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "bot_token": "123456789:AAH…", "chat": "@mybrandchannel"}' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); await reelspan.accounts.connect('my-brand', 'telegram', { bot_token: process.env.TELEGRAM_BOT_TOKEN, chat: '@mybrandchannel' }); ``` ```python title="Python" import os from reelspan import Reelspan reelspan = Reelspan() reelspan.accounts.connect("my-brand", "telegram", bot_token=os.environ["TELEGRAM_BOT_TOKEN"], chat="@mybrandchannel") ``` ### Bluesky Use an app password (Bluesky: Settings → Privacy and security → App passwords), never your main password. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/accounts/bluesky \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "identifier": "mybrand.bsky.social", "app_password": "abcd-efgh-ijkl-mnop"}' ``` ```javascript title="Node.js" await reelspan.accounts.connect('my-brand', 'bluesky', { identifier: 'mybrand.bsky.social', app_password: process.env.BLUESKY_APP_PASSWORD }); ``` ```python title="Python" reelspan.accounts.connect("my-brand", "bluesky", identifier="mybrand.bsky.social", app_password=os.environ["BLUESKY_APP_PASSWORD"]) ``` ### WordPress In your WordPress admin, open Users → Profile → Application Passwords (WordPress 5.6 or later, on https) and create one for Reelspan. ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/accounts/wordpress \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "site": "blog.example.com", "username": "editor", "app_password": "abcd efgh ijkl mnop qrst uvwx"}' ``` ```javascript title="Node.js" await reelspan.accounts.connect('my-brand', 'wordpress', { site: 'blog.example.com', username: 'editor', app_password: process.env.WP_APP_PASSWORD }); ``` ```python title="Python" reelspan.accounts.connect("my-brand", "wordpress", site="blog.example.com", username="editor", app_password=os.environ["WP_APP_PASSWORD"]) ``` ## The response ```json { "success": true, "platform": "telegram", "account": "@mybrandchannel", "reconnected": false } ``` `account` is how the connected account is named. `reconnected` is `true` when the profile already had an account on this network: the new credentials replace it. ## Fields per network Each field also accepts the name in the **Also accepted** column, so calls written for Upload-Post work unchanged. When both are sent, the Reelspan name wins. | Network | Field | Also accepted | Required | What it is | | --- | --- | --- | --- | --- | | `bluesky` | `identifier` | `handle` | Yes | Your handle (like `mybrand.bsky.social`) or account email | | | `app_password` | `password` | Yes | An app password, not your main password | | `telegram` | `bot_token` | | Yes | The token @BotFather gave you, like `123456789:AAH…` | | | `chat` | `chat_id` | Yes | `@name` of a public channel, or the numeric id of a private channel or group | | `discord` | `webhook_url` | | Yes | The channel's webhook: channel settings → Integrations → Webhooks | | `slack` | `webhook_url` | | One way | An incoming webhook URL (`https://hooks.slack.com/services/…`) | | | `name` | | No | How the channel shows in Reelspan, with a webhook | | | `bot_token` | | Other way | A bot token (`xoxb-…`) with `chat:write`, `files:write`, `channels:read` and `channels:join` | | | `channel` | | With `bot_token` | The channel's name or id (`C…`) | | `mastodon` | `server` | `instance_url` | Yes | Your server, like `mastodon.social` | | | `access_token` | | Yes | From Preferences → Development → New application, with `read` and `write` | | `wordpress` | `site` | `site_url` | Yes | Your site's address | | | `username` | | Yes | Your WordPress username | | | `app_password` | `application_password` | Yes | An application password, not your login password | | `devto` | `api_key` | | Yes | From Settings → Extensions → DEV Community API Keys | | `hashnode` | `token` | `pat` | Yes | A personal access token from Account settings → Developer | | | `blog` | `publication_id` | No | The blog's address or publication id; needed only with more than one blog | | `lemmy` | `server` | `instance_url` | Yes | Your server, like `lemmy.world` | | | `community` | | Yes | The community name, or `name@server` for one on another server | | | `username` | | Yes | An account that may post in the community | | | `password` | | Yes | Used once to get a token; it isn't kept | | `listmonk` | `server` | `instance_url` | Yes | Your Listmonk address | | | `user` | `username` | Yes | An API user (Listmonk 4+), or the admin username | | | `token` | `password` | Yes | The API user's token, or the admin password | | | `list` | `list_id` | Yes | The list's name or id | | `whop` | `api_key` | | Yes | A company API key with `forum:read`, `forum:post:create` and file permissions | | | `forum` | `experience_id` | One of these | The forum's experience id (`exp_…`) | | | `company_id` | | One of these | The company id (`biz_…`), to post to its public forum | | `nostr` | `nsec` | `private_key` | Yes | Your private key, `nsec1…` (or 64 hex characters) | | | `relays` | | No | `wss://` relays, as a list or separated by spaces | Slack has two ways in. A webhook is quickest: messages and photos post into the channel, and a video or file goes as a link. A bot token uploads videos, photos and files into the channel itself; for a private channel, invite the bot first with `/invite @yourbot`. Nostr without `relays` publishes to `relay.damus.io`, `nos.lol` and `relay.primal.net`. Media is hosted on nostr.build. Values can be strings, numbers (a Telegram chat id, for example) or lists of strings; a list is joined with spaces. ## Errors | Status | `error_code` | When | | --- | --- | --- | | 400 | `MISSING_FIELD` | A required field is missing. The message names it. | | 400 | A code for the problem, such as `INVALID_TOKEN`, `INVALID_WEBHOOK` or `BOT_NOT_ADMIN` | The credentials are malformed or the network refused them. The message says what to fix. | | 403 | `PLATFORM_DISABLED` | Connecting this network is switched off for now. | | 403 | `PROFILE_FORBIDDEN` | The key is limited to other profiles. | | 404 | `PROFILE_NOT_FOUND` | No profile has that username. | | 429 | `RATE_LIMITED` | More than 30 connection attempts from your workspace in 10 minutes. | Each attempt reaches the network, which is why attempts are capped. Calls need a key with the `profiles` scope. ## Disconnect `DELETE /v1/accounts` removes one network from a profile, for credentials and OAuth networks alike. It deletes the stored credentials and fires the `social_account_disconnected` webhook. Disconnecting a network that isn't connected also answers 200. ```bash title="cURL" curl -X DELETE https://api.reelspan.com/v1/accounts \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"profile_username": "my-brand", "social_platform": "telegram"}' ``` ```javascript title="Node.js" await reelspan.accounts.disconnect('my-brand', 'telegram'); ``` ```python title="Python" reelspan.accounts.disconnect("my-brand", "telegram") ``` ```json { "success": true, "message": "telegram disconnected" } ``` When nothing was connected, the message is `telegram was not connected`. Source: https://docs.reelspan.com/api/connect-credentials --- # White-label connect > Let your clients connect their own social accounts to a profile through a branded hosted page, or through your own connect page built on the Connect API. When your clients own the social accounts, they should connect them themselves: they sign in on each network, and you never handle their passwords. Reelspan gives you two ways to do that without a Reelspan login for them: - **A hosted connect link**: one call returns a link to a page with your logo, texts and language. Send it to your client. - **Your own connect page**: you build the page, and call the Connect API to start each network's sign-in. ## A hosted connect link ```bash title="cURL" curl -X POST https://api.reelspan.com/v1/connect-links \ -H "Authorization: Bearer $REELSPAN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "username": "my-brand", "platforms": ["tiktok", "instagram", "youtube"], "logo_image": "https://example.com/logo.png", "connect_title": "Connect your accounts to Example Studio", "redirect_url": "https://example.com/onboarding/done", "redirect_button_text": "Back to Example Studio", "language": "es", "connect_theme": "dark" }' ``` ```javascript title="Node.js" import { Reelspan } from 'reelspan'; const reelspan = new Reelspan(); const { access_url } = await reelspan.connect.link('my-brand', { platforms: ['tiktok', 'instagram', 'youtube'], logo_image: 'https://example.com/logo.png', connect_title: 'Connect your accounts to Example Studio', redirect_url: 'https://example.com/onboarding/done', redirect_button_text: 'Back to Example Studio', language: 'es', connect_theme: 'dark', }); ``` ```python title="Python" from reelspan import Reelspan reelspan = Reelspan() access_url = reelspan.connect.link( "my-brand", platforms=["tiktok", "instagram", "youtube"], logo_image="https://example.com/logo.png", connect_title="Connect your accounts to Example Studio", redirect_url="https://example.com/onboarding/done", redirect_button_text="Back to Example Studio", language="es", connect_theme="dark", )["access_url"] ``` ```json { "success": true, "access_url": "https://app.reelspan.com/connect?token=eyJhbGciOiJIUzI1NiIs…", "duration": "48h" } ``` Send `access_url` to your client. It works for 48 hours; make a new one whenever you need it. Anyone with the link can connect accounts to that one profile, so send it only to the person it is for. ### What the page shows - Your logo, title and description, or Reelspan's default texts. - Each network you listed in `platforms` (every network when you leave it out). OAuth networks have a **Connect** button that opens the network's sign-in; Bluesky and the other credentials networks show a short form for the token or key. - For each connected network: the account's name, a **Disconnect** button, and **Reconnect** when the account needs it. - The profile's scheduled posts, unless you send `show_calendar: false`. - A button back to your `redirect_url`, labelled with `redirect_button_text` (default "Done"). After each sign-in your client returns to the same page, which says whether the network connected, was cancelled, or failed and why. ### Branding options | Field | What it does | Plans | | --- | --- | --- | | `username` | The profile the link connects accounts to. Required. | All | | `platforms` | The networks to offer, by id (up to 20). Your client can't connect any other network with this link, here or through the Connect API. | All | | `redirect_url` | Where the page's final button goes. An `http` or `https` address. | All | | `show_calendar` | `false` hides the scheduled posts. | All | | `readonly_calendar` | `true` makes a view-only link: your client sees the connections and scheduled posts, but can't connect or disconnect anything. | All | | `language` | The page's language (see [Languages](#languages)). | All | | `logo_image` | Your logo, as an `http` or `https` image URL. | White-label | | `connect_title` | The heading, up to 120 characters. | White-label | | `connect_description` | The text under it, up to 500 characters. | White-label | | `redirect_button_text` | The final button's label, up to 60 characters. | White-label | | `connect_theme` | `light`, `dark` or `auto` (the default). | White-label | | `ui_labels` | Your own wording for any text on the page (see [Your own wording](#your-own-wording)). | White-label | White-label is part of the Pro, Agency and Scale plans; those plans also drop the "Secure connection powered by Reelspan" line at the bottom of the page. On Free and Starter the link still works with Reelspan's look: the white-label fields are left out, and the answer says so in `warnings`. ```json { "success": true, "access_url": "https://app.reelspan.com/connect?token=…", "duration": "48h", "warnings": ["Branding (logo_image, connect_title) needs a plan with white-label; the link uses Reelspan's default look."] } ``` ### Settings are remembered Branding is saved on the profile, so the next link for that profile only needs `username`. Fields you send change only themselves; the rest stay as they were. Send `ui_labels: null` (or `{}`) to clear your labels. `readonly_calendar` is fixed into each link when it is made: changing it later doesn't change links already sent. ### Languages The hosted page comes in English (`en`), Spanish (`es`), German (`de`), French (`fr`), Portuguese (`pt`), Polish (`pl`) and Turkish (`tr`). Without `language`, it follows your client's browser language, and falls back to English. ### Your own wording `ui_labels` replaces any text on the page, in any language. Send up to 100 labels, each up to 300 characters: ```json { "username": "my-brand", "ui_labels": { "connect.button": "Link account", "calendar.title": "Coming up" } } ``` | Key | Default text | | --- | --- | | `connect.title` | Connect your social accounts | | `connect.description` | Connect the accounts you want us to publish to. You can disconnect them here at any time. | | `connect.button` | Connect | | `connect.reconnect` | Reconnect | | `connect.disconnect` | Disconnect | | `connect.connected` | Connected | | `connect.success` | connected. | | `connect.cancelled` | was not connected: permission was not granted. | | `connect.error` | could not be connected | | `connect.expired` | This link has expired or is not valid. Ask for a new one. | | `connect.done` | Done | | `connect.maintenance` | Under maintenance. It can't be connected right now; check back soon. | | `calendar.title` | Scheduled posts | | `calendar.empty` | Nothing is scheduled. | | `bluesky.handle` | Bluesky handle | | `bluesky.password` | App password (Settings → Privacy and security → App passwords). Never your main password. | | `footer.powered` | Secure connection powered by Reelspan | `connect_title` and `connect_description` take priority over the `connect.title` and `connect.description` labels. ### Embedding The hosted page can be shown in an `