Skip to main content

Scheduled triggers (cron)

A scheduled trigger posts one event to your function every time its cron schedule comes round. The function does the work; the trigger owns the clock. This page covers exactly what arrives at your handler, how retries work, and the three execution-policy behaviors that surprise everyone.

Triggers live on the Serverless API, but a trigger can target a function — the same platformctl serverless triggers create command, with your function's name as the --target.

Exactly what arrives at your handler

Each firing sends one request:

POST / HTTP/1.1
Content-Type: application/json
ce-specversion: 1.0
ce-id: 3f1a9c22b70d48e1
ce-source: //platform.crusoe.ai/projects/ab12cd/triggers/nightly-rollup
ce-type: ai.crusoe.trigger.schedule
ce-time: 2026-08-12T02:00:00Z
ce-deliveryattempt: 1

{"job":"rollup"}

Point by point, for a function target:

  • Method is always POST — even when there is nothing to send.
  • The body is your configured payload, character for character. Leave the payload blank and the body is {} — a valid empty JSON object, so a JSON-parsing handler never needs a special case.
  • The ce-id header makes it a CloudEvent under the shim's rules: your handler's return value is discarded and the shim answers 204. Write the handler for what it does — and observe it through platformctl logs --history.
  • Whether the ce-* headers reach your handler depends on the runtime. On Python, the shim strips the ce- prefix and hands them over as event["_cloudevent"], so event["_cloudevent"]["source"] tells one trigger from another and event["_cloudevent"]["deliveryattempt"] recognises a redelivery. On Node.js, Go and Ruby the headers really are gone and the handler sees only the JSON body — so on those runtimes, if a function is fired by several triggers and has to tell them apart, put the identity in the payload. Read the key defensively either way: event.get("_cloudevent", {}).get("source").
  • ce-id is unique per firing and stable across retries of that firing; ce-deliveryattempt counts up from 1. Together they let an idempotent handler recognize a repeat — on Python by reading _cloudevent, on the other three by whatever you put in the payload. Neither works within one function instance: a scale-to-zero function has no memory between runs unless it stores state somewhere.
  • An exception raised inside a CloudEvent delivery is answered 400. For a trigger, any 4xx is terminal — never retried. A handler that crashes on a temporary problem loses the run forever. Catch it and return normally when you want the retry. The full retry table, backoff modes, and the 30-second-per-attempt limit are on triggers.

A complete worked example

The function — sweep pending rows every 15 minutes:

def handle(event: dict) -> dict:
job = event.get("job", "sweep")
print(f"scheduled run: {job}")
# ... do the work; look up rows added since the last run ...
return {"statusCode": 200, "ok": True, "job": job} # discarded on trigger firings

Deploy it, then wire the schedule:

platformctl functions deploy ./my-function --name sweeper

platformctl serverless triggers create quarter-hourly \
--target sweeper \
--type schedule \
--cron '*/15 * * * *' \
--payload '{"job":"sweep"}'

You should see:

NAME TYPE SOURCE TARGET STATE LAST RUN
quarter-hourly schedule */15 * * * * sweeper pending -

pending is the normal first answer: the trigger object exists, and its schedule is wired up a moment later.

Leave target.path at its default of /, which is where the function shim answers. (Point a trigger at an agent instead and you almost certainly want /invoke.)

Writing the cron expression

Five fields — minute, hour, day-of-month, month, day-of-week — or an @ shorthand. The API validates at creation, so a typo is a refusal now, not a silent never-firing trigger:

*/15 * * * * every 15 minutes
0 2 * * * 02:00 every day
0 0 * * MON-FRI midnight on weekdays
@daily once a day
@every 90m every 90 minutes

Accepted shorthands: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly, and @every plus a duration.

Sunday is 0, and only 0

Most crontab(5) implementations accept 7 for Sunday. This one does not:

the day of week field has 7, outside the allowed 0-6 - Sunday is 0 here, not 7, unlike crontab(5)

This is the single most common mistake when copying an expression from a server crontab.

Set --time-zone (source.schedule.time_zone) to a region name such as America/Chicago when the wall-clock time matters. Default is UTC. Pick a daylight-saving zone and the schedule shifts twice a year — once skipping an hour, once running it twice. UTC never does either.

Retries, briefly

  • Any 2xx: success. 429 or any 5xx, or no response: retried up to retry.max_attempts (default 5, meaning one try plus four). Any other 4xx: terminal, never retried.
  • Exponential backoff by default: 1 s, 2 s, 4 s, 8 s between attempts, each wait capped at 60 s.
  • Each attempt gets 30 seconds. A handler still working at 30 seconds counts as a failed attempt.
  • A handler exception on a CloudEvent delivery is answered 400 by the shim — so a crashing handler is terminal too. Catch it inside handle and return normally when you want the retry.

Execution policy: three behaviors that surprise people

1. A missed window is skipped, not caught up. If the platform cannot start a firing within 60 seconds of its time, that firing is dropped forever — no backlog replay when things recover. So do not treat one firing as one unit of work. Have the job look up its own work each run (rows added since the last processed one), which also makes the handler naturally idempotent.

2. Firings never overlap. If a delivery is still running when the next window comes round, that window is skipped. An hourly trigger whose work takes 70 minutes fires roughly every two hours, not every hour. If your schedule seems to run at half the rate you set, this is why.

3. retry.max_attempts is the only retry knob. The job underneath never retries itself, so what you configure is exactly what you get.

Observing runs

Trigger firings land in the trigger's run logs; handler output lands in the function's persisted logs:

platformctl logs sweeper --history

A function fired every 15 minutes spends nearly all of its life scaled to zero — live logs will almost always show nothing, which is normal. --history reads what was written before the instance went away.

Next steps

  • Triggers — the full retry/backoff tables and the exact terminal-error log lines.
  • Language guides — per-runtime handler patterns.
  • HTTP and events — the CloudEvent rules the shim applies to every trigger firing.