Tesselith
ProductSolutionsCompareFree toolsBlogPricing
Join the waitlist
  1. Home/
  2. Blog/
  3. How to Automate Social Media Posting With an API (Node.js and Python Examples)

Developers

How to Automate Social Media Posting With an API (Node.js and Python Examples)

October 30, 2026 · 12 min read

Step-by-step: automate social media posting from your own code. Schedule and queue posts across networks via REST, upload media and retry safely.

Every team that posts on social media regularly hits the same wall. The content already exists somewhere else, in a CMS, a product catalog, a changelog or a spreadsheet, and someone copies it into a scheduler by hand. That's fine at five posts a week. At fifty it's a part-time job, and it's exactly the kind of job that breaks on the week the person who does it is on holiday. The fix is to automate social media posting through an API: your system creates the post, picks the time, and hands it to a scheduler that publishes it to every network.

This guide walks through building that automation end to end, with working code against the Tesselith REST API. It covers authentication, creating a post for several networks at once, choosing between an exact time and a posting queue, uploading images, retrying without creating duplicates, and finding out when a post actually went live. The same ideas apply to any well-designed scheduling API. The specifics are worth knowing because they're the parts that go wrong in production.

Key takeaways

  • Don't call each network's API directly unless you have to. A scheduling API handles authentication, media rules and retries for every network behind one endpoint.
  • The automation is four calls: list channels, create a draft, upload media, then schedule it for a time or add it to a queue.
  • Use a posting queue for a steady cadence and exact times for anything date-bound like launches or events.
  • Send an Idempotency-Key on every create so a retried request never posts twice.
  • Use webhooks, not polling, to learn when a post published or failed.

Why not post to each network's API directly?

It's tempting to skip the middle layer and call Instagram, Threads, Bluesky and YouTube directly. For one network and one account, that can be reasonable. For anything more, you inherit a long list of jobs that have nothing to do with your product:

  • App review and permissions. Meta, Google and LinkedIn each require an app review before you can publish on behalf of users, and each review takes weeks and a screencast. Bluesky and Mastodon don't, but use entirely different auth models.
  • Token lifecycle. Access tokens expire on different schedules (60 days, an hour, never) and refresh differently. Miss one and posting silently stops.
  • Media rules. Instagram wants JPEGs in specific aspect ratios. Bluesky caps images at 1 MB. YouTube uploads are resumable, multi-gigabyte transfers. Each needs its own upload flow.
  • Formatting quirks. Bluesky only makes a link clickable if you send byte offsets marking where it starts and ends. Threads and Instagram count characters differently. A post that renders perfectly on one network can be truncated on another.
  • Retries and rate limits. Every network fails in its own way and throttles at its own rate.

A social media scheduling API absorbs all of that behind one interface. You send text, media and a list of channels, and the scheduler deals with each network. The rest of this guide assumes you're doing it that way.

What you'll build

A small script that takes a piece of content (think a new blog post or product), turns it into a social post for several channels, attaches an image, and either queues it or schedules it for a set time. We'll write it in TypeScript for Node.js 20+, with the Python equivalent for each step. You'll need:

  • A Tesselith workspace with at least one connected channel. See pricing for plans.
  • An API key. Owners and admins create one in Settings → Developers → Create key. For this tutorial, pick Full access. In production, give each integration only the scopes it needs.
  • Node.js 20 or later (for the built-in fetch) or Python 3.9+ with requests.

Store the key in an environment variable, never in source control. It starts with tsl_live_, and you'll only see it once.

export TESSELITH_KEY="tsl_live_…"

Step 1: Set up an API client

A thin wrapper keeps the rest of the code readable. It adds the bearer token, sends JSON, and turns error responses into exceptions that carry the API's stable error code. Tesselith returns errors as RFC 9457 problem documents, so the code is always in the same place.

// tesselith.ts
const API = "https://api.tesselith.com/v1"

export class ApiError extends Error {
  constructor(readonly status: number, readonly code: string, message: string) {
    super(message)
  }
}

export async function api<T>(
  path: string,
  init: { method?: string; body?: unknown; idempotencyKey?: string } = {}
): Promise<T> {
  const res = await fetch(API + path, {
    method: init.method ?? "GET",
    headers: {
      Authorization: `Bearer ${process.env.TESSELITH_KEY}`,
      ...(init.body !== undefined && { "Content-Type": "application/json" }),
      ...(init.idempotencyKey && { "Idempotency-Key": init.idempotencyKey }),
    },
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
  })
  if (res.status === 204) return undefined as T
  const json = await res.json()
  if (!res.ok) throw new ApiError(res.status, json.code, json.detail)
  return json as T
}
# tesselith.py
import os, requests

