Head terms

How to Build a Social Media Scheduler

What actually goes into building your own social media scheduler — OAuth token storage, a durable job queue, per-platform API quirks, idempotent retries, and per-target delivery status — plus where a managed API fits if you want to skip the hard 80%.

Quick answer

A production scheduler needs five systems: encrypted OAuth token storage with per-platform refresh cycles, a durable job queue where workers claim due rows atomically with leases, an adapter layer normalizing each platform's quirks, idempotent retries keyed per logical publish so a timeout never double-posts, and an aggregate-plus-per-target delivery status model. Building all five honestly is a multi-month effort even for a small platform set.

The tutorial version of "build a social media scheduler" is a form, a calendar, and a button that fires an HTTP request at the right time. The real version is five separate systems that all have to work correctly at once: a place to keep OAuth tokens that doesn't leak them, a queue that survives a restart without losing or duplicating a job, an adapter layer that hides how differently every platform behaves, a retry strategy that doesn't double-post when a request times out, and a status model detailed enough to tell "it worked" from "three of four platforms worked." None of those five are optional once real customers are trusting the tool with their accounts. This is what each one actually requires.

PostGate handles OAuth, publishing and delivery evidence for every network through one API. Start free or read the docs.

01

How should you handle OAuth token storage?

Every platform issues a credential your scheduler needs to hold onto: an access token, sometimes a refresh token, sometimes an app-password-style secret. These need to be encrypted at rest — not just access-controlled — because a database dump or a misconfigured backup shouldn't hand an attacker live posting access to every connected account. Encryption is the easy part; the part that actually breaks in production is the refresh cycle, and every platform runs a different one. Instagram's long-lived token needs to be refreshed inside a roughly 60-day window before it expires outright. LinkedIn's standard member tokens force reconnection on a similar cadence. X authenticates with OAuth 1.0a instead of OAuth 2.0, so its access tokens don't expire at all and there's no refresh cycle to run — but that also means there's no way to silently repair a revoked connection; you find out only when a publish attempt fails. A scheduler that treats "token storage" as one schema and one refresh job for every platform will pass testing and then fail in week nine, when the first batch of tokens crosses whatever expiry window that specific platform actually enforces.

A per-platform-aware connection schemaPostGate API
create table connected_profiles (
  id                uuid primary key default gen_random_uuid(),
  tenant_id         uuid not null references tenants(id),
  platform          text not null,               -- 'instagram', 'linkedin', 'x', ...
  external_id       text not null,                -- the platform's own account id
  access_token_enc  bytea not null,               -- encrypted, never stored in plaintext
  refresh_token_enc bytea,                        -- null for platforms with no refresh cycle
  granted_scopes    text[] not null default '{}',
  expires_at        timestamptz,                  -- null for tokens that don't expire (e.g. X)
  status            text not null default 'active', -- active | expiring | expired | reauth_required
  created_at        timestamptz not null default now(),
  unique (tenant_id, platform, external_id)
);

-- a scheduled sweep, not a request-time check, is what actually catches expiry in time
create index on connected_profiles (expires_at) where status = 'active';
02

How do you build a job queue that survives a restart?

"Post this at 9am Tuesday" needs durable state, not a timer. An in-memory setTimeout or a plain cron loop with a list held in a process disappears the moment that process restarts — a deploy, a crash, an autoscaler cycling an instance — and running more than one instance for redundancy means every instance holds its own copy of the same timer, so the post fires once per instance instead of once. The fix is to materialize scheduled posts as rows in durable storage ahead of time, then have workers claim due rows atomically instead of just selecting them, so two workers polling at the same instant split the batch instead of colliding on it. Give every claim a lease with an expiry: a worker that dies mid-publish should let the job become claimable again automatically instead of losing it silently. I wrote the full version of this — materialization horizons, claim-with-lease, heartbeats — in a dedicated post on job queue architecture for scheduled posts, since it's a big enough topic on its own.

Claiming due jobs atomically instead of racing on a SELECTPostGate API
UPDATE scheduled_jobs
SET status = 'claimed', claimed_by = $1, claimed_until = now() + interval '90 seconds'
WHERE id IN (
  SELECT id FROM scheduled_jobs
  WHERE status = 'due' AND run_at <= now()
  ORDER BY run_at
  LIMIT 50
  FOR UPDATE SKIP LOCKED
)
RETURNING id, run_at, payload;
03

How different are the platform APIs, really?

Once a job is claimed, publishing it means talking to a provider API, and every provider disagrees with every other one on almost everything that matters. Error formats alone: Meta's Graph API returns numeric error codes and subcodes, TikTok returns string error codes, LinkedIn mixes an HTTP status with a message body, and none of it shares a shape. Media requirements diverge just as much — a single feed image on Instagram has to be JPEG, under roughly 8MB, with an aspect ratio between 0.8:1 and 1.91:1 (portrait 4:5 up to landscape 1.91:1), while a TikTok video accepts MP4 only, up to 4GB and 10 minutes long, with no aspect-ratio check enforced at the API level at all. Publishing flows differ too: Instagram needs a two-step media-container-then-publish sequence, X needs a four-step chunked upload protocol for anything over a few megabytes, and YouTube deliberately blocks recurring scheduled templates because every upload needs an explicit audience and privacy choice at publish time. An adapter layer that normalizes these differences behind one request shape is most of the actual engineering effort in a scheduler — it's also the part that never finishes, because platforms change these rules without much notice.

