puter.events.onSchedule()events_disabled.Runs a handler your app published with puter.events.handlers.publish() on a timer. A schedule can run at an interval, on a cron expression in a time zone, or once at a set time. It's stored on the user's account and runs in your app's events worker, whether or not your app is open. It never runs in the client that created it.
To run a handler when the user's data changes instead, use puter.events.onPersistent().
Change Data at a Set Time walks through a one-time schedule with at, including how to cancel and move it.
Only an app can create a schedule, meaning an app running on Puter or a website using Puter.js. The schedule belongs to the user the app runs for, and to that app. On a website with nobody signed in, it asks the user to sign in first, as other Puter.js calls do. An app running on Puter is always signed in.
puter.events.onSchedule(options)
options (Object) (required)
Give exactly one of every, cron or at.
every (Number | String): Run at this interval, in milliseconds or as a duration such as '30m', '6h' or '1d'. The units are s, m, h, d and w. The interval must be a whole number of seconds, at least your plan's minimum interval, and at most 366 days.cron (String): Run on a cron expression, such as '0 9 * * 1-5' for 09:00 on weekdays.tz (String): The IANA time zone the cron expression runs in, such as 'America/New_York'. Defaults to 'UTC'. Only allowed with cron. See Time zones and daylight saving time.at (Number | String | Date): Run once, at this time. It must be at least 1 second and at most 366 days ahead. A schedule with at takes no startAt, expiresAt or maxRuns.startAt (Number | String | Date): For every, the first run. Without it, the first run is one interval after this call. A startAt in the past sets where the intervals line up, and the first run is the next one after this call. For example, every: '1d' with startAt: '2026-01-01' runs at midnight UTC every day. For cron, the earliest time the schedule may run. At most 366 days ahead.expiresAt (Number | String | Date): The last time the schedule may run. A run due at exactly this time still runs.maxRuns (Number): How many slots the schedule has, from 1 to 100,000. It ends at its maxRuns-th slot counted from the first. It counts slots, not runs that happened, so slots skipped while the schedule was behind or suspended (see When a schedule runs) still count, and the retries of one slot count once. With expiresAt too, the one that comes first ends the schedule.handlerName (String) (required): The published handler each run calls.handler (Function | String | Object): The handler source you wrote this schedule against, as a function, a source string, or { file: '~/AppData/…/handler.js' }. Only its hash is sent. The schedule is created only if it matches what's published under handlerName. It never runs in this client.handlerHash (String): The SHA-256 hex digest of the handler source, in place of handler.context (Object): Values the handler needs, passed to every run as a frozen ctx. Up to 4 KB serialized. It's read once, at this call, and can't be changed later. To change it, remove the schedule and create a new one.The at, startAt and expiresAt options each take one of these:
'2026-11-01T18:30:00Z' or '2026-11-01T18:30+02:00'. Seconds and up to 3 fraction digits are optional.'2026-11-01', which is read as midnight UTC.Date.Times are rounded up to the next whole second. A string with a time and no zone, such as '2026-11-01T18:30', is refused with events_schedule_invalid, because it could be read in any zone.
These times are in milliseconds, unlike expiresAt on onPersistent(), which takes seconds. Every time a schedule reports, including event.scheduledFor, is in milliseconds too.
A cron expression has the 5 standard fields, separated by spaces:
| Field | Values |
|---|---|
| Minute | 0-59 |
| Hour | 0-23 |
| Day of the month | 1-31 |
| Month | 1-12, or JAN-DEC |
| Day of the week | 0-7, where 0 and 7 are Sunday, or SUN-SAT |
Each field takes a number, * for every value, a list such as 1,15, a range such as MON-FRI, or a step such as */15 or 0-30/10. Names can be in any case.
The expression can also be one of these nicknames:
| Nickname | Runs |
|---|---|
@yearly, @annually |
0 0 1 1 *, midnight on January 1 |
@monthly |
0 0 1 * *, midnight on the first of the month |
@weekly |
0 0 * * 0, midnight on Sunday |
@daily, @midnight |
0 0 * * *, every midnight |
@hourly |
0 * * * *, the start of every hour |
Some cron syntax isn't supported, and is refused with events_schedule_invalid:
?, L, W and #, and a day of the week starting with +.@reboot.When neither the day of the month nor the day of the week is *, a day matches if either one matches, as in standard cron. So 0 9 13 * 5 runs at 09:00 on the 13th of each month and at 09:00 every Friday. A day that a month doesn't have is skipped, so 0 0 31 * * runs only in months with 31 days.
The expression has to run within 366 days of the call. An expression that never runs, such as 0 0 31 2 *, is refused with events_schedule_never_fires. So is 0 0 29 2 * while the next February 29 is more than 366 days away.
A cron expression runs in tz. For example, 0 9 * * 1-5 with tz: 'America/New_York' runs at 09:00 New York time all year, which is 13:00 UTC in the summer and 14:00 UTC in the winter.
The tz option takes an IANA name. A UTC offset such as '+02:00' is refused with events_schedule_invalid, since an offset has no daylight saving rules. The schedule reports the zone under its canonical name, so 'US/Eastern' comes back as 'America/New_York'.
When the clocks change:
30 2 * * *, runs once that day. When its time is skipped because the clocks go forward, it runs at the first minute after the gap (03:00 in New York). When its time happens twice because the clocks go back, it runs once.* in the minute or hour field, such as 0,30 1 * * * or 0 * * * *, skips the runs that fall in the gap when the clocks go forward. When the clocks go back, it runs at each repeated time twice. Each of those runs has its own scheduledFor and event.id.A schedule can't run more often than your plan's minimum interval, which is 1 minute on a paid plan and 15 minutes on a free one. For every, the interval is what's checked. For cron, it's the smallest gap between two runs, checked over the next 10,000 runs or 400 days, whichever ends first. For example, 0-4 9 * * * runs at 09:00, 09:01, 09:02, 09:03 and 09:04 each day. Those runs are 1 minute apart, so a free plan can't use it. A schedule under the minimum is refused with events_schedule_too_frequent.
The plan also limits how many schedules the user can hold for your app and in total, and how many runs per hour all of them can add up to. Over those limits, the call fails with events_schedule_limit or events_schedule_budget. With maxRuns on a cron schedule, the last run has to fall inside those same 10,000 runs or 400 days, or the call fails with events_schedule_invalid. See Rate Limits and Quotas for the numbers.
The plan's limits are checked when a schedule is created. A schedule keeps running if the user later moves to a plan with lower limits. Schedules created after that are held to the new plan.
Each run is due at a time, called its slot, which the handler gets as event.scheduledFor. A run usually starts within 60 seconds of its slot.
So that schedules due at the same moment don't all start at once, Puter starts each schedule's runs a fixed delay after their slots. The delay comes from the schedule's id, and is a whole number of seconds under 30 seconds or a tenth of the schedule's smallest gap, whichever is smaller. For an at schedule it's under 10 seconds. So a schedule that runs every minute has a delay of at most 5 seconds.
A run can start later than 60 seconds after its slot when it's being retried, when your app's schedules for this user go over 200 runs a minute, while your events worker is being deployed, or while the user's account is suspended. The handler can compare event.firedAt with event.scheduledFor to see how late a run is.
Puter starts one attempt at a time for each schedule, and the next slot waits until the current run finishes or is given up. A handler that's still running when its 30 seconds are up can overlap its own retry, which has the same event.id.
When a schedule falls behind, for example while a run was being retried, it doesn't make up every slot it missed. It runs the latest slot that's due, once, and event.missed counts the slots it skipped. A schedule that resumes after a suspension waits for its next slot, and that run's missed counts the slots it skipped while it was suspended.
Runs are at-least-once. A retry, or a handler that outlives its timeout, can run one slot twice. Every attempt at one slot has the same event.id, <scheduleId>:<scheduledFor>, so record it once the work is done and skip ids you've already handled:
await puter.events.handlers.publish('sendDigest', async ({ event, ctx, user }) => {
const doneKey = `digest:${event.id}`;
if (await user.kv.get(doneKey)) return;
await fetch(ctx.webhookUrl, { method: 'POST', body: `Digest for ${event.scheduledFor}` });
await user.kv.set(doneKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}, { appUid });
The handler is called with event, ctx, user and fetch, the same bindings a persistent subscription's handler gets in the events worker. The ctx binding is the frozen context. There's no ack.
A run's event carries:
| Field | Type | Description |
|---|---|---|
subject |
String | schedule:<scheduleId>. |
op |
String | Always 'tick'. |
id |
String | <scheduleId>:<scheduledFor>. The same on every attempt at one run, so use it to skip work you've already done. |
scheduleId |
String | The schedule this run belongs to. |
scheduledFor |
Number | The slot this run is for, in milliseconds since the epoch. |
firedAt |
Number | When this attempt was sent to the handler, in milliseconds since the epoch. |
missed |
Number | Slots skipped since the last run, for example while the schedule was suspended. Only this run happens for them. |
missedCapped |
Boolean | Present, and true, when missed stopped counting at 1,000. |
attempt |
Number | Attempts at this run, this one included. 1 on the first try. |
The user binding is valid for 15 minutes, so use it only during the run. Writes made through user count toward the same handler chain limit as a subscription's, starting from the depth of the call that created the schedule.
| The handler | Result |
|---|---|
| Resolves | Done. The schedule moves on to its next slot. |
| Throws | The run is dropped and the schedule moves on to its next slot. It counts as a failure. |
Throws a temporary error, meaning a network error, a timeout, a rate limit (429), a 5xx, or any error with retry: true |
Retried after 2 seconds, then 4, 8 and so on, up to 5 minutes apart, with the same event.id. It counts as a failure. |
| Runs past 30 seconds | Retried on the same schedule. It counts as a failure. |
A run that fails because of Puter doesn't count as a failure. It's retried on the same schedule, and waits longer if Puter's answer asked for a longer wait, up to 5 minutes. To drop the run in that case anyway, throw an error with terminal: true. The rules are the same as for a queue subscription, except that the retries stop once the schedule's next run is due, or after 24 hours for an at schedule or a last run. The run is then dropped without billing.
Five failures in a row suspend the schedule with failures. The developer is notified, and publishing the handler again resumes it.
A schedule runs your handler while the user isn't there, so it needs the per-app permission events:background, as onPersistent() does. Without it, the call fails with events_background_consent_required. To ask for it, call puter.perms.request() with the 'backgroundEvents' resource:
await puter.perms.request('backgroundEvents');
When the user revokes it, every schedule your app holds for them ends with permission_revoked. Granting it again doesn't bring them back, so create them again.
A schedule can stop running without being removed. It's then suspended, and listSchedules() shows state: 'suspended' and a suspendedReason:
suspendedReason |
Cause | Resumes when |
|---|---|---|
handler_not_found |
Its handler was removed, or the app's events worker was destroyed. | A handler is published under that name again. |
failures |
Its handler failed 5 times in a row. | The handler is published again. |
no_credit |
The user ran out of credit. | The balance is topped up (checked every few minutes). |
A suspended schedule isn't billed. One that stays suspended for 30 days ends.
An ended schedule never runs again. It has state: 'ended' and an endReason, and stays in listSchedules() for 7 days, after which it is deleted:
endReason |
Cause |
|---|---|
removed |
It was removed with removeSchedule() or off(). |
ended |
Its last run is done (an at schedule, expiresAt or maxRuns), or it stayed suspended for 30 days. |
permission_revoked |
The user revoked events:background for your app. |
uninstalled |
The user uninstalled your app. |
app_deleted |
The app was deleted. |
holder_gone, app_gone |
The user's account or the app no longer exists. |
orphaned |
A create or remove that failed partway left it incomplete, and Puter cleaned it up. |
delivered, dropped |
A notification sent later with notify() was sent, or couldn't be. |
When schedules end with permission_revoked, the user's app gets an app.events.ended notification about them, which you can read with puter.events.fetch().
A Promise that resolves to the schedule. All times are in milliseconds since the epoch.
scheduleId (String): Its id, which getSchedule() and removeSchedule() take.kind (String): 'recurring' for every and cron, 'once' for at, and 'deferred' for a notification sent later with notify().appUid (String): The app that created it.handlerName (String | null): The handler each run calls, or null once the schedule has ended.contextKeys (Array | null), contextHash (String | null): The key names and a hash of the stored context, never its values. The contextKeys field is null when there's no context, and once the schedule has ended.every (Number | null): The interval, in milliseconds, of an every schedule.cron (String | null): The expression of a cron schedule.tz (String): The time zone of a cron schedule, and 'UTC' for the others.startAt (Number): The first time it may run. For at, its only run.endsAt (Number | null): The last time it may run, from expiresAt or maxRuns. For at, its only run.state (String): 'active', 'suspended', 'sending', or 'ended'. A delayed notification is 'sending' once it has started to go out.suspendedReason (String | null): Why it's suspended.endReason (String | null), endedAt (Number | null): Why and when it ended.nextRunAt (Number | null): The next slot of the schedule, worked out from its definition and the clock. A late or retried run for an earlier slot can still happen first. After the last slot is due, it stays at that slot until the run for it is done. It's null while the schedule is suspended or sending, and once it has ended.createdAt (Number): When it was created.off() (Function): Removes the schedule. Same as puter.events.removeSchedule(scheduleId). Only on the object onSchedule() returns.The promise rejects with { message, code }:
code |
Meaning |
|---|---|
invalid_request |
options isn't an object. |
events_handler_required |
There's no handlerName. |
events_handler_name_required |
handler was given without handlerName. |
events_handler_free_variable |
The handler uses an outside variable. The message names it. |
events_handler_invalid |
handler isn't a function, a source string, or { file }. |
events_handler_hash_unavailable |
This environment has no crypto.subtle, so handler can't be hashed. Pass handlerName alone. |
events_context_invalid |
context isn't JSON-serializable. |
events_context_too_large |
The serialized context is over 4 KB. |
auth_canceled |
Nobody was signed in, and the user closed the sign-in without finishing it. |
events_schedule_app_required |
The call wasn't made by an app, for example from Node.js with an account token. |
events_durable_requires_account |
The call came from a scoped access token, such as the one in a getReadURL() URL, or from a temporary (guest) account. |
too_many_requests |
Over the create rate limit, which is shared with subscribing. |
events_background_consent_required |
The user hasn't granted your app events:background. |
events_schedule_invalid |
An option is malformed. For example, more than one of every, cron and at, an every that isn't a whole number of seconds, a time with no zone, an at less than 1 second or more than 366 days ahead, an unsupported cron expression, an unknown tz, or tz without cron. The message says which. |
events_schedule_never_fires |
The schedule would never run. Its first run or its startAt is more than 366 days away, or it ends before its first run. |
events_schedule_too_frequent |
It would run more often than your plan's minimum interval. |
events_schedule_too_deep |
The call was made from a handler at the end of a handler chain, so the schedule could never run. |
bad_request |
handlerName is empty or over 128 characters, or handlerHash isn't a SHA-256 hex digest. |
events_handler_not_found |
Nothing is published under handlerName. The schedule isn't created. |
events_handler_hash_mismatch |
The published handler isn't the source you passed as handler. |
events_schedule_limit |
The user holds as many schedules as their plan allows, for your app or in total. |
events_schedule_budget |
The schedule would make your app's schedules, or all of the user's schedules, run more times an hour than their plan allows. |
events_schedule_busy |
Puter is checking too many cron expressions at once. Try again in a moment. |
events_disabled |
Schedules aren't available on this server, as on self-hosted servers. |
events_failed |
The server sent a response the SDK couldn't read. |
The handler is published once, by the app's developer, from a deploy script or the browser console. The schedule is created from the app, for each user.
Run a handler on weekday mornings in the user's time zone
// Deploy script, run as the app's developer
const app = await puter.apps.get('daily-digest');
await puter.events.handlers.publish('sendDigest', async ({ event, ctx, user }) => {
const doneKey = `digest:${event.id}`;
if (await user.kv.get(doneKey)) return;
const notes = await user.kv.list('note:*', true);
await user.notifications.notify({
title: `Good morning, ${ctx.name}`,
text: `You have ${notes.length} notes.`,
});
await user.kv.set(doneKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}, { appUid: app.uid });
// In the app, signed in as the user
await puter.perms.request('backgroundEvents');
const schedule = await puter.events.onSchedule({
cron: '0 8 * * 1-5',
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
handlerName: 'sendDigest',
context: { name: 'Ana' },
});
if (schedule.nextRunAt !== null) console.log(`next digest at ${new Date(schedule.nextRunAt).toString()}`);
Run once, an hour from now
const reminder = await puter.events.onSchedule({
at: Date.now() + 60 * 60 * 1000,
handlerName: 'remind',
context: { todoId: 'todo-42' },
});
// Changed your mind
await reminder.off();
Run every 6 hours, on the hour, 28 times
await puter.events.onSchedule({
every: '6h',
startAt: '2026-01-01T00:00:00Z',
maxRuns: 28,
handlerName: 'syncFeeds',
});