Blog
9 min readGuideAPI

How to schedule social media posts with an API

Most networks’ APIs publish the moment you call them. Here is how to schedule one post to X, Instagram, LinkedIn and the rest with a single request — in curl, Node and Python — and what gets it refused.

The short version: you send the post once to a scheduling API — the text, the files, the accounts it goes to and the time it should go out — and the API holds it and publishes it at that moment, on every network at once. With Ravenpost that is one request:

curl https://api.ravenpo.st/v1/posts \
  -H "Authorization: Bearer $RAVENPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "IMAGE",
    "caption": "Dark mode is here: every screen, every theme. Turn it on in Settings → Appearance.",
    "captionOverrides": {
      "TWITTER": "Dark mode is here 🌙 Every screen, every theme."
    },
    "mediaIds": [
      "m4n8b2v6c1x9z3l7k5j0h2gf"
    ],
    "accountIds": [
      "k9v2m4q8r1x7c3p5n6b0t2wz",
      "p3h8d2s6f1g9j4k7l0z5x2cv",
      "a7s1d5f9g3h6j2k8l4q0w1er"
    ],
    "action": "schedule",
    "scheduledAt": "2027-06-15T09:00:00-04:00"
  }'

The rest of this guide is the whole path: getting a token, finding the account ids, uploading an image, knowing when the post went out, changing your mind — and the handful of mistakes that get a scheduled post refused.

Why the networks’ own APIs don’t do this

Most networks publish the moment you call them. The endpoints X, Instagram, TikTok and Bluesky offer for posting have no “publish at” field, and neither does Telegram’s Bot API; Facebook Pages (scheduled_publish_time) and YouTube (publishAt) are the exceptions. So scheduling a post to most networks means holding it yourself and making the call at the right moment, with everything that comes with that:

  • A job that fires on time, survives a deploy and runs exactly once. A retry after the network has already accepted the post is a duplicate on a live account.
  • A token that is still valid when it fires. TikTok and X hand out a new refresh token every time you use one, Instagram and Threads renew a 60-day token in place, and LinkedIn issues no refresh token at all, so a person reconnects every 60 days.
  • Each network’s rules, checked before the moment passes: captions counted the way each network counts them, the files each one takes, which ones need a file at all.
  • Errors read for what they mean. A timeout is worth retrying. A caption that is too long is not, and retrying it four times only delays the bad news.

A scheduling API is that layer, run for you. This guide uses Ravenpost’s, which reaches 9 networks — Instagram, TikTok, X, Telegram, LinkedIn, Threads, Bluesky, YouTube and Pinterest — and is on every plan, the free one included (1 connected account and 10 posts a month).

What you need

  1. A Ravenpost account. Sign up — it is free.
  2. Your social accounts, connected on the dashboard’s Accounts page. You sign in to each network there once; your code never sees a network’s token, and no API call returns one.
  3. A personal access token. On the dashboard’s MCP page, choose Generate token. It starts with rvp_, is shown once, and belongs to one workspace: every call acts there, so no request names a workspace.

Keep the token out of your code, in the environment:

export RAVENPOST_TOKEN=rvp_your_token_here

Step 1: Check that the token works

curl https://api.ravenpo.st/v1/me -H "Authorization: Bearer $RAVENPOST_TOKEN"

It returns the workspace, its plan’s limits and what this month has used so far. A 401 means the token is mistyped or was revoked, and the error says how to send it.

Step 2: Find the accounts to post to

curl https://api.ravenpo.st/v1/accounts -H "Authorization: Bearer $RAVENPOST_TOKEN"
Response
[
  {
    "id": "k9v2m4q8r1x7c3p5n6b0t2wz",
    "platform": "TWITTER",
    "username": "acme",
    "status": "ACTIVE"
  },
  {
    "id": "p3h8d2s6f1g9j4k7l0z5x2cv",
    "platform": "LINKEDIN",
    "username": "Ada Lovelace",
    "status": "ACTIVE"
  },
  {
    "id": "a7s1d5f9g3h6j2k8l4q0w1er",
    "platform": "INSTAGRAM",
    "username": "acme",
    "status": "ACTIVE"
  }
]

Each connected account, with the id you post to. One post can go to any mix of them, with one limit: a single X account per post, because X’s rules forbid the same post on several accounts.

Step 3: Upload the image

Skip this for a text-only post. You need it for Instagram, TikTok, YouTube and Pinterest, which refuse a post with no file. Uploading takes three calls on purpose: the bytes go straight to storage through a presigned URL, so a large video never travels through the API.

