# Cron jobs

Cron jobs run one of your functions on a schedule. Declare them in `flatstack/cron.ts`:

```ts
import { cron } from "@flatstack/server";

export default cron({
  "0 * * * *": "cleanupExpired",                     // every hour, on the hour
  "*/15 * * * *": { fn: "syncFeeds", timeout: "30s" }, // every 15 minutes
});
```

Each key is a schedule; each value names a function exported from `flatstack/functions/*.ts`, or gives the function and a timeout.

## Schedules

Schedules have five fields: minute, hour, day of month, month and day of week. They're always in **UTC**, so daylight saving never moves, skips or repeats a run: `30 1 * * *` runs at 01:30 UTC every day, including the days clocks change.

| Field | Values | Names |
| --- | --- | --- |
| Minute | 0–59 | |
| Hour | 0–23 | |
| Day of month | 1–31 | |
| Month | 1–12 | `jan`–`dec` |
| Day of week | 0–7 (0 and 7 are Sunday) | `sun`–`sat` |

Each field takes `*`, a value (`5`), a list (`1,15`), a range (`9-17`) and a step (`*/10`, `0-30/5`, `5/20`). When both day fields are set, a day matching either runs the job. `@hourly`, `@daily` (or `@midnight`), `@weekly`, `@monthly` and `@yearly` (or `@annually`) work too.

## Limits

| Limit | Hobby | Pro | Studio |
| --- | --- | --- | --- |
| Shortest gap between a job's runs | 15 minutes | 5 minutes | 1 minute |

Unclaimed sandboxes have Hobby's limit. On every plan:

- A run can last up to **30 seconds**. `timeout` sets a shorter limit: `"10s"`, `"5000ms"` or a number of milliseconds. A run that passes it is stopped with `cron_timeout`.
- An environment can have up to **50** jobs.
- A run's CPU time is limited as for requests (50, 200 or 500 ms by plan).
- The **last 100 runs** of each job are kept.

The gap is checked when you deploy: a schedule like `*/5 * * * *` on Hobby stops the deploy with `422 cron_interval_too_short`, with `details.jobs` listing each job's shortest gap and `details.minimumMinutes` your plan's. If you later move to a plan with a longer minimum, or roll back to a deploy whose jobs run more often, those jobs are skipped (see below) until you change their schedules.

## How a run works

The scheduler checks every job each minute. A due job calls its function with no signed-in user: `ctx.user` is `null`, and `ctx.cron` holds the run's `schedule`, `scheduledFor` (the UTC minute it was due) and `attempt`. Everything else in `ctx` works as in a request.

- The function must have `auth: "none"` or `"optional"`, and its `input` (if any) must accept no value, for example with `.default()`. Otherwise the deploy fails, since the job could never run.
- What the function returns is the run's **output**, stored as JSON. Fields named like secrets (`token`, `apiKey`, `password`, …) are stored as `[redacted]`, and output over 4 KB is cut.
- A run that throws, times out or crashes is **retried 3 times**, after 30 seconds, 1 minute and 2 minutes. A run the function can't accept (a missing function, or input it requires) isn't retried. Use `ctx.cron.scheduledFor` to make a job safe to run twice.

## When jobs don't run

Jobs follow the same rules as requests to your app ([How your app is served](serving.md)). The run is recorded as `skipped`, with the reason as an error code:

| Code | When |
| --- | --- |
| `account_locked` | A payment failed and your account is read-only. |
| `account_cancelled` | Your subscription ended and the account is in its read-only grace period. |
| `requests_capped`, `cpu_capped` | Your account used all its requests or CPU time this period. Jobs run again when the period resets. |
| `cron_interval_too_short` | The job runs more often than your current plan allows. |
| `project_deleted`, `not_deployed` | The project was deleted, or the environment has nothing deployed. |

A suspended account's jobs don't run and aren't recorded. When database or file storage is full, jobs still run; writes that add data fail with `507`, as in requests.

## Deploying

`flatstack deploy` reads `flatstack/cron.ts` and sends its jobs with the bundle. The jobs go live with the deploy, in the same step as the code: a failed deploy leaves the previous jobs running. A job whose schedule stays the same keeps its run history; a job removed from `cron.ts` is deleted with its history. A deploy without `cron.ts` has no jobs. Rolling back puts back the jobs of the deploy you roll back to.

## API

| Request | Who | What it returns |
| --- | --- | --- |
| `GET /v1/projects/{id}/cron/jobs` | Everyone | `{ jobs }`: each job's `id`, `environment`, `schedule`, `fn`, `timeoutMs`, `intervalMinutes`, `nextRunAt`, `lastRun` and `deployId`. `?environment=` narrows to one. |
| `GET /v1/projects/{id}/cron/runs` | Everyone | `{ runs, nextCursor }`, newest first: each run's `id`, `jobId`, `environment`, `scheduledFor`, `status` (`running`, `retrying`, `succeeded`, `failed`, `skipped`), `attempts`, `durationMs`, `output`, `error` (`{ code, message }` or `null`), `nextAttemptAt`, `startedAt` and `finishedAt`. Filter with `?environment=`, `?jobId=` and `?status=`; `?limit=` is 1–100 (default 50); pass `nextCursor` back as `?cursor=` for the next page. |

A deploy token sees only its own project, and only its environment if it was created for one. The deploy API takes the jobs as `cron` (see [Deploying](deploying.md)).