Skip to content
ChatfuelSDK
Modules

Publishing

Instagram publishing over four one-way mutations — the three views, the queue the API does not have, and the failure that has already succeeded.

What it is

There is no post entity on the Chatfuel API. The four instagramAccountPublish* mutations take a bot id and some URLs, publish, and answer with an id and a permalink — nothing is stored on the Chatfuel side afterwards, and no query reads published media back. So a draft, a scheduled time, a failure you can retry and a history to look back at are all this app's own, kept in lib/queue/, and the calendar is a view over that queue rather than over the account.

Instagram only, and one account: the one connected to the bot. Before the composer exists at all the module asks whether that account can publish, because a publish into an account without InstagramBusinessContentPublish fails several seconds later, after somebody has written the whole post.

Installed as

Wizard idpublishing
Install itnpx @chatfuel/wizard --modules publishing
Selected byDefault — --yes installs it
RequiresNothing, beyond the implicit core
Recommendsauth, admin, automations
Skillchatfuel-publishing
Migration0001_publishing.sql, written into your app as supabase/migrations/0010_chatfuel_publishing.sql — only alongside auth

Neither hidden nor railHidden. The first two recommendations are concrete rather than thematic, and they are the two halves of one feature: auth brings the database a scheduled post fires from, and admin brings the password that points the timer at this deployment. An app with this module and neither of those composes and publishes on the spot; one with auth and no admin has the queue and no way to turn it on.

Routes and views

The view is a path segment; everything else is a query parameter. calendar is the default, so it has no segment of its own.

PathWhat it shows
/publishingThe calendar: this app's queue over time, in a month, a week or a list.
/publishing/queueThe same posts as rows, by status, where a failure is read and retried.
/publishing/libraryWhat is already on the Instagram account — posts, carousels, Reels, stories and ads.
ParameterWhat it does
composeThe post whose composer is open: an id, or new.
fromThe InstagramMediaID a new post is being started from, off the library. Only meaningful beside compose=new.
atThe instant a calendar slot handed a new post. Only meaningful beside compose, and cleared with it.
modeCalendar only: month, week (the default) or list.
monthCalendar only, as YYYY-MM. Absent means this one.
statusQueue only: draft, scheduled, publishing, published, failed. Absent means every status.
kindLibrary only: post, reel, story, carousel or ad. Absent means every kind.

An unknown value anywhere falls back in silence, so /publishing/nonsense is the calendar and ?kind=purple is no filter at all. Defaults are left out of what is written, so the back stack does not fill with equivalent addresses.

The library is the only surface here that reads Instagram; the calendar and the queue share a store over the app's own posts. Its refresh action is not a convenience either — the platform serves what it has already pulled down, so an account somebody also posts to from their phone goes stale on its own, and nothing this app publishes appears in the list until a pull has run.

The model

The Chatfuel API publishes immediately. No input takes a time, there is no draft entity, and the only scheduledPublishTime in the whole schema belongs to FbPagePost and is read-only. A content calendar is therefore not a view over something the platform holds. It is a view over a queue the deployment holds, and the queue has to be built.

Which leaves the question a scheduling feature actually turns on: what runs when the time comes? A browser is not an answer — a queue that only fires while somebody has a tab open is a reminder. So there are exactly two shapes, and which one a deployment has decides what the composer may offer:

Where the queue livescanScheduleWhat the composer offers
LocalsetUserStorageItem, the only persistence this API offers a clientfalseSave, and publish now. No time control — absent, not disabled with a sentence beside it.
DatabaseA table on the deployment's own Postgres, with a job on a timertrueA time, and everything that follows from one.

Both implement the same QueueBackend interface, and the difference between them is that one flag. The local store is per signed-in user, so a colleague on the same bot sees their own drafts and not these; a time written into it is a note to self. The module asks the proxy for the scheduled half once at startup, and a route that is not mounted is a valid answer — it means the deployment was scaffolded without the database. The probe treats only a response that is the config as a yes, because a single-page app served by a catch-all answers an unclaimed address with its own HTML and a 200.

The durable shape is worth four notes, because a simpler one gets each of them wrong: claim due rows with for update skip locked before publishing (a publish can run for five minutes, so two ticks will overlap); reap a claim older than about ten minutes, or a function killed mid-publish leaves a post that never goes out and never says why; write the outcome back from the route rather than reading it off the HTTP response; and authenticate the timer's callback with a shared secret, because there is no signed-in user on a cron.

A publish whose HTTP call died may well have succeeded. The mutation blocks while the platform works — around ten seconds for a photo, minutes for a Reel — and the connection can be lost at any point in that. Offering a retry then posts it twice, and there is no unpublish and no delete anywhere in this API. Worse, the obvious signal does not work: a successful publish produces no subscription event, and the post is absent from the media connection until instagramAccountRefetchLatestMedias has run. So the module reads the ids on the account before publishing and, after an unexplained failure, refetches and diffs for twenty seconds. A refusal the server actually sent — a caption too long, a carousel of one — is a decision taken before anything was posted, and a retry after one of those is immediately safe.

