# Scheduled jobs

A scheduled job runs work at a set time, with no visitor: purging sessions, sending a digest, recomputing a ranking. Bastide offers two kinds: application jobs, declared in `glowsoft.json`, and database jobs, written in SQL in the `pg_cron` style. This page covers both, their syntax, their time zone, their limits by tier and how to read their runs.

## Application jobs

They are declared in the `glowsoft.json` at the root of your repository, and take effect on the next deployment. They run for **production** only.

### Calling a path

The form is that of `vercel.json`: a project imported from Vercel keeps its jobs.

```json
{
  "crons": [
    { "path": "/api/cron/cleanup", "schedule": "0 3 * * *" },
    { "path": "/api/cron/report?format=short", "schedule": "30 7 * * MON", "timezone": "Europe/Paris", "overlap": "skip", "timeoutSeconds": 120, "retries": 1 }
  ]
}
```

At each slot, the platform calls `GET <path>` under the production host name, through the edge like a visitor: a sleeping application wakes up for its job. The call carries:

- `User-Agent: bastide-cron/1`;
- `X-Bastide-Execution`: the attempt's unique identifier (`exp_…`);
- `X-Bastide-Signature`: an Ed25519 signature, `t=<Unix seconds>,kid=<fingerprint>,v1=<base64url signature>`.

A 2xx response makes the attempt succeed; any other makes it fail, and it is retried. An application that cannot be reached (no connection, or a 502 from the platform) gets the slot marked **missed** after its last attempt.

### Checking that the call comes from the platform

`User-Agent` and `X-Bastide-Execution` prove nothing: check the signature. The signed bytes are six lines joined by `\n`, with no trailing newline: `bastide-cron/1`, `GET`, the received `Host` header in lower case, the received path (query string included), the value of `X-Bastide-Execution`, and `t`.

1. Read the public key once: `GET https://api.bastide.app/v1/scheduled-runs/signing-key` returns `{ algorithm, keyId, publicKey }`, the key in PEM. No token is required.
2. Reject a `kid` that is not the `keyId` you read: the key has been replaced, read it again.
3. Reject a `t` more than 300 seconds away from your clock.
4. Verify the signature over what you **received**. To reject a replay, remember `X-Bastide-Execution`: it never comes back.

```js
import { createPublicKey, verify } from "node:crypto";

const key = await (await fetch("https://api.bastide.app/v1/scheduled-runs/signing-key")).json();
const publicKey = createPublicKey(key.publicKey);

export function comesFromPlatform(req) {
  const m = /^t=(\d+),kid=([0-9a-f]{16}),v1=([A-Za-z0-9_-]{86})$/.exec(
    req.headers["x-bastide-signature"] ?? "",
  );
  if (!m || m[2] !== key.keyId) return false;
  if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
  const signed = [
    "bastide-cron/1",
    "GET",
    String(req.headers.host).toLowerCase(),
    req.url,
    req.headers["x-bastide-execution"],
    m[1],
  ].join("\n");
  return verify(null, Buffer.from(signed), publicKey, Buffer.from(m[3], "base64url"));
}
```

### Running a container: the `cron` service

A job can also run in its own container, without going through your `web` service:

```json
{
  "services": [
    { "name": "web", "type": "web" },
    { "name": "cleanup", "type": "cron", "start": "cleanup", "schedule": "0 3 * * *" }
  ]
}
```

- `services` always holds exactly one `web`.
- `start` is the name of a script in your `package.json`, never a command. The container is built like a `worker` and starts with that script.
- No call goes out and nothing is signed: the **exit code** concludes the attempt, zero succeeds, anything else fails and is retried. Past its timeout, the container is stopped and the attempt fails.

### Job fields

| Field | What it says | Default |
| --- | --- | --- |
| `path` | The path called, query string included; starts with `/`, at most 255 characters. | required (path job) |
| `schedule` | The expression, at most 128 characters. | required |
| `timezone` | An IANA time zone name. | `Europe/Paris` |
| `overlap` | If the previous run is still going: `skip` drops the slot, `queue` waits for it, `kill` stops the previous one. | `skip` |
| `timeoutSeconds` | A run's timeout, from 10 to 3,600 seconds. | 300 |
| `retries` | Retries of a failed run, from 0 to 5, with a 30-second back-off doubled each time. | 2 |

### At least once

An attempt can run **more than once**: a retry follows a failure even when your code had finished its work, and a `cron` service container whose node is replaced restarts on the next one. Write jobs that can be replayed safely. A slot discovered more than five minutes late is not launched: it is recorded as missed. A new deployment does not catch up past slots.

