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

Page: https://sdk.chatfuel.com/docs/guides/scheduled-publishing
Markdown: https://sdk.chatfuel.com/docs/guides/scheduled-publishing.md

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.

<Steps>
  <Step>
    ### Install `publishing` together with `auth` [#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:

    ```bash
    npx @chatfuel/wizard --modules publishing,auth
    ```

    On an app that already exists, [add a module](/docs/guides/add-a-module) covers the `--embed`
    route. [The auth gate](/docs/concepts/auth-gate) is what you are turning on along with it.
  </Step>

  <Step>
    ### Put the queue on your Supabase project [#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.

    <Tabs items="['The wizard applies it', 'You paste it']">
      <Tab value="The wizard applies it">
        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.
      </Tab>

      <Tab value="You paste it">
        The files are copied into your project either way, because re-running them in the SQL editor is
        the documented repair path and on the manual path it is the only way the schema gets there. Open
        `https://supabase.com/dashboard/project/<ref>/sql` and run them in name order, `0001` first.
      </Tab>
    </Tabs>

    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.
  </Step>

  <Step>
    ### Check the shared secret [#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:

    ```bash
    grep '^PUBLISHING_SECRET=' .env
    ```

    Nothing back means you are filling it in yourself, and both halves have to match:

    ```bash
    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.
  </Step>

  <Step>
    ### Say where this deployment answers [#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:

    ```bash
    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.
  </Step>

  <Step>
    ### Register the callback [#register-the-callback]

    One call, once per deployment, as a signed-in owner or admin of the workspace:

    ```bash
    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.
  </Step>

  <Step>
    ### Verify: queue one post a few minutes out and watch it go [#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.
  </Step>
</Steps>

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

## What to read next [#what-to-read-next]

The [publishing module](/docs/modules/publishing) covers the composer, the account gate and the
library. [The auth gate](/docs/concepts/auth-gate) is what mounts these routes at all, and
[proxy routes](/docs/reference/proxy-routes) lists every path under `/chatfuel/`.
[Environment](/docs/reference/environment) is the full variable table.
