Skip to content
Browse the docs

DocsPublish and automate

Scheduling

The built-in cron trigger, an external pinger when the free-plan trigger limit is in the way, the cadence, and failure emails.

On this page

Scheduled posts live in D1 and are published by a tick: either the Worker’s own cron trigger, which needs no setup, or anything that can POST to the tick endpoint.

How a tick works

The Posts page: status filters with counts for All Posts, Scheduled, Published, Failed and Drafts, over scheduled cards showing platform icons, the post text, and a Will publish time with Edit Post, Reschedule and Cancel actions.
The queue a tick drains. Each time is shown in your own timezone with the relative difference beside it.
  • The cron trigger ships enabled in wrangler.jsonc ("triggers") and runs every minute.
  • A tick always publishes the oldest due post, then starts each further one only if its estimated calls still fit in the request’s Cloudflare budget. That budget covers every D1 statement, R2 read and call to a platform, and is 50 on the Workers Free plan. Anything that doesn’t fit stays due for the next tick, so on Free a backlog drains a post or two a minute. This stops a request from running out half-way through a post the platform already accepted, which would publish it twice. On a paid plan, set SUBREQUEST_LIMIT (see Configuration) and a tick publishes everything due at once.
  • Ticks are idempotent: a duplicated or delayed caller is harmless.
  • A tick that runs out of the plan’s CPU budget also leaves the rest due for the next minute.

The trigger is registered the moment the Worker is deployed, but the first tick can take a few minutes — Cloudflare runs a Worker’s cron on machines that have spare capacity. Until one arrives, Settings → Scheduled publishing says “no tick yet”; that is the normal state for the first few minutes of a fresh install, not a failure. If it still says that an hour later, npm run doctor -- --app-url <url> says which of the cases you are in: ticks arriving, no trigger configured, or a refused trigger. A heartbeat older than six hours counts as delayed, and the app says so wherever the scheduler is shown.

Pick one tick

The built-in cron needs nothing from you, but the Workers free plan allows only five cron triggers per account — shared with every other Worker you run. If your account has none left, npm run deploy says so, retries without the trigger, and still ships the app; scheduled posts then wait until something calls the tick endpoint.

Settings → Scheduled publishing covers both paths: it shows whether ticks are arriving, and can generate a token for an external cron (cron-job.org, UptimeRobot, the bundled GitHub Actions workflow). Keep one primary tick — there is no reason to run a per-minute pinger alongside the cron.

To drive the tick from something else — cron-job.org, a Raspberry Pi, a systemd timer, the bundled GitHub Actions workflow, a different cadence on a paid plan — POST to it:

curl -X POST "$APP_URL/api/internal/tick" \
  -H "Authorization: Bearer $SCHEDULER_SECRET" \
  -H "Content-Type: application/json"

The endpoint takes SCHEDULER_SECRET (API_TOKEN still works as a fallback; AUTH_SECRET never does — it signs sessions and is rejected on the wire). Keep Content-Type: application/json too: SvelteKit’s built-in CSRF guard answers 403, before app code ever runs, to a POST that arrives with a form content type (application/x-www-form-urlencoded, multipart/form-data, text/plain) and no matching Origin. A bare curl -X POST sends no content type and gets through, but many cron services default to a form type, so set the header explicitly.

An external caller needs a bearer it can read. Easiest is the token from Settings → Scheduled publishing, which needs no redeploy and cannot reach anything except the tick. The alternative is SCHEDULER_SECRET (openssl rand -hex 32), which then lives in two places — the Worker secret and the pinger’s config — so rotate both together. Without one of those (or API_TOKEN) an external caller cannot authenticate at all; the built-in cron keeps working either way, because the Worker derives the same value.

The bundled GitHub workflow (.github/workflows/scheduler-tick.yml) stays off until you set repository secrets APP_URL and SCHEDULER_SECRET — the token from Settings → Scheduled publishing works as that SCHEDULER_SECRET value. With the built-in cron running, treat it as a backup rather than the primary tick: it is scheduled every five minutes (GitHub’s shortest interval) but GitHub throttles it to roughly one run every two hours. You can also run it by hand from Actions → Scheduler tick → Run workflow.

Cadence

Edit triggers.crons in wrangler.jsonc — */5 * * * * and friends are fine on the free plan too. To use no trigger at all, set "crons": [] and point a pinger at the endpoint instead; npm run doctor -- --app-url <url> confirms which of the two is actually running.

Failure alerts (optional)

The dashboard shows a “failed to publish” banner linking to the Failed tab. To also get a morning-after email, set RESEND_API_KEY and NOTIFY_EMAIL as Worker secrets (the app no-ops without them), plus an optional NOTIFY_FROM sender on a domain verified in Resend. At most one digest is sent per 24h window, covering failures newer than the last digest. Posts that are still retrying are not emailed.

Where to go next

  • Configuration — SCHEDULER_SECRET, the tick token, and the rest of the secrets.
  • API keys — scheduling a post from a script instead of the composer.
  • Install and deploy — changing the deploy so the trigger is not registered at all.

Written for CogSend 1.3.2. This page lives in the app repo, next to the code it describes. Something wrong or missing? Edit docs/scheduling.md or open an issue.