# Schedules Hook

> Run plugin code on a timetable with a cron expression. Full example, cron basics and common mistakes.

Source: https://sendbroadcast.net/docs/plugins-hook-schedules

> Coming in Broadcast 3.0. Until then, this page applies only to the Upcoming (edge) build.

Use this hook to do a job on a timetable, such as a nightly sync.

## How it works

Once a minute, Broadcast looks at the schedules of every plugin that is on. If one is due, a background job calls `onSchedule`. If the plugin is on in three channels, it runs three times, once for each channel.

## Full example: daily activity

This plugin writes a line to its log each morning. The line says how many subscribers changed.

`manifest.json`:

```json
{
  "id": "local.daily-activity",
  "name": "Daily activity",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "permissions": {
    "subscribers": "read"
  },
  "hooks": {
    "schedules": [{ "name": "morning", "cron": "0 9 * * *" }]
  }
}
```

`dist/main.js`:

```javascript
export default definePlugin({
  async onSchedule(run, ctx) {
    if (run.name !== "morning") return;

    // The first run has no earlier time, so it looks at the last 24 hours.
    const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
    const since = (await ctx.store.get("last_run")) ?? dayAgo;

    const page = await ctx.broadcast.subscribers.list({ limit: 100, updated_since: since });
    const more = page.next_cursor ? " or more" : "";
    ctx.log.info(`${page.items.length}${more} subscribers changed since ${since}.`);

    await ctx.store.set("last_run", run.scheduled_for);
  },
});
```

The log line looks like this: `10 subscribers changed since 2026-10-31T18:54:26.538Z.`

## What you get

```json
{ "name": "morning", "scheduled_for": "2026-11-02T09:00:00Z" }
```

`name` is the name from the manifest. A plugin can have several schedules, and one function handles them all, so check `run.name`.

`scheduled_for` is the planned time. It stays the same when a run is tried again, so it makes a good `idempotency_key` for bulk calls.

## Cron in one minute

A cron expression has five parts: minute, hour, day of month, month and day of week.

| Expression | Runs |
|---|---|
| `0 9 * * *` | Each day at 09:00 |
| `0 2 * * 1` | Each Monday at 02:00 |
| `*/15 * * * *` | Every 15 minutes |
| `0 9 1 * *` | At 09:00 on the first day of each month |

Times use the clock of the server. To fix the time zone, add its name at the end: `0 9 * * * Europe/Paris`.

The shortest gap is 5 minutes. A faster schedule is refused with `hooks.schedules.morning: runs more often than every 5 minutes.`

## Common mistakes

**You expect a run right after you turn the plugin on.** Broadcast never runs a schedule that it "missed". The first run comes at the next due time after Broadcast first sees the schedule.

**You do too much in one run.** You have 10 seconds. Save your place in storage, do one batch and continue at the next run.

**You move your place forward too early.** Save `last_run` as the last step. If the run fails, the retry starts from the same place.

**You forget that the list stops at 100.** `subscribers.list` reads the first 100 people. To read more, the plugin needs `"subscribers": ["read", "export"]`, which is a sensitive permission.