API = "https://api.tesselith.com/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['TESSELITH_KEY']}"

class ApiError(Exception):
    def __init__(self, status, code, detail):
        super().__init__(f"{code}: {detail}")
        self.status, self.code = status, code

def api(method, path, json=None, idempotency_key=None, **kwargs):
    headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
    res = session.request(method, API + path, json=json, headers=headers, **kwargs)
    if res.status_code == 204:
        return None
    body = res.json()
    if not res.ok:
        raise ApiError(res.status_code, body["code"], body["detail"])
    return body

Step 2: Find the channels to post to

Posts target channels, which are the social accounts connected to the workspace. GET /channels returns each with an id, platform, name, handle and status. Look them up by platform or handle rather than hard-coding ids, so the script keeps working if someone reconnects an account. Skip any channel whose status isn't active: its authorization has lapsed, and posting to it will fail until someone reconnects it in the app.

type Channel = { id: string; platform: string; handle: string; status: string }

const { data: channels } = await api<{ data: Channel[] }>("/channels")
const targets = channels
  .filter((c) => c.status === "active")
  .filter((c) => ["instagram", "threads", "bluesky", "linkedin"].includes(c.platform))
  .map((c) => c.id)

Step 3: Upload the image

Media is uploaded once and referenced by id. If the image already lives at a public URL (a CMS asset, a product photo on your CDN), let the API fetch it instead of downloading and re-uploading it yourself. Include alt text: it's what screen readers announce, and on several networks it's also what search indexes.

const media = await api<{ id: string }>("/media", {
  method: "POST",
  body: {
    url: article.coverImageUrl,      // must be public https
    alt_text: article.coverImageAlt,
  },
  idempotencyKey: `media-${article.id}`,
})

To upload from disk instead, send multipart/form-data with a file field. Images can be up to 32 MB and videos up to 100 MB through the API. The response includes the stored file's dimensions and content type, which is handy for checking an image meets a network's aspect-ratio rules before you schedule it.

Step 4: Create the post (and make it retry-safe)

Now create the post as a draft. One post can target every channel at once. Write the copy for the strictest network you're posting to, or use per-channel overrides when the networks need different text. Bluesky's 300-character limit is usually the binding one; see the character limits guide for every network.

const post = await api<{ id: string; status: string }>("/posts", {
  method: "POST",
  body: {
    body: `New on the blog: ${article.title}\n\n${article.summary}\n\n${article.url}`,
    channel_ids: targets,
    media_ids: [media.id],
    timezone: "Europe/London",
  },
  idempotencyKey: `post-${article.id}`,
})
// post.status === "draft"

The Idempotency-Key is not optional

Here's a failure that bites every automation sooner or later. Your script sends the create request, the API creates the post, and then the connection drops before the response gets back. Your code sees a timeout and retries, and now there are two identical posts queued. On social media, a duplicate post is publicly embarrassing in a way most bugs aren't.

The Idempotency-Key header fixes this. Send a key derived from your own record (the post- prefix plus the article id, above) and the API remembers the first successful response for 24 hours. A retry with the same key gets that original response back, flagged with Idempotent-Replayed: true, instead of creating a second post. Because the key comes from your data rather than a random UUID, it even survives your script crashing and restarting. Reuse a key with a *different* body and you get a 422 IDEMPOTENCY_KEY_REUSED, which catches the bug where two different articles accidentally share a key.

Step 5: Schedule it, or queue it

A draft doesn't publish. You have two ways to give it a time, and choosing the right one is the most important design decision in a posting automation.

Exact timePosting queue
CallPOST /posts/{id}/schedulePOST /posts/{id}/queue
You decideThe date and timeOnly the order
Best forLaunches, events, deadlines, anything date-boundEvergreen content, steady weekly cadence, backlogs
CollisionsTwo automations can pick the same minuteEach post takes the next free slot, so no pile-ups
Changing cadenceRe-time every postEdit the weekly slots once; queued posts re-flow

Scheduling for an exact time

await api(`/posts/${post.id}/schedule`, {
  method: "POST",
  body: { scheduled_at: "2026-11-03T09:30:00+00:00" },
})

Always include a UTC offset or Z. The API rejects a bare local time like 2026-11-03T09:30:00, because nobody can tell whose 9:30 that means. The scheduler checks every minute, so posts go out within a minute of their time.

Adding to the queue

