Scheduled jobs
Run a handler on a schedule, under an identity that outlives whoever set it up.
A scheduled job runs one of your published handlers on an interval or a cron expression. Create
them in the console under Build → Scheduled jobs, from a handler with sdk.crons, or over the
API.
A job is two things: a schedule and a principal. The schedule half is ordinary. The principal half is worth understanding before you create your first one.
A job runs as itself
Every job gets its own identity, and the job runs as that — never as the person who created it. What a job may do is decided entirely by the roles you bind to its identity.
The obvious alternative is to run a job as whoever set it up. That is wrong for this shape of thing. A scheduled job is infrastructure with an indefinite lifetime; a person's access is a fluid claim about their current role. Tie the first to the second and someone changing teams silently disarms your nightly invoice sweep — and it fails as a stream of denials nobody is watching.
So the two questions are kept apart:
| Question | Answered by | Changes when |
|---|---|---|
| What may this job do? | the roles bound to its identity | you deliberately bind or remove one |
| Who set this up? | the job's created_by | never — it is audit, not authorization |
A new job holds no roles and is created paused, so it is born incapable of doing anything. Arming it is a separate act, governed by its own permission. That is the point: creating a schedule and giving it power are two decisions, and nothing collapses them into one.
The trade-off, stated plainly
An over-privileged job is invisible unless someone looks — no one's departure will ever prune it. Two things help: the job list shows each job's bound-role count on the row, and every run records the roles the job actually held at the moment it ran, so you can compare what a job used against what it may do.
Schedules
Interval — every N seconds, minimum 60. Measured from the previous scheduled instant rather than from when the last run finished, so a job that takes eight seconds never drifts to every sixty-eight.
Cron — a standard five-field expression (minute hour day-of-month month day-of-week), plus
the usual macros: @hourly, @daily, @weekly, @monthly, @yearly. Ranges, lists, steps and
three-letter names all work, and Sunday is accepted as both 0 and 7.
Two things about cron expressions surprise almost everyone:
- Setting both day fields means OR, not AND.
0 0 1 * monis "the 1st and every Monday", not "Mondays that fall on the 1st". - Expressions run in the job's own timezone.
0 2 * * *means 2am where you set it, and it stays 2am across daylight saving — its UTC offset shifts, its local time does not.
Daylight saving is handled explicitly rather than left to chance. On the day an hour is skipped, a job scheduled inside the gap runs at the first moment it can (02:30 becomes 03:30 local) instead of being silently skipped for the day. On the day an hour repeats, the job runs once, on the first occurrence — running twice would be a duplicate invoice; running early is merely early.
Use the Next runs panel in the editor, or sdk.crons.preview, before you save. Real timestamps
prove what a schedule does; a description of one only restates what you typed, and it cannot show
you that your DST assumption was wrong.
Missed windows are skipped, never replayed
If your API is down from 01:00 to 07:00, an hourly job does not wake up owing six runs. It runs once, and that run records that six windows were missed.
Replaying is almost never what a scheduled job wants — six duplicate invoice emails — and a catch-up switch would put that one checkbox away. So skipping is the only behaviour, and the count is kept, because a skip nobody can see is indistinguishable from a gap where nothing was due.
Timing
The scheduler ticks every 15 seconds. A job fires at or after its due time, never before, and how late it can be is bounded by that tick: a job due at 02:00:00 runs somewhere in 02:00:00 to 02:00:15.
If you need sub-minute precision, you want a queue, not a schedule. We would rather say so than imply a guarantee the scheduler does not make.
The run history
Every attempt is recorded, including the ones that were refused.
| Status | Meaning |
|---|---|
succeeded | The handler returned successfully. |
failed | The handler errored, timed out, or could not run — for example it has no published version. |
denied | Refused before it ran: the job's identity was deleted or deactivated. |
running | In flight. A run abandoned by a crashed process is settled by the next sweep. |
This is the part that makes a broken job visible. The failure worth guarding against is not a job that crashes loudly — it is one that quietly stops doing anything while its schedule keeps ticking. Denials in this list are what that looks like, instead of silence.
A failed job is still rescheduled. Leaving it due would make every sweep retry it immediately, turning one broken job into a loop hammering whatever it talks to. The consecutive-failure count is surfaced on the job, but it never switches a job off on its own — silently disarming your infrastructure because a downstream API had a bad hour is exactly the failure this feature exists to avoid.
Running a job by hand
Run now fires a job immediately, for real, through the identical path the scheduler uses and as the job's own identity. Rows written and mail sent are permanent — it is the counterpart of a handler dry run, not a safer version of it.
It runs as the job rather than as you on purpose: the mistake a test most needs to catch is "this job is missing a role", and a version that ran as you would sail straight through it.
It does not touch the schedule. The next run, the last outcome and the failure count all describe the health of the schedule, and a manual test must not quietly reset them.
From a handler
sdk.crons is the handler-facing door — useful when a schedule belongs to your data rather than to
your configuration, such as giving each new customer their own digest job.
// Check the schedule before saving it — real fire times, in the target timezone.
const nextRuns = await sdk.crons.preview({
scheduleKind: "cron",
cronExpression: "0 7 * * mon-fri",
timezone: "America/New_York",
});
// Created PAUSED, holding no roles — it can do nothing until someone arms it.
const job = await sdk.crons.create({
name: `Nightly digest — ${data.customerName}`,
targetHandlerUid: data.digestHandlerUid, // the handler's uid
payload: { customerUid: data.customerUid },
scheduleKind: "cron",
cronExpression: "0 7 * * *",
timezone: data.timezone,
});
// The audit trail, including refusals.
const failures = await sdk.crons.listRuns({ status: "failed", limit: 20 });Two grants govern it:
sdk:crons_read— list jobs, read run history, preview a schedule. Granted by default.sdk:crons_write— create, change, pause, delete, trigger. Deny-by-default and high risk, because changing an existing job decides what code runs under an identity someone else armed, andrunNowfires a job as that identity rather than as the caller.
Binding roles to a job is deliberately not in the SDK. Arming a job stays in the console and the governed API, so handler code can create a schedule but can never grant it power — which is what keeps a handler from minting an unattended identity more privileged than itself.
Deleting a job
Deleting a job retires its identity and removes that identity's role bindings, so nothing is left holding access. The run history is kept — "what did this do before we removed it" is exactly the question asked afterwards.