That is also why Publishing is a real status rather than a spinner, and why a published row offers Remove from list and never Delete: removing takes it out of this queue and leaves Instagram alone, which is the only thing this API can actually do.

Media, and what survives a schedule

Every publish input carries a link and Instagram's own servers fetch the bytes, so the address has to be reachable from the other side of the internet with no session, no cookie and no header. The proxy requires every one of them — a carousel's items and a Reel's cover alike — to be https:, and refuses the whole post with a sentence in the row rather than dropping the item that failed.

Where the bytes live decides whether a post can be given a time at all. The composer's own uploads go to the platform's file storage, whose URL carries no authorization and does not last: the upload response has a deletion deadline that the GraphQL File type does not carry, so a client cannot read it, only know it is there. Long enough to publish now, and nothing at all for tomorrow morning — which is why a post being given a time refuses one outright, with An upload cannot be held until a later time. Media already on the account and a link somebody pasted both survive.

The deployment's own bucket is the durable answer, and it comes with the publishing migration: a public bucket — public because Instagram fetches the bytes itself and can present no credential — that only the service role writes into, so everything in it went through the proxy's media route with its session check, its bot fence, its 25 MB per file and its list of five accepted types, each checked against the file's first bytes rather than the caller's claim about them. PUBLISHING_MEDIA_BUCKET names it, and the app and the migration must name the same one. Two ceilings bound what it can grow to: PUBLISHING_MEDIA_QUOTA_MB is how much one bot may keep, past which an upload answers 507 MediaQuotaExceeded and nothing is stored, and PUBLISHING_MEDIA_TTL_DAYS is how long a file no post of that bot refers to any more survives — old and unreferenced together, since recent-and-unreferenced is a post somebody is still writing. Their values are on environment variables.

Configuration

VariableNotes
CHATFUEL_TOKENSecret. Server-side only.
VITE_CHATFUEL_WORKSPACE_IDThe workspace you picked during the wizard run.
CHATFUEL_API_BASEDefaults to https://panel.chatfuel.com.
PUBLISHING_MEDIA_BUCKETThe Supabase Storage bucket the media route stores into. The app and the migration must name the same one; the wizard fills both from this value.
PUBLISHING_SECRETSecret, and optional. Needed only for posts that go out unattended — it is what the app and its database use to prove a request came from the other.

Three more matter to a scheduled deployment and are not this module's own: ADMIN_PASSWORD, because turning scheduling on is an /admin action; PUBLIC_URL, where the deployment answers from outside; and VERCEL_AUTOMATION_BYPASS_SECRET on a host that puts authentication in front of every URL. The queue's routes are mounted on the same condition as provisioning — the auth gate on, and a service-role key to reach the database with. All of them, and which side reads each, are on environment variables; the procedure is schedule Instagram posts.

Permissions

ObjectActionRequired for
BotViewReading the connected Instagram account and what it may do.
BotEditPublishing, and reconnecting the account when its permissions are short.

Those are the Chatfuel permissions. The one that decides whether a publish can happen at all is Instagram's: InstagramBusinessContentPublish, on the connected account. The module treats "no account", "an account that cannot publish" and "ready" as three separate screens rather than one empty state.

Limits

A caption is measured in codepoints, not in JavaScript characters. Instagram accepts 2,200 and refuses 2,201 with InstagramPublishCaptionTooLong. A caption of 2,200 emoji is accepted, while .length reads those same emoji as 4,400 and a client counting that way refuses a post the platform would have taken. The database counts it the same way, so a post that could never go out is refused while somebody is still looking at it.

Thirty hashtags is a warning, not a refusal. Instagram counts thirty and quietly ignores the rest, and there is no hashtag field anywhere in this schema.

The publishing rate limit is invisible. Instagram throttles published posts per rolling 24 hours, at media_publish rather than at container creation, and exposes neither the number nor the remaining allowance. It arrives as a failure and can only be reported as one.

Publishing takes URLs, not files. Every publish input carries a link and Instagram's own servers fetch the bytes, so a URL that needs an Authorization header cannot be published however valid it looks in a signed-in browser.

Nothing here reports how a post did. InstagramPost, InstagramReel, InstagramAd and InstagramStory carry no likes, no reach, no impressions — and no timestamp either, which is why media published outside this app cannot be placed on a calendar day at all. There is no delete and no edit: once it is on the account, this API is finished with it. No publish input takes locations, user tags, product tags, collaborators or alt text.

The module's own ceilings:

Caption2,200 codepoints
Hashtags before a warning30
Items in a carousel2 to 10
One publish5 minutes
Double-publish confirmation window20 seconds
Upload polled for a public address2 minutes
One media file through the proxy's media route25 MB, and one of five types
Library page24 items
Media pulled from Instagram on a refresh30
Posts kept in the browser-only queue200, oldest published dropped first

A Reel blocks inside the mutation while Instagram transcodes, and even a feed image is seconds rather than milliseconds. Nothing in this module is a form submit, and the five-minute budget is the host's ceiling as much as the API's: a serverless function killed at five minutes takes the request with it whether or not Instagram has finished.

On this page