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 click 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 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 with auth and admin
Three modules have a part in this one feature. publishing brings the table and the routes;
auth brings the database they live on; admin brings the password that says where this
deployment answers. Both of the last two are opt-in, so --yes takes neither — name all three:
npx @chatfuel/wizard --modules publishing,auth,adminThe 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.
An app with auth and no admin has the queue and no way to point the timer at itself, because
the row that records the callback address belongs to the whole deployment rather than to any
workspace in it — and every sign-up is the owner of the workspace it opens, so a workspace role
would be no smaller a set than "anybody who registered".
On 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 content/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 — and why this one reads what
0001_chatfuel_auth.sql made.
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. Their names carry a prefix
that follows the media bucket's — cf-pub-claim-due and cf-pub-reap by default — which is what
keeps a second deployment on the same project from silently taking the first one's jobs over.
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 and from nothing else:
PUBLIC_URL=https://posts.example.comNothing in the request decides it. Not a body field, not x-forwarded-host — a plain request
header, settable by whoever is calling — and not Host either: a platform routes by Host and
will usually not deliver a request for a name it does not serve, but that is the platform's
property rather than this code's, and a server run directly answers to whatever name it is handed.
A deployment that has not said which name it answers to cannot register, and is told so with 409 ProxyPublicUrlMissing.
It has to be https, and the database enforces that too: the secret rides that address in a
header once a minute for as long as the deployment lives, and over plain HTTP that is the
credential in the clear on every tick. A server on this machine is the exception, where there is
no wire to read it off — local dev sets PUBLIC_URL=http://localhost:5173.
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 only, with --force so re-running overwrites rather than duplicating.
Turn scheduling on from /admin
Open /admin, unlock it with ADMIN_PASSWORD, and go to the Health tab. Under the health
tiles is a card called Scheduled posts, saying Off, and one button: Enable scheduled
posts. Press it.
That is the whole procedure, and the missing form is the point rather than an omission: there is
nowhere to type an address, because the address is PUBLIC_URL on the server and letting the
request choose it would let whoever makes the request choose where a credential gets posted every
minute. The button posts to /chatfuel/publishing/register with the admin cookie, and the route
builds the URL from PUBLIC_URL and records three things — that address, the host's protection
bypass, and the hash of the shared secret — then answers { "scheduling": true, "publishUrl": … }.
The card then reads On, and its button becomes Register again, which is what to press after
the address in PUBLIC_URL changes.
The card is drawn only where the queue's routes are mounted; where they are not, there is nothing on the Health tab to press. And the address cannot be set at scaffold time because the app has not been deployed yet, which is why this is a click and not a variable.
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 — five statuses in all: draft, scheduled, publishing, published, failed. The jobs
run every minute, so up to a minute of lateness is the design, and one tick takes at most twenty
due posts.
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. Taking a post is itself a write, so a
callback delivered twice publishes once. A claim nobody ever came back for is put back by the
reaper on the next tick — unless the app had already started publishing it, in which case it is
marked failed and left for a person, because the post may well be on the account.
A file uploaded through the composer goes to the platform's own storage, is deleted a short
while 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,
from media already on the account, or from a bucket that outlives the afternoon: the publishing
migration creates one on your own project (cf-pub-media by default, public because Instagram
fetches the bytes itself), and the proxy's media route is what writes into it — 25 MB a file,
512 MB a bot before a 507, and a file no post refers to any more let go after 30 days. The app
and the migration must name the same bucket; the wizard fills both from
PUBLISHING_MEDIA_BUCKET.
What to read next
The publishing module covers the composer, the account gate and the
library. The admin panel is the door the last step goes through.
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.