A posting queue works the other way around. Each channel has a weekly schedule of time slots (say weekdays at 9:00 and 17:00 in your timezone), and queueing a post gives it the next free one. When a post goes to several channels, it waits for a slot that's free on all of them, so the same announcement lands everywhere at once instead of drifting across the week.

const queued = await api<{ scheduled_at: string }>(`/posts/${post.id}/queue`, {
  method: "POST",
  body: { position: "last" }, // or "next" to jump the line
})
console.log("Will publish at", queued.scheduled_at)

If a channel has no schedule yet, set one with PUT /channels/{id}/schedule. The queue call returns 422 NO_QUEUE_SLOT when there's no free slot, so the right response in code is usually to fall back to an exact time:

try {
  await api(`/posts/${post.id}/queue`, { method: "POST", body: {} })
} catch (err) {
  if (err instanceof ApiError && err.code === "NO_QUEUE_SLOT") {
    await api(`/posts/${post.id}/schedule`, {
      method: "POST",
      body: { scheduled_at: tomorrowAtNine() },
    })
  } else throw err
}

Step 6: Handle errors like you mean it

Production automations fail in boring, predictable ways, and the API tells you which one with a stable code. The rule of thumb: retry only what can succeed unchanged. Everything else needs a person or a code change.

CodeRetry?Why
RATE_LIMITED (429)Yes, after Retry-AfterYou went over 120 requests a minute
IDEMPOTENCY_IN_PROGRESS (409)Yes, shortlyThe same request is still running
INTERNAL_ERROR (500)Yes, with backoffA transient failure on the server
VALIDATION_ERROR (422)NoFix the fields listed in errors
UNKNOWN_CHANNEL (422)NoA channel id is wrong or was removed
POST_LIMIT_REACHED (403)NoThe plan's monthly post limit is used up
UNAUTHORIZED (401)NoThe key was revoked, expired or rotated
async function withRetry<T>(fn: () => Promise<T>, attempts = 5): Promise<T> {
  for (let i = 0; ; i++) {
    try {
      return await fn()
    } catch (err) {
      const retryable =
        err instanceof ApiError &&
        (err.status === 429 || err.status >= 500 || err.code === "IDEMPOTENCY_IN_PROGRESS")
      if (!retryable || i === attempts - 1) throw err
      await new Promise((r) => setTimeout(r, 2 ** i * 1000))
    }
  }
}

Because every create carries an idempotency key, wrapping it in withRetry is safe: the worst case is a replayed response, never a duplicate post. For exact rate-limit handling, read the Retry-After header rather than guessing, and watch the RateLimit header on normal responses to slow down before you hit the wall.

Step 7: Know when it published (without polling)

Scheduling a post is a promise. Publishing is the event you actually care about, and it can fail for reasons outside your control: a revoked token, a network outage, an image the network rejects. You could poll GET /posts/{id} every few minutes, but that wastes requests and still finds failures late.

Subscribe to webhooks instead. Tesselith sends a signed post.published or post.failed request to your URL within seconds, retries it for about 27 hours if your server is down, and signs it to the Standard Webhooks spec so you can verify it in a few lines. A channel.reconnect_required event warns you when an account's authorization lapses, before the next scheduled post fails. We cover the details in Social media webhooks, explained.

Putting it together

Here's the whole automation as one function you can call from a CMS publish hook, a cron job or a CI pipeline:

export async function shareArticle(article: Article) {
  const { data: channels } = await api<{ data: Channel[] }>("/channels")
  const targets = channels.filter((c) => c.status === "active").map((c) => c.id)
  if (targets.length === 0) throw new Error("No active channels to post to")

  const media = await withRetry(() =>
    api<{ id: string }>("/media", {
      method: "POST",
      body: { url: article.coverImageUrl, alt_text: article.coverImageAlt },
      idempotencyKey: `media-${article.id}`,
    })
  )

  const post = await withRetry(() =>
    api<{ id: string }>("/posts", {
      method: "POST",
      body: {
        body: `${article.title}\n\n${article.url}`,
        channel_ids: targets,
        media_ids: [media.id],
      },
      idempotencyKey: `post-${article.id}`,
    })
  )

  const queued = await withRetry(() =>
    api<{ scheduled_at: string }>(`/posts/${post.id}/queue`, {
      method: "POST",
      body: {},
      idempotencyKey: `queue-${article.id}`,
    })
  )
  return { postId: post.id, publishesAt: queued.scheduled_at }
}

