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

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 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=' .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 ProxyInstagramMisconfiguredPUBLISHING_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.com

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

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.

On this page