a) Ask for an upload URL
curl https://api.ravenpo.st/v1/media/uploads -H "Authorization: Bearer $RAVENPOST_TOKEN" -H "Content-Type: application/json" \
  -d '{ "filename": "dark-mode.jpg", "contentType": "image/jpeg" }'

# { "uploadUrl": "https://…", "storageKey": "…/dark-mode.jpg",
#   "publicUrl": "https://cdn.ravenpo.st/…/dark-mode.jpg" }
b) Send the bytes, with the same Content-Type
curl -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --upload-file dark-mode.jpg
c) Register the file, and keep the id
curl https://api.ravenpo.st/v1/media -H "Authorization: Bearer $RAVENPOST_TOKEN" -H "Content-Type: application/json" \
  -d '{ "type": "IMAGE", "storageKey": "'"$STORAGE_KEY"'", "url": "'"$PUBLIC_URL"'",
        "mimeType": "image/jpeg" }'

# { "id": "m4n8b2v6c1x9z3l7k5j0h2gf", "type": "IMAGE", "url": "https://cdn.ravenpo.st/…", … }

The upload URL is good for ten minutes. The Content-Type of the upload has to be the one you named in step a, and step c takes the publicUrl back exactly as it came — any other address is refused. Images can be JPEG, PNG, WebP, GIF, HEIC or AVIF; videos MP4, MOV or WebM.

Step 4: Schedule the post

Now the post itself: one request, three networks, and a shorter caption for X. It is the request from the top of this page:

curl
curl https://api.ravenpo.st/v1/posts \
  -H "Authorization: Bearer $RAVENPOST_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "IMAGE",
    "caption": "Dark mode is here: every screen, every theme. Turn it on in Settings → Appearance.",
    "captionOverrides": {
      "TWITTER": "Dark mode is here 🌙 Every screen, every theme."
    },
    "mediaIds": [
      "m4n8b2v6c1x9z3l7k5j0h2gf"
    ],
    "accountIds": [
      "k9v2m4q8r1x7c3p5n6b0t2wz",
      "p3h8d2s6f1g9j4k7l0z5x2cv",
      "a7s1d5f9g3h6j2k8l4q0w1er"
    ],
    "action": "schedule",
    "scheduledAt": "2027-06-15T09:00:00-04:00"
  }'
  • accountIds come from step 2, mediaIds from step 3 — [] for a post with no file.
  • caption goes to every network; captionOverrides replaces it on the networks you name, keyed by platform id (TWITTER, LINKEDIN, INSTAGRAM, …).
  • action: "schedule" needs scheduledAt, an ISO 8601 time with its offset. "now" publishes at once, "draft" only saves it, and "queue" takes the next free slot of your posting schedule (below).
  • type is IMAGE for a text or single-image post, CAROUSEL for several images and REEL for a video on Instagram; networks with no such formats ignore it.

The answer comes straight back, with one target per account, each waiting its turn:

Response (trimmed)
{
  "id": "q2w6e1r5t9y3u7i4o8p0a1sd",
  "status": "SCHEDULED",
  "scheduledAt": "2027-06-15T13:00:00.000Z",
  "targets": [
    {
      "platform": "TWITTER",
      "username": "acme",
      "status": "PENDING",
      "permalink": null,
      "error": null
    },
    {
      "platform": "LINKEDIN",
      "username": "Ada Lovelace",
      "status": "PENDING",
      "permalink": null,
      "error": null
    },
    {
      "platform": "INSTAGRAM",
      "username": "acme",
      "status": "PENDING",
      "permalink": null,
      "error": null
    }
  ]
}

Times come back in UTC: 2027-06-15T09:00:00-04:00 is 9am in New York, which is 2027-06-15T13:00:00.000Z. The same request in Node and in Python, with “this time tomorrow” worked out rather than typed:

Node
// Node 18 or later: fetch is built in. Save as schedule.mjs and run
// RAVENPOST_TOKEN=rvp_… node schedule.mjs
const API = 'https://api.ravenpo.st/v1';

// This time tomorrow. Any ISO 8601 instant with an offset will do.
const scheduledAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();

