shiply.now

Crons

Schedule your site's worker to run on a recurring UTC schedule — daily digests, hourly polling, retention emails

Cron triggers fire on a schedule (UTC). Use them for daily reminders, weekly digests, hourly polling, or any recurring background job — without standing up a separate cron service.

Crons run on the same per-site worker as Functions, so they share the same bindings (env.SITE_DB, secrets, variables).

How the tick reaches your code depends on what your worker exports:

Your workerHow a cron arrives
Exports scheduled() (hand-written worker.ts)shiply calls scheduled(event, env, ctx) — see Handler
Exports only fetch() (Next.js, SvelteKit, Astro, any framework bundle)shiply sends a request to the path you registered — see Framework apps

Framework builds can't add a scheduled export — the adapter owns the entry point — so for those the registered path is the whole mechanism.

Declare schedules in .shiply/crons.json

{
  "crons": [
    { "path": "/api/cron/daily-reminder", "schedule": "0 9 * * *" },
    { "path": "/api/cron/hourly-sync",   "schedule": "0 * * * *" }
  ]
}

On shiply publish, shiply registers these as Cloudflare Worker cron triggers.

crons.json is the source of truth on publish. A deploy replaces the site's whole cron set with what the file declares. A cron you added afterwards with shiply cron set is dropped by the next deploy unless it's also in the file — so keep the file in your build output, not just in your repo root.

Schedule syntax

Standard 5-field crontab, UTC only:

ExpressionMeaning
0 9 * * *Every day at 09:00 UTC
*/5 * * * *Every 5 minutes
0 * * * *Top of every hour
0 0 1 * *First of each month at 00:00 UTC
0 0 * * 0Every Sunday at 00:00 UTC

Full reference: Cloudflare cron triggers.

Handler

Add a scheduled method to your worker default export. Dispatch on event.cron to handle multiple schedules in one worker:

// worker.ts
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // ... your normal request handling
    return env.ASSETS.fetch(request)
  },

  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    // event.cron === "0 9 * * *" — match on this to dispatch
    if (event.cron === '0 9 * * *') {
      await sendDailyReminders(env)
    }
    if (event.cron === '0 * * * *') {
      await syncFromUpstream(env)
    }
  },
}

scheduled runs in the same V8 isolate as your fetch handler with the same bindings — including any secrets and the attached D1 database (env.SITE_DB).

Framework apps (no scheduled export)

A Next.js / SvelteKit / Astro build exports only fetch — the adapter owns the entry point, so you can't add a scheduled handler. When the schedule fires and your worker has no scheduled export, shiply calls the registered path as a request into your own app, in-isolate (no network hop, so it never touches your public routes from outside):

POST /api/cron/daily-reminder
x-shiply-cron: 0 9 * * *
authorization: Bearer <your CRON_SECRET>   # only when the site has one
  • POST first, because tick routes usually mutate. If your handler answers 405, shiply immediately retries the same path as GET — so a read-style route handler works without changes.
  • x-shiply-cron carries the schedule expression that fired, so one route can serve several schedules.
  • The request host is shiply-cron.internal, not your domain. If your handler builds absolute URLs from the incoming Host, read your public hostname from a variable instead.

So write an ordinary route:

// app/api/cron/daily-reminder/route.ts
export async function POST(req: Request) {
  if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('unauthorized', { status: 401 })
  }
  await sendDailyReminders()
  return new Response('ok')
}

x-shiply-cron is not authentication. It's a plain request header, and your cron path is a normal route on a public site — anyone can curl it and set that header themselves. Authenticate the real way: shiply secret set <slug> CRON_SECRET <random> and compare the Authorization: Bearer … value, as above. shiply attaches that header automatically once the secret exists.

Deployed before 2026-07-27? Cron delivery lives in the per-site worker that shiply publish uploads, so a site last deployed before that date won't deliver until it's published once more. shiply cron set on its own won't start it — the schedule registers and the dashboard lists it, but nothing fires. One shiply publish fixes it.

Manage from the CLI

shiply cron ls <slug>                              # list configured crons
shiply cron set <slug> /api/cron/daily "0 9 * * *" # add or replace one
shiply cron rm  <slug> /api/cron/daily             # remove one

Manage from MCP

ToolPurpose
list_cronsRead configured crons for a site
set_cronAdd or replace a cron trigger
remove_cronDrop a cron trigger by path

Limits

  • 20 cron triggers per site (shiply cap; Cloudflare's per-Worker limit on the underlying paid plan is higher).
  • 30 s CPU per scheduled run — same isolate budget as fetch handlers.
  • No sub-minute schedules. * * * * * (every minute) is the smallest interval; Cloudflare doesn't accept second-precision crontabs.