# 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.

Page: https://sdk.chatfuel.com/docs/modules/publishing
Markdown: https://sdk.chatfuel.com/docs/modules/publishing.md

## What it is [#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 [#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 [#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-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: &#x2A;*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.

<Callout type="warn">
  **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.
</Callout>

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 [#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](/docs/deploy/environments).

## Permissions [#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 [#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.

<Cards>
  <Card title="Publishing, as a product" href="/modules/publishing" description="The four formats, the five states, where media comes from, and the calendar — from the person writing the post." />

  <Card title="Accounts" href="/docs/modules/accounts" description="Recommended alongside this one: the Supabase project a scheduled queue fires from." />

  <Card title="AI Agent" href="/docs/modules/ai-agent" description="The same Instagram media, seen from the comment-reply side." />

  <Card title="Your agent's notes" href="/docs/concepts/agent-skills" description="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." />
</Cards>
