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 id | publishing |
| Install it | npx @chatfuel/wizard --modules publishing |
| Selected by | Default — --yes installs it |
| Requires | Nothing, beyond the implicit core |
| Recommends | auth, admin, automations |
| Skill | chatfuel-publishing |
| Migration | 0001_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.
| 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 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
| 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_MEDIA_BUCKET | The 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_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. |
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
| 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. 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:
| 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 |
| One media file through the proxy's media route | 25 MB, and one of five types |
| 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 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.
Publishing, as a product
The four formats, the five states, where media comes from, and the calendar — from the person writing the post.
Schedule Instagram posts
The migration, the shared secret, PUBLIC_URL, and the one click in /admin that points the timer at this deployment.
Accounts
Recommended alongside this one: the Supabase project a scheduled queue fires from.
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.
Broadcasts
WhatsApp campaigns over the flow API — a campaign is a flow, the four statuses, the audience as a segment, the schedule in the bot's zone, and the template catalog you cannot write to.