Skip to content
ChatfuelSDK
Guides

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,admin

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.

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=' .env

Nothing 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.com

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

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.

On this page