const res = await fetch(`${API}/posts`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.RAVENPOST_TOKEN}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    type: "IMAGE",
    caption: "Dark mode is here: every screen, every theme. Turn it on in Settings → Appearance.",
    captionOverrides: {
      TWITTER: "Dark mode is here 🌙 Every screen, every theme."
    },
    mediaIds: [
      "m4n8b2v6c1x9z3l7k5j0h2gf"
    ],
    accountIds: [
      "k9v2m4q8r1x7c3p5n6b0t2wz",
      "p3h8d2s6f1g9j4k7l0z5x2cv",
      "a7s1d5f9g3h6j2k8l4q0w1er"
    ],
    action: "schedule",
    scheduledAt,
  }),
});

if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(await res.json())}`);
const post = await res.json();
console.log(post.id, post.status, post.scheduledAt);
Python
import os
from datetime import datetime, timedelta, timezone

import requests

API = "https://api.ravenpo.st/v1"

post = {
    "type": "IMAGE",
    "caption": "Dark mode is here: every screen, every theme. Turn it on in Settings → Appearance.",
    "captionOverrides": {
        "TWITTER": "Dark mode is here 🌙 Every screen, every theme."
    },
    "mediaIds": [
        "m4n8b2v6c1x9z3l7k5j0h2gf"
    ],
    "accountIds": [
        "k9v2m4q8r1x7c3p5n6b0t2wz",
        "p3h8d2s6f1g9j4k7l0z5x2cv",
        "a7s1d5f9g3h6j2k8l4q0w1er"
    ],
    "action": "schedule"
}
# This time tomorrow, as an ISO 8601 instant with its offset.
post["scheduledAt"] = (datetime.now(timezone.utc) + timedelta(days=1)).isoformat(timespec="seconds")

res = requests.post(
    f"{API}/posts",
    headers={"Authorization": f"Bearer {os.environ['RAVENPOST_TOKEN']}"},
    json=post,
    timeout=30,
)
res.raise_for_status()
created = res.json()
print(created["id"], created["status"], created["scheduledAt"])

Step 5: Find out when it went out

You can ask: GET /v1/posts/:id returns the post and each target with its own status — PENDING, PUBLISHING, PUBLISHED or FAILED — its permalink once it is live, and the network’s reason if it failed.

curl https://api.ravenpo.st/v1/posts/q2w6e1r5t9y3u7i4o8p0a1sd -H "Authorization: Bearer $RAVENPOST_TOKEN"

Read the targets, not only the post. A post counts as PUBLISHED as soon as one account has it, so a post that reached X and LinkedIn but not Instagram says PUBLISHED with one target FAILED.

Or you can be told. Add a webhook on the dashboard’s Settings page, and Ravenpost POSTs to your URL once per account, as each one publishes (post.published) or fails for good (post.failed):

Headers: x-ravenpost-event, x-ravenpost-timestamp, x-ravenpost-signature
{
  "event": "post.published",
  "timestamp": 1813064405,
  "workspaceId": "w8e2r6t1y5u9i3o7p4a0s2df",
  "data": {
    "postId": "q2w6e1r5t9y3u7i4o8p0a1sd",
    "targetId": "t1y5u9i3o7p4a0s2dfw8e2r6",
    "platform": "TWITTER",
    "username": "acme",
    "status": "PUBLISHED",
    "permalink": "https://x.com/acme/status/1934567890123456789",
    "error": null,
    "publishedAt": "2027-06-15T13:00:04.000Z"
  }
}

Every delivery is signed. x-ravenpost-signature is a hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the secret the dashboard showed when you created the webhook, and x-ravenpost-timestamp is the timestamp it covers. Check that it is recent, so an old delivery cannot be replayed:

Verify a delivery (Express)
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Verify the raw body: parsing and re-serialising the JSON changes the bytes.
app.post('/hooks/ravenpost', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('x-ravenpost-timestamp');
  const signature = req.get('x-ravenpost-signature') ?? '';
  const expected = crypto
    .createHmac('sha256', process.env.RAVENPOST_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
  const valid =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
  if (!fresh || !valid) return res.sendStatus(401);

  const { event, data } = JSON.parse(req.body);
  if (event === 'post.failed') console.error(data.platform, data.username, data.error);
  else console.log(data.platform, data.permalink);
  res.sendStatus(200);
});

app.listen(3000);

Answer with a 2xx within ten seconds. A delivery that gets anything else is tried again — five attempts in all, the wait doubling from ten seconds — and redirects are not followed.

Changing your mind

ToCallGood to know
Move itPOST /v1/posts/:id/scheduleSend the new scheduledAt; nothing else changes.
Edit itPATCH /v1/posts/:idAn edit turns the post back into a draft, so send action and scheduledAt in the same call to keep it scheduled. A published post can’t be edited.
Publish it nowPOST /v1/posts/:id/publishWhatever time it was scheduled for.
Retry what failedPOST /v1/posts/:id/retryOnly the failed accounts go again; the ones that published are left alone.
Cancel itDELETE /v1/posts/:idDeletes Ravenpost’s copy and the pending publish. Anything already live stays up.

Let a schedule pick the time

If you post on a rhythm — weekdays at 9 and at 5, say — set those slots once on the dashboard’s Settings page and send action: "queue" with no time at all: the post takes the next free slot, on the workspace’s clock. GET /v1/queue-slots lists them. A month of posts at once is a CSV sent to POST /v1/posts/bulk (10 requests a minute), which answers line by line so you resend only the rows that failed.

What gets a scheduled post refused

Some mistakes come back at once, as a 400 with the reason, because Ravenpost checks them when you create the post: a time that has passed, a missing file, an X caption that is too long or breaks X’s rules, a thread longer than a network allows. The rest surface when the network answers at publish time, with its reason on the target. The ones people run into:

  • A time with no offset, or one that has passed. Send 2027-06-15T09:00:00-04:00 or 2027-06-15T13:00:00Z. A time without an offset is read as UTC, whatever timezone your workspace is set to, and one more than a minute in the past is refused rather than published on the spot.
  • A caption longer than a network allows. A post has to fit the strictest network in accountIds, counted the way that network counts: X counts every link as 23 characters and most emoji as 2, so a caption that looks like 270 can be 290 to X. Ravenpost refuses an X caption that won’t fit when you create the post; the other networks refuse at publish time. Try one in the X character counter or the caption length checker, see every network’s limit in one table, and in code read the limits from GET /v1/platforms instead of hardcoding them.
  • No file where a network needs one. Instagram, TikTok, YouTube and Pinterest refuse a post without media, and so does Ravenpost when you create one. TikTok photo posts take JPEG or WebP, not PNG, and an Instagram video needs type: "REEL" (or "STORY").
  • A network’s own rules. X allows one $cashtag per post, checked when you create it. Pinterest needs a board for each account (pinterestBoards, with ids from GET /v1/accounts/:id/boards): a draft saves without one, but the pin fails at publish time. A TikTok post needs tiktokOptions to say who may see it; without them it goes out private (SELF_ONLY), because TikTok makes the creator choose.
  • Sending a create again after a timeout. POST /v1/posts takes no idempotency key, so repeating it after a lost answer schedules the post twice. Look first: GET /v1/posts?status=SCHEDULED&page=1 lists what is waiting, newest first.
  • Going faster than the limit. 120 requests a minute per IP, fewer for uploads (60) and bulk (10). Past it the answer is a 429.

Without code: schedule from Claude or ChatGPT

If what you want is “schedule this for tomorrow morning” rather than a script, the same engine is an MCP server: connect Ravenpost to Claude, ChatGPT or another assistant and ask in a sentence. A post scheduled more than 15 minutes ahead goes straight onto your calendar; publishing now waits until you press Publish on the post card. How to connect it.

Questions

Do I need my own developer app on Instagram, TikTok or X?

No. You connect your accounts by signing in to each network on the dashboard, through Ravenpost’s apps, so there is no app review to pass. Ravenpost renews the tokens that can be renewed; LinkedIn’s can’t, so it asks you to reconnect every 60 days and warns you two weeks ahead.

How much does it cost?

The API, the MCP server and webhooks are on every plan, the free one included (1 connected account and 10 posts a month). Paid plans start at $14 a month — see pricing.

Which networks can I schedule to?

Instagram, TikTok, X, Telegram, LinkedIn, Threads, Bluesky, YouTube and Pinterest. Each account keeps its own status, permalink and error, so one post can go out on some networks and fail on another.

Can I schedule a thread?

Yes, on X, Telegram, Threads and Bluesky: put the follow-up posts in threadParts and they publish in order under the first one. A thread longer than a network allows is refused, never cut short.

What happens if a network is down when the post is due?

Ravenpost retries what is worth retrying — timeouts, rate limits, a network’s own server errors — and fails at once, with the network’s reason, when a retry can’t help, such as a caption that is too long or an account that needs reconnecting. The target and the post.failed webhook both say which.

Every endpoint and field is in the API reference, generated from the same table this guide is checked against. Still comparing options? The best social media scheduling APIs lines them up by the plan their key is on.