Schedule Instagram posts
Chatfuel publishes immediately and stores no post, so a schedule is your own database waking up on a timer and calling your deployment back. The migration, the shared secret, the address it knocks on, and the one call that turns it on.
The Chatfuel API publishes to Instagram immediately. No input takes a time, there is no draft
entity, and the only scheduledPublishTime in the schema belongs to another network and is
read-only. So a content calendar is not a view over something the platform holds — it is a view
over a queue your deployment holds, and something has to be awake to fire it. A browser is not
that something: a queue that only runs while somebody has a tab open is a reminder, and calling
it a schedule is a lie a customer finds out about at midnight.
What actually runs is Postgres. Your own Supabase project carries a table of posts and two jobs that wake up every minute — one takes what is due and knocks on your deployment's door, the other rescues a claim nobody came back for. Your deployment does the publishing, because that is where the Chatfuel token is.
Until all of that is in place the publishing module still works. It composes, it previews, it
publishes on the spot, and it keeps drafts in the signed-in user's own storage. What it does not
do is offer a time: the schedule control is absent rather than present and inert, because a time
written against a store only one browser reads is not a plan.
Install publishing together with auth
The queue routes mount on exactly the condition provisioning does: the auth gate on, and a
service-role key to reach the database with. Without both, the proxy does not claim
/chatfuel/publishing/* at all, the host answers its own 404, and the app reads that as "this
deployment keeps its queue in the browser". That 404 is a contract, not an accident.
The same dependency runs through the scaffold: the supabase/ directory is written only when
auth is selected, so an app without it never receives the publishing migration either.
auth is opt-in, which means --yes does not take it. Name both:
npx @chatfuel/wizard --modules publishing,authOn an app that already exists, add a module covers the --embed
route. The auth gate is what you are turning on along with it.
Put the queue on your Supabase project
The migration is modules/publishing/supabase/migrations/0001_publishing.sql in the SDK, and it
lands in your app as supabase/migrations/0010_chatfuel_publishing.sql. Every module's SQL goes
into that one directory and is applied in name order, which is why the numbers carry a single
sequence across modules rather than each module's own.
Give the wizard a Supabase access token and it runs each migration in order over the management API, stopping at the first failure rather than attempting the rest — they assume the shape the one before them was to make. It reports each as applied and idempotent.
The file is written to be re-run: create … if not exists, create or replace, drop policy if exists. Tables have row-level security on with no policies and their grants revoked — the
SECURITY DEFINER functions are the whole read and write surface, because a table PostgREST can
reach directly is a table a browser can reach directly.
Two extensions do the work. pg_cron runs the jobs, pg_net is how the database reaches your
app; both ship enabled on every Supabase project, the free plan included, and the migration is
guarded on their availability. A project without pg_cron still gets the tables and the routes
and prints a notice. What it does not have is anything that fires on its own, which is precisely
what the app's "can this deployment schedule?" answer is for.
The two jobs are one minute each, and cron.schedule(name, …) replaces a job of the same name,
so re-running the file re-points them rather than piling up duplicates.
Check the shared secret
The scheduler lives on the database and the thing it wakes up lives in your app, so each has to
be able to tell that a request came from the other. One value does both jobs, and a run that took
both publishing and auth has already made it — 24 random bytes as base64url, written into
.env as PUBLISHING_SECRET and, as its sha256 in base64, into the migration it just wrote.
Look for the line before you go further:
grep '^PUBLISHING_SECRET=' .envNothing back means you are filling it in yourself, and both halves have to match:
node -e "console.log(require('node:crypto').randomBytes(24).toString('base64url'))"Put that in .env, and put its hash where the migration's __CHATFUEL_PUBLISHING_SECRET_SHA256__
placeholder is before you run the file. The database never learns the secret. cf_pub_claim_due()
sends the stored hash as the callback's credential, in an x-chatfuel-publish-key header, and the
app hashes what it holds to compare in constant time; in the other direction the app sends the raw
secret and the database hashes it. So neither side can impersonate the other with what it read
from the other's storage.
Base64 rather than hex on purpose, on both sides: a log scrubber that masks 64-hex strings would make a hex secret invisible in exactly the logs somebody needs to read it out of.
Without it the register call below answers 500 ProxyInstagramMisconfigured — PUBLISHING_SECRET is not set — this deployment cannot schedule posts — rather than recording an address it cannot
authenticate.
Say where this deployment answers
Registering records the address the database will post a credential to, every minute, from then on. That makes the address security-sensitive rather than a convenience, so it comes from configuration first:
PUBLIC_URL=https://posts.example.comx-forwarded-host is never read — it is a plain request header, settable by whoever is calling,
and reading it would hand the caller the callback credential on a timer. The Host header is used
only as a fallback, because a platform routes by it and will not deliver a request for a name it
does not serve. That fallback is fine on a single-domain deployment and is not something to rely
on behind a load balancer, on a preview URL, or anywhere more than one name reaches the same app —
and in a deployment serving an agency's clients, every workspace owner may register.
On a Vercel deployment whose production URL is protected, VERCEL_AUTOMATION_BYPASS_SECRET is set
for you. The app passes it to the database when scheduling is turned on, so the callback is not
bounced at the edge before any code runs. A host that puts authentication in front of every URL
blocks a call from your own database as readily as one from a stranger.
npm run deploy pushes PUBLISHING_SECRET as a Vercel sensitive variable and PUBLIC_URL as a
readable one, to production and preview, with --force so re-running overwrites rather than
duplicating.
Register the callback
One call, once per deployment, as a signed-in owner or admin of the workspace:
curl -X POST https://posts.example.com/chatfuel/publishing/register \
-H "authorization: Bearer <a Supabase session access token>"Nothing in the body says where to post: the route builds the URL from the request this server
actually received, so it can only ever name somewhere the deployment really answers. It records
that URL, the protection bypass, and the hash of the shared secret, and answers
{ "scheduling": true, "publishUrl": … }.
The address cannot be set at scaffold time because the app has not been deployed yet, which is why this is a call and not a variable. There is no button for it in the module today — that is a gap, not a design.
After it, GET /chatfuel/publishing/config answers { "scheduling": true } only when all three
are true: the database has an address to knock on, it has a credential to knock with, and the app
still holds the secret that credential was derived from. The composer reads that answer and draws
the time control from it.
Verify: queue one post a few minutes out and watch it go
Open /publishing, press New post, put an image link and a caption in, and set a time three
or four minutes ahead. The primary button changes from Publish now to Schedule post —
that is the deployment telling you it read scheduling: true. A time already past is refused
before it is saved.
Then leave it alone and watch /publishing/queue. Within a minute of the time you set, the row
moves from scheduled through publishing to published with Instagram's own id and permalink
on it. The jobs run every minute, so up to a minute of lateness is the design.
A row that stops at failed carries the reason and offers a retry. The callback is
fire-and-forget — the database's HTTP call returns as soon as it is queued, and a Reel can take
five minutes to publish — so the outcome you are reading was written back by the app itself
through cf_pub_report(), not lifted from an HTTP response. A claim nobody ever came back for is
put back by the reaper on the next tick.
A file uploaded through the composer is deleted from the platform's storage two hours after it lands, and nothing in the API reports that deadline. So a scheduled post refuses one — An upload cannot be held until a later time — and the check applies only to posts being given a time, because publishing now has no such problem. Schedule from a pasted link or from media already on the account.
What to read next
The publishing module covers the composer, the account gate and the
library. The auth gate is what mounts these routes at all, and
proxy routes lists every path under /chatfuel/.
Environment is the full variable table.
Open the operator panel
The panel reads and changes the whole Chatfuel account behind your token, so it opens on a password held next to that token and on nothing else. Installing it, the sixteen-character floor, and why /admin is the only way in.
Replace the Chatfuel token
npx @chatfuel/wizard auth rewrites one line of one file. The rest of the job is getting that value to wherever the proxy actually reads it.