Production checklist

  • Least-privilege keys. An integration that only creates drafts for a human to approve needs channel:read and post:create, not post:publish. Scheduling requires a separate scope on purpose.
  • One key per integration. Name keys after what uses them ("CMS autoposter", "Release bot"). Then the key list's *last used* column tells you what's alive, and revoking one doesn't break the others.
  • Set an expiry on keys for short-lived projects, and rotate long-lived ones on a schedule. Rotation kills the old secret immediately, so deploy the new one first.
  • Deterministic idempotency keys from your own ids, on every POST.
  • Alert on `post.failed` and `channel.reconnect_required` so a dead account is noticed the day it dies.
  • Log the post id against your source record, so support can go from "why didn't this go out?" to the post in one step.
  • Respect plan limits. Posts created by the API count toward the workspace's monthly allowance like any other.

Common automations worth building

  • Blog to social. When an article publishes, queue a post on every channel. (If you'd rather not write code, Tesselith's built-in RSS automation covers the simple version.)
  • Product launches. When a product goes live in your store, schedule an announcement for an exact time on launch day and a reminder queued for the following week.
  • Release notes. A CI step that posts the changelog headline when a release is tagged.
  • Events. Schedule a countdown (one week, one day, one hour before) from a single event record.
  • Approval flows. Your system creates drafts; a human reviews and schedules them in the calendar. Give the integration post:create only.

Wrapping up

Automating social media posting comes down to a small number of calls done carefully: find channels, upload media, create a draft with an idempotency key, then queue or schedule it, and listen for the result with webhooks. The hard parts (network auth, media rules, publishing retries) are the scheduler's job. Your job is choosing when posts go out and making retries safe. The full reference is in the API docs, and every error is explained in the error code reference.

Questions people ask

Can I automate posting to Instagram with an API?
Yes. Connect an Instagram professional account to a scheduler like Tesselith, then create and schedule posts through its API. The scheduler handles Instagram's Graph API, media rules and token refresh for you.
What's the best way to avoid duplicate posts when automating?
Send an Idempotency-Key header derived from your own record id on every create request. If a request is retried after a timeout, the API returns the original post instead of creating a second one.
Should I use a posting queue or exact times for automated posts?
Use a queue for evergreen content and steady cadence: each post takes the next free slot, so automations can't pile posts into the same minute. Use exact times for launches, events and anything tied to a date.
How do I know if an automated post failed?
Subscribe a webhook endpoint to post.failed and channel.reconnect_required. You'll get a signed request within seconds instead of finding out from a gap in your feed.
Do I need to be a developer to automate social media posting?
Not for the common cases. Tesselith's posting queue, RSS auto-posting and evergreen recycling cover most automation without code. The API is for when the content lives in your own systems and you want them to post on their own.

Try Tesselith free

Connect your channels and schedule your first post in minutes. No credit card required.

Join the waitlist

Related pages

API getting startedAPI error codesWebhooks guideREST API overview

More from the blog

How to Schedule Social Media Posts With Claude (and Other AI Assistants) Using MCPWhat Is a Social Media Posting Queue? How to Set One Up That Never Runs DrySocial Media Webhooks, Explained: Get Notified the Moment a Post Publishes or FailsChoosing a Social Media Scheduling API in 2026: A 12-Point Checklist for Developers

Opening soon

Be first in when the doors open.

Join the waitlist and you'll be in the first wave of invites. One email when it opens, nothing else.

Join the waitlist
Tesselith

The social scheduler for creators, teams and agencies. One calendar, every network you post to.

Product

  • Composer
  • Calendar
  • Analytics
  • Automation
  • AI assistant
  • Posting queue
  • API & webhooks
  • MCP server
  • All features

Solutions

  • Creators
  • Agencies
  • Small business
  • E-commerce
  • All solutions

Compare

  • vs Buffer
  • vs Hootsuite
  • vs Later
  • vs Sprout Social
  • All comparisons
  • Alternatives

Free tools

  • Character counter
  • Best time to post
  • UTM builder
  • Engagement rate
  • All tools

Company

  • About
  • Blog
  • Developer docs
  • Pricing
  • Contact
  • Security
  • Status

Legal

  • Privacy
  • Terms
  • Cookies
  • Acceptable Use
  • DPA
  • Refunds

Schedule posts for

  • X (Twitter)
  • Instagram
  • Facebook Pages
  • LinkedIn
  • YouTube
  • TikTok
  • Threads
  • Bluesky
  • Mastodon
  • Pinterest
  • Reddit
  • Discord
  • Telegram
  • Dev.to
  • Hashnode
© 2026 Tesselith. All rights reserved.
PrivacyTermsCookiesSecurityData deletion
Tesselith