## Expression syntax

Five fields, to the minute: minute, hour, day of month, month, day of week.

| Element | Example | Meaning |
| --- | --- | --- |
| Value | `30 7 * * *` | Every day at 7:30. |
| List | `0 8,12,18 * * *` | At 8:00, 12:00 and 18:00. |
| Range | `0 9-17 * * MON-FRI` | Every hour from 9:00 to 17:00, Monday to Friday. |
| Step | `*/15 * * * *` | Every fifteen minutes. |
| Names | `0 0 1 JAN *` | Months `JAN`…`DEC` and days `SUN`…`SAT`, case-insensitive. |
| Shortcuts | `@daily` | `@hourly`, `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly`, `@annually`. |

- Sunday is both `0` and `7`.
- **When both day of month AND day of week are restricted, the job runs if either matches**: `0 0 1 * MON` means "the 1st and every Monday", as with cron and Vercel.
- **Daylight saving changes** follow the `cronie` rule. In spring, a fixed-time job that falls in the hour that does not exist runs once, at the first minute after the jump. In autumn, it runs at the first occurrence only. A job whose hour field is `*` or `*/n` follows real time.
- The granularity is one minute: a per-second expression is refused.

A wrong expression or time zone **drops the job without failing the deployment**: the deployment log gives the job's position, the field and the rule, never the value written.

## Database jobs

For purely SQL work, write the job in your database, with the functions you would use on Supabase:

```sql
select cron.schedule('purge-sessions', '*/10 * * * *', $$ delete from sessions where expire_le < now() $$);
select cron.unschedule('purge-sessions');
select * from cron.job_run_details order by start_time desc limit 20;
```

`cron.alter_job(job_id, active, fenetre_secondes)` suspends or resumes a job and sets its window. This is not `pg_cron`: the platform reads your jobs and runs them itself, which changes a few things.

- **The time zone is UTC**, and the granularity one minute.
- **The command runs inside a transaction**, under your project's role, through the pooler: no `VACUUM` and no `COMMIT`. It is stopped after 10 minutes.
- **At most 50 jobs per database.**
- **A window** (60 to 3,600 seconds, 300 by default): a slot that has not started within it is recorded as `missed`, with its cause, instead of vanishing. A slot whose previous run is still going is recorded the same way.
- **A new job** is picked up at most five minutes after it is created.
- **The history** (`cron.job_run_details`, states `running`, `succeeded`, `failed`, `missed`) is purged beyond 30 days.
- **An alert**: each failure or missed slot emits the `database.cron.failed` event to the project's endpoints, grouped by job (see [Webhooks](webhooks.md)).

## Limits by tier

| Tier | Application jobs per project |
| --- | --- |
| Discovery | 5 |
| Standard | 20 |
| Pro | 100 |
| Sovereign | 500 |

Path jobs (`crons`) and `cron` services count together. Every declared job counts, even one that will be dropped for a wrong expression. Beyond the cap, the deployment is refused with a message naming the tier and the cap. Database jobs have their own cap of 50 per database, whatever the tier.

## Reading runs

Every attempt of an application job (its slot, its state, its duration, its code, and the start of the response or of what the container wrote) is kept **90 days**.

- **Console**: the **Journaux** (Logs) screen, « Tâches planifiées » (scheduled jobs) view.
- **Agent**: `logs_read` with `scheduledRuns: true`; at most fifty attempts per read, most recent first, the rest counted.
- **Command line**: `bastide logs read --project-id <project> --scheduled-runs`.

```json
{
  "projectId": "prj_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "environmentId": "env_01JQ8ZK4M7NP2RSTVWXYZ34567",
  "scheduledRuns": true
}
```

Database job runs are read in `cron.job_run_details`, through the SQL editor or `database_sql_read`.

## Common errors

- **« N tâche(s) planifiée(s) écartée(s), le déploiement continue »** (N jobs dropped, the deployment carries on): read the position and the field; fix the expression or the time zone.
- **Deployment refused, « au-delà des N que le palier permet »** (beyond the N the tier allows): remove jobs or change tier.
- **Missed slot**: the application did not answer (stopped, 502); read its logs.
- **The job runs twice**: it is a retry; make it replayable with `X-Bastide-Execution`.
- **A database job does not start**: check that it is active and that its expression has five fields; a refused expression never runs, and the refusal is reported only once.

## See also

- [Logs](logs.md), [Webhooks](webhooks.md), [Database](database.md), [Migrate from Vercel](from-vercel.md), [Limits by tier](limits.md)
- Reference: [logs.read](../reference/operations/logs.read.md)