04

How do you retry without double-posting?

A publishing API is one of the few places where a naive retry is actively dangerous: retrying a timed-out request the normal way can mean the same launch announcement goes out twice on a customer's real Instagram and LinkedIn accounts. The fix is a stable idempotency key attached to every logical publish attempt, generated once from your own internal job or queue message ID — not regenerated per attempt — and honored by the API for a fixed window so a retried request with the same key returns the original result instead of creating a second post. Reusing that key with different content should be rejected outright rather than silently overwriting what a customer already saw published. This is exactly the contract PostGate's Idempotency & safe retries guide documents — a 72-hour retention window, and a 409 conflict on key reuse with a different body — because getting this wrong is the single most damaging bug class a scheduler can ship.

A retry-safe key derived from your own job, not the attemptPostGate API
const idempotencyKey = `schedule:${scheduleId}:${occurrenceTimestamp}`;

// Reused across every retry of the same logical job — a fresh UUID per
// attempt would defeat the entire point of the header.
await fetch('https://postgate.studio/api/posts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${POSTGATE_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': idempotencyKey,
  },
  body: JSON.stringify({ post: { body: caption }, profiles, media }),
});
05

Why is one status boolean not enough?

The failure mode that actually erodes trust in a scheduler isn't a crash — it's reporting "published" when nothing appeared, or hiding the one platform out of four that silently failed behind an overall success flag. A status model that only tracks the job needs two layers: an aggregate status for the whole unified post (draft, scheduled, processing, then a terminal published, partial, failed, or canceled), and a separate status per platform target, since "three published, one failed" is a real, common, and entirely valid terminal outcome that a single boolean cannot represent. PostGate models exactly this split — see Delivery lifecycle for the full state machine — and treats the initial create response as acceptance, not proof of publication: real status only exists once a terminal webhook fires or a target's state is read back.

06

What you just read is the hard 80%

Token storage, a durable queue, a per-platform adapter layer, idempotent retries, and per-target delivery status — none of that is the interesting 20% that makes a scheduler product actually worth building: the customer-facing scheduling UX, the content workflow, the approval flow, the analytics your users actually care about. If the plan is to build all five of those systems from scratch, budget for it honestly — it's a multi-month effort even for a small platform set, and every platform you add repeats most of the work. PostGate exists specifically to skip that 80%: one create-post endpoint across every supported platform, hosted OAuth connections instead of building each provider's consent flow yourself, a 72-hour idempotency window handled server-side, and the aggregate-plus-per-target status model already built in — so the only code left to write is the product you actually set out to build.

Frequently asked questions

Why can't I use setTimeout or a cron loop for scheduled posts?

Because in-memory timers disappear the moment the process restarts — a deploy, a crash, an autoscaler cycling an instance — and running multiple instances for redundancy means every instance holds its own copy of the timer, firing the post once per instance. The fix is materializing scheduled posts as durable rows, having workers claim due rows atomically (FOR UPDATE SKIP LOCKED) instead of racing on a SELECT, and giving every claim a lease with an expiry so a worker that dies mid-publish releases the job automatically.

How do I stop a retried publish request from double-posting?

Attach a stable idempotency key to every logical publish attempt, generated once from your own internal job or queue message ID — not regenerated per attempt. The API honors the key for a fixed window (72 hours in PostGate’s contract), so a retried request with the same key returns the original result instead of creating a second post, and reusing the key with different content is rejected with a 409 conflict rather than silently overwriting a post a customer already saw.

How should a scheduler store OAuth tokens?

Encrypted at rest, not just access-controlled — a database dump or misconfigured backup should not hand an attacker live posting access to every connected account. The harder part is the refresh cycle, which differs per platform: Instagram's long-lived token must be refreshed inside a roughly 60-day window, LinkedIn's member tokens force periodic reconnection, and X's OAuth 1.0a tokens never expire but can be revoked with no silent repair. Catch expiry with a scheduled sweep, not a request-time check.

Why is a single success/failure flag not enough for delivery status?

Because "three platforms published, one failed" is a real, common, and entirely valid terminal outcome that a single boolean cannot represent. You need two layers: an aggregate status for the whole post (draft, scheduled, processing, then published, partial, failed, or canceled) and a separate status per platform target. And treat the initial create response as acceptance, not proof of publication — real status only exists once a terminal webhook fires or the target state is read back.

Build it with PostGate

One API for every social platform your customers care about — sandboxed, documented, and ready today.

Start building Read the docs