Schedules Hook
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:
{ "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:
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
{ "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.