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. 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 id | publishing |
| Install it | npx @chatfuel/wizard --modules publishing |
| Selected by | Default — --yes installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | auth, automations |
| Skill | chatfuel-publishing |
Neither hidden nor railHidden. auth is recommended for a concrete reason rather than a
thematic one: it brings the database a scheduled post has to fire from.
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.
| Path | What it shows |
|---|---|
/publishing | The calendar: this app's queue over time, in a month, a week or a list. |
/publishing/queue | The same posts as rows, by status, where a failure is read and retried. |
/publishing/library | What is already on the Instagram account — posts, carousels, Reels, stories and ads. |
| Parameter | What it does |
|---|---|
compose | The post whose composer is open: an id, or new. |
from | The InstagramMediaID a new post is being started from, off the library. Only meaningful beside compose=new. |
at | The instant a calendar slot handed a new post. Only meaningful beside compose, and cleared with it. |
mode | Calendar only: month, week (the default) or list. |
month | Calendar only, as YYYY-MM. Absent means this one. |
status | Queue only: draft, scheduled, publishing, published, failed. Absent means every status. |
kind | Library 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 lives | canSchedule | What the composer offers | |
|---|---|---|---|
| Local | setUserStorageItem, the only persistence this API offers a client | false | Save, and publish now. No time control — absent, not disabled with a sentence beside it. |
| Database | A table on the deployment's own Postgres, with a job on a timer | true | A 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 404 is a valid answer — it means the deployment
was scaffolded without the database.
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: measured against
a live account a successful publish produced no subscription event, and the post was
absent from the media connection until instagramAccountRefetchLatestMedias had 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.
Configuration
| Variable | Notes |
|---|---|
CHATFUEL_TOKEN | Secret. Server-side only. |
VITE_CHATFUEL_WORKSPACE_ID | The workspace you picked during the wizard run. |
CHATFUEL_API_BASE | Defaults to https://panel.chatfuel.com. |
PUBLISHING_SECRET | Secret, 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. |
Two more matter to a scheduled deployment and are not this module's own: PUBLIC_URL, where the
deployment answers from outside, and VERCEL_AUTOMATION_BYPASS_SECRET on a host that puts
authentication in front of every URL. All of them, and which side reads each, are on
environment variables.
Permissions
| Object | Action | Required for |
|---|---|---|
Bot | View | Reading the connected Instagram account and what it may do. |
Bot | Edit | Publishing, 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 — found by bisecting a live account. 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.
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:
| Caption | 2,200 codepoints |
| Hashtags before a warning | 30 |
| Items in a carousel | 2 to 10 |
| One publish | 5 minutes |
| Double-publish confirmation window | 20 seconds |
| Upload polled for a public address | 2 minutes |
| Library page | 24 items |
| Media pulled from Instagram on a refresh | 30 |
| Posts kept in the browser-only queue | 200, oldest published dropped first |
A single feed image, already uploaded, took 9.7 seconds end to end on a live account. 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.
Publishing, as a product
The four formats, the five states, where media comes from, and the calendar — from the person writing the post.
Accounts
Recommended alongside this one: the Supabase project a scheduled queue fires from.
AI Agent
The same Instagram media, seen from the comment-reply side.
Your agent's notes
The wizard installs .claude/skills/chatfuel-publishing/ (Codex: .agents/skills/chatfuel-publishing/) — the four publish shapes, references/scheduler.md for the queue's two backends, and references/library.md for reading the account back.
Bookings
A booking workspace over bookingsV2 — the six sections, where availability comes from, and the time-zone rule the schema does not carry.
Ads Optimization
Conversion reporting for click-to-WhatsApp ads — event sets over one automation scope, the seven triggers, and the permission that decides whether any of it is delivered.