Queue a post once with text, an image and a link, choose which pages it goes to, set a time. A worker publishes it and records, per destination, whether it landed and where. Facebook Pages, Instagram Business and LinkedIn organisations are live today.
That last detail is the whole design. Most schedulers report that a post was sent. This one stores the live post's identifier and permalink for each platform, which turns did it actually publish to Instagram from an assumption into something you can click.
One post, several independent destinations
A post is one record. Where it goes is one record per account, each publishing independently with its own status, external identifier, permalink, error and attempt count. A Facebook success is never lost because Instagram failed.
The parent rolls up to published when all destinations succeeded, failed when none did, and partial when some did. Partial is a real state rather than a rounding error. Without it you either hide a failure or throw away a success, and both cost trust the first time someone checks.
Tokens are the fragile part
Access tokens are encrypted at rest with AES-256-GCM, with the key held in the server environment and never sent to a browser, so a database dump alone does not let anyone post as the client. Account state is explicit: active, expired, revoked or error. An expired token is something to display, not an exception to swallow.
LinkedIn tokens expire after about sixty days and are refreshed before publishing when they are close to it, rather than after a failure. Failing first and asking a human to reconnect turns a solved problem into a support ticket. Meta system-user tokens do not expire, so they skip that path instead of running refresh logic for nothing.
Platform quirks live in adapters
The core knows nothing about any platform. It signs an image URL, picks a fresh token, calls an adapter and writes down the result. Each adapter absorbs one platform's weirdness, so adding a platform means writing one adapter rather than touching anything else.
Instagram publishes in two steps: create a media container, then publish it. The container is frequently not ready when you ask, and the honest response to that is to poll and retry on the next tick rather than mark a perfectly good post as failed. Meta fetches the image itself, so media sits in a private bucket and gets a thirty-minute signed URL at publish time: long enough for the fetch, short enough not to linger as a public asset.
The tick is five minutes, on purpose
Posting happens one to three times a day. Minute precision buys nothing and multiplies invocations by five, so the worker runs every five minutes and claims a bounded number of posts per run. A backlog drains over the next few ticks instead of one run trying to do everything, timing out halfway and leaving posts stuck mid-publish with nobody to finish them.
What it does not do
Images only, no video: video is a different upload flow on every platform and deserves its own scope rather than an assumption. Facebook, Instagram and LinkedIn only. Publishing is confirmed, but reach and engagement are not read back, so this is a publishing system and not an analytics one.
The part that actually costs time is not the code. It is platform approval: Meta App Review for posting to a client's pages, and LinkedIn's Community Management API for posting as an organisation, which is not granted casually. Start the approvals before the code. The code is days; the approvals are not.