docs(events): schedule and delayed-send accuracy and style pass

Spread and send-time wording match the runner, the per-user runs-at-once
and rate-limited stored notification limits are published, and the new
pages follow the docs writing rules.
This commit is contained in:
Daniel Salazar committed 2026-11-01 22:04:22 -07:00
1 parent 31b48f7203
commit 7dc966e1ec
18 files changed
+120 -103

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ What you watch is written as a subject string:
[Subjects and events](/Events/subjects/) has the full subject syntax and the shape of each event.
To run a handler on a timer instead of when data changes, create a **schedule** with [`onSchedule()`](/Events/onSchedule/). It runs your handler at an interval, on a cron expression in the user's time zone, or once at a set time, even while your app is closed:
To run a handler on a timer instead of when data changes, create a schedule with [`onSchedule()`](/Events/onSchedule/). It runs your handler at an interval, on a cron expression in the user's time zone, or once at a set time, even while your app is closed:
```js
await puter.events.onSchedule({
+1 -1
View File
@@ -50,7 +50,7 @@ The promise rejects with `{ message, code }`:
title: 'Stand-up in 10 minutes',
sendAt: Date.now() + 10 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
const schedule = await puter.events.getSchedule(later.scheduleId);
const due = schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleTimeString() : 'nothing due';
+1 -1
View File
@@ -49,7 +49,7 @@ A handler gets one object. Which `event` it carries depends on what ran it:
The other bindings are the same for both: `ctx`, `user` and `fetch`, plus `ack` on a `queue` subscription. See [Where the handler runs](/Events/onPersistent/#where-the-handler-runs). One handler can serve both, and tell them apart by `event.op`.
Schedule runs and `queue` deliveries are at-least-once. A retry, or a handler still running past its 30 seconds when the retry starts, can run the same event twice, and even at the same time. Both attempts have the same `event.id`, so record it once the work is done and skip IDs you've already handled.
Schedule runs and `queue` deliveries are at-least-once. A retry, or a handler still running past its 30 seconds when the retry starts, can run the same event twice, and even at the same time. Both attempts have the same `event.id`, so record it once the work is done and skip ids you've already handled.
## `publish()`
+2 -2
View File
@@ -36,7 +36,7 @@ A `Promise` for one page, `{ items, cursor?, total? }`:
- `cursor` (String): Present while there are more pages to load. Pass it as `cursor` to get the next page.
- `total` (Number): The number of schedules across every page, only with `includeTotal`.
**Pages can be short.** A page with fewer than `limit` items isn't the last one. Keep going until `cursor` is absent.
Pages can be shorter than `limit` before the last one, so keep going until `cursor` is absent.
The `context` a schedule was created with is never returned, since it often holds secrets. The `contextKeys` field lists its key names, and `contextHash` changes whenever a value does.
@@ -66,7 +66,7 @@ The promise rejects with `{ message, code }`:
title: 'Time for a break',
sendAt: Date.now() + 60 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
// (2) Page through everything this app holds for the user
let cursor = null;
+17 -17
View File
@@ -31,11 +31,11 @@ Give exactly one of `every`, `cron` or `at`.
- `at` (Number | String | Date): Run once, at this [time](#times). 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](#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.
- `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](#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.
- `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.
- `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.
### Times
@@ -48,7 +48,7 @@ The `at`, `startAt` and `expiresAt` options each take one of these:
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 are **milliseconds**, unlike `expiresAt` on [`onPersistent()`](/Events/onPersistent/), which takes seconds. Every time a schedule reports, including `event.scheduledFor`, is in milliseconds too.
These times are in milliseconds, unlike `expiresAt` on [`onPersistent()`](/Events/onPersistent/), which takes seconds. Every time a schedule reports, including `event.scheduledFor`, is in milliseconds too.
## Cron expressions
@@ -81,9 +81,9 @@ Some cron syntax isn't supported, and is refused with `events_schedule_invalid`:
- `@reboot`.
- An expression over 128 characters.
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.
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. `0 0 31 2 *` never runs, and is refused with `events_schedule_never_fires`. So is `0 0 29 2 *` while the next February 29 is more than 366 days away.
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.
### Time zones and daylight saving time
@@ -98,17 +98,17 @@ When the clocks change:
## How often a schedule can run
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`.
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](/rate-limits-and-quotas/#schedules) 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.
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.
## When a schedule runs
Each run is due at a time, its **slot**, which the handler gets as `event.scheduledFor`. A run usually starts within 60 seconds of its slot.
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 up to 30 seconds or a tenth of the schedule's smallest gap, whichever is smaller. For an `at` schedule it's up to 10 seconds. So a schedule that runs every minute has a delay of at most 5 seconds.
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.
@@ -118,7 +118,7 @@ When a schedule falls behind, for example while a run was being retried, it does
## Delivery guarantees
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:
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:
```js
await puter.events.handlers.publish('sendDigest', async ({ event, ctx, user }) => {
@@ -157,16 +157,16 @@ The `user` binding is valid for 15 minutes, so use it only during the run. Write
| --- | --- |
| 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: 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. |
| 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](/Events/onPersistent/#what-happens-when-a-handler-fails).
**Five failures in a row suspend the schedule** with `failures`. The developer is notified, and publishing the handler again resumes it.
Five failures in a row suspend the schedule with `failures`. The developer is notified, and publishing the handler again resumes it.
## Background runs need consent
A schedule runs your handler while the user isn't there, so it needs the per-app permission **`events:background`**, as [`onPersistent()`](/Events/onPersistent/#background-delivery-needs-consent) does. Without it, the call fails with `events_background_consent_required`. To ask for it, call [`puter.perms.request()`](/Perms/request/) with the `'backgroundEvents'` resource:
A schedule runs your handler while the user isn't there, so it needs the per-app permission `events:background`, as [`onPersistent()`](/Events/onPersistent/#background-delivery-needs-consent) does. Without it, the call fails with `events_background_consent_required`. To ask for it, call [`puter.perms.request()`](/Perms/request/) with the `'backgroundEvents'` resource:
```js
await puter.perms.request('backgroundEvents');
@@ -176,7 +176,7 @@ When the user revokes it, every schedule your app holds for them ends with `perm
## Suspended and ended schedules
A schedule can stop running without being removed. It's then *suspended*, and [`listSchedules()`](/Events/listSchedules/) shows `state: 'suspended'` and a `suspendedReason`:
A schedule can stop running without being removed. It's then suspended, and [`listSchedules()`](/Events/listSchedules/) shows `state: 'suspended'` and a `suspendedReason`:
| `suspendedReason` | Cause | Resumes when |
| --- | --- | --- |
@@ -209,7 +209,7 @@ A `Promise` that resolves to the schedule. All times are in milliseconds since t
- `kind` (String): `'recurring'` for `every` and `cron`, `'once'` for `at`, and `'deferred'` for a notification sent later with [`notify()`](/Notifications/notify/#send-it-later).
- `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. `contextKeys` is `null` when there's no context, and 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.
@@ -218,7 +218,7 @@ A `Promise` that resolves to the schedule. All times are in milliseconds since t
- `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](#suspended-and-ended-schedules).
- `endReason` (String | null), `endedAt` (Number | null): Why and when it [ended](#suspended-and-ended-schedules).
- `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. `null` while the schedule is suspended or sending, and once it has 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)`](/Events/removeSchedule/). Only on the object `onSchedule()` returns.
+8 -9
View File
@@ -30,7 +30,9 @@ A `Promise` that resolves when the schedule has ended.
An app can only remove schedules it created. An account session can remove any of the user's schedules, including ones left by apps they no longer use. A scoped access token can't remove any.
An id this caller doesn't hold, or a schedule that already ended, is reported as not existing, so removing a schedule twice rejects the second time. With `events_schedule_sending`, `auth_canceled`, `too_many_requests` or `events_disabled`, nothing was removed. When the call fails any other way, such as a network error or `events_failed`, the removal may still have happened: read the schedule again with [`getSchedule()`](/Events/getSchedule/) to find out. It rejects with `{ message, code }`:
An id this caller doesn't hold, or a schedule that already ended, is reported as not existing, so removing a schedule twice rejects the second time. With `events_schedule_sending`, `auth_canceled`, `too_many_requests` or `events_disabled`, nothing was removed. When the call fails any other way, such as a network error or `events_failed`, the removal may still have happened, so read the schedule again with [`getSchedule()`](/Events/getSchedule/) to find out.
It rejects with `{ message, code }`:
| `code` | Meaning |
| --- | --- |
@@ -43,7 +45,7 @@ An id this caller doesn't hold, or a schedule that already ended, is reported as
## Examples
<strong class="example-title">Cancel a notification, then try again</strong>
<strong class="example-title">Cancel a notification, then read it</strong>
```html;events-remove-schedule
<html>
@@ -55,17 +57,14 @@ An id this caller doesn't hold, or a schedule that already ended, is reported as
title: 'Your trial ends tomorrow',
sendAt: Date.now() + 24 * 60 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
await puter.events.removeSchedule(later.scheduleId);
puter.print('cancelled<br>');
// Removing it twice is refused the same way an unknown id is
try {
await puter.events.removeSchedule(later.scheduleId);
} catch (error) {
puter.print(`second attempt: ${error.code}<br>`);
}
// The ended schedule can still be read for 7 days
const ended = await puter.events.getSchedule(later.scheduleId);
puter.print(`${ended.state} (${ended.endReason})<br>`);
})();
</script>
</body>
+1 -1
View File
@@ -51,7 +51,7 @@ It rejects with an error carrying `message` and `code`:
target: '/timers',
sendAt: Date.now() + 30 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
puter.print(`stored ${later.scheduleId}, goes out ${new Date(later.sendAt).toLocaleTimeString()}<br>`);
// (2) The user stopped the timer, so it shouldn't go out
+6 -6
View File
@@ -23,10 +23,10 @@ puter.notifications.notify(notification)
- `text` (String): Up to 240 characters. Line breaks are kept.
- `icon` (String): An `https` URL to an image, up to 2,048 characters. Without it, your app's icon is used. A push that Puter shows always uses your app's icon.
- `target` (String): A path inside your app, starting with `/`, such as `/chats/42`. Up to 512 characters. A click hands it back to your app through [`on('click')`](/Notifications/on/). On a website, a tap on a push opens this path when no page of your site is open.
- `data` (any): A JSON value of up to 1 KB, handed back on click. Push services can read it, so put IDs in it, not personal content.
- `data` (any): A JSON value of up to 1 KB, handed back on click. Push services can read it, so put ids in it, not personal content.
- `group` (String): A later notification with the same `group` replaces this one, as [Groups](#groups) describes. Up to 128 letters, digits and `. _ : @ -`.
- `channel` (String): What kind of notification it is, such as `messages` or `reminders`. The [`onPush()`](/Notifications/onPush/) handler registered with this key decides what its push shows. Up to 32 lowercase letters, digits and `-`.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your service worker's scope (`/` by default), and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `sensitive` (Boolean): Pushes, and the notification a page of your app shows itself, name only your app, such as "New notification from Chat" for an app named Chat, instead of showing the title and text. On a website, a sensitive push carries no `channel`, `target` or `data`. So no [`onPush()`](/Notifications/onPush/) handler runs for it, a tap opens your service worker's scope (`/` by default), and [`on('click')`](/Notifications/on/) gets `null` for `target` and `data`. A listener added with [`onNotification()`](/Notifications/onNotification/) still gets the whole notification.
- `priority` (String): Only `'normal'` is available to apps.
- `ttl` (Number): How many seconds a push may wait for a device that's offline, from 1 to 86400. Defaults to 3600. It doesn't change how long the notification is kept.
- `to` (Object): Send it to the other side of a key-value share instead, as `{ handle }` with the share's handle. See [Notify the other side of a share](#notify-the-other-side-of-a-share).
@@ -38,7 +38,7 @@ A `Promise` that resolves to the notification's `uid` (String). When the notific
With `to`, the `Promise` resolves with no value, so the call doesn't reveal anything about the recipient's notifications.
With a `sendAt` more than 5 seconds ahead, the `Promise` resolves to `{ scheduleId, sendAt }` instead, with or without `to`. Pass `scheduleId` to [`cancel()`](/Notifications/cancel/) to take it back before it goes out. The `sendAt` it resolves with is when it goes, in milliseconds since the epoch. With a `sendAt` within 5 seconds, it resolves as it would without one.
With a `sendAt` more than 5 seconds ahead, the `Promise` resolves to `{ scheduleId, sendAt }` instead, with or without `to`. To cancel it before it goes out, pass `scheduleId` to [`cancel()`](/Notifications/cancel/). The `sendAt` it resolves with is when it goes, in milliseconds since the epoch. With a `sendAt` within 5 seconds, it resolves as it would without one.
It rejects with an error carrying `message` and `code`:
@@ -84,16 +84,16 @@ const later = await puter.notifications.notify({
if (typeof later === 'object' && later) await puter.notifications.cancel(later.scheduleId);
```
A stored notification is a schedule of `kind: 'deferred'`. [`puter.events.getSchedule()`](/Events/getSchedule/) and [`puter.events.listSchedules()`](/Events/listSchedules/) show it, with `nextRunAt` set to its `sendAt` until it starts to go out. It needs no handler and no `events:background` permission.
A stored notification is a schedule of `kind: 'deferred'`. You can read it with [`puter.events.getSchedule()`](/Events/getSchedule/) and [`puter.events.listSchedules()`](/Events/listSchedules/), which show `nextRunAt` set to its `sendAt` until it starts to go out. It needs no handler and no `events:background` permission.
When it's due:
- It usually goes out within 10 seconds of `sendAt`, as if [`notify()`](/Notifications/notify/) were called then. It reaches every open page of your app live, and goes out as a push when no page answers, as described in [Push notifications for your app](/Notifications/push/#when-a-notification-is-pushed). A `group` replaces whatever notification is open in that group at that time.
- It usually goes out within 15 seconds of `sendAt`, as if [`notify()`](/Notifications/notify/) were called then. It reaches every open page of your app live, and goes out as a push when no page answers, as described in [Push notifications for your app](/Notifications/push/#when-a-notification-is-pushed). A `group` replaces whatever notification is open in that group at that time.
- The notification limits are counted then, against the recipient. With `to`, the share is checked when you call [`notify()`](/Notifications/notify/), and again when it goes out, which is when it counts toward the limit for that sender, recipient and app. A push is billed to the user who receives it, as any push is.
- If it can't go out because of a rate limit or a temporary error, it's retried, up to 5 attempts in all. After that it's dropped. A share that stopped, a recipient who blocked you, or an app or account that was deleted drops it right away.
- While the sending account is suspended, it waits, and it's tried again every 15 minutes.
It's written once, even when sending is retried. A retry doesn't write a second notification, and doesn't push it or bill it a second time. An open page of your app can receive it twice, with the same `uid`. A retry sends the live copy again only within 24 hours of the first; after that the send is finished without it.
It's written once, even when sending is retried. A retry doesn't write a second notification, and doesn't push it or bill it a second time. An open page of your app can receive it twice, with the same `uid`. A retry delivers it to open pages again only within 24 hours of the first attempt. After that, the retry finishes without delivering it.
Once it has gone out, or been dropped, its schedule has `state: 'ended'` and an `endReason` of `delivered` or `dropped`, and it stays in [`puter.events.listSchedules()`](/Events/listSchedules/) for 7 days. While it's going out it's `sending`, and [`cancel()`](/Notifications/cancel/) rejects with `events_schedule_sending`.
+2 -2
View File
@@ -133,7 +133,7 @@ The page you send from is usually open and focused. With an [`onPush()`](/Notifi
- **A [Puter Worker](/Workers/)**, with [`user.puter.notifications.notify()`](/Notifications/notify/) in a route your page calls through [`puter.workers.exec()`](/Workers/exec/). It notifies the user who called the worker, from the app that called it.
- **An [events handler](/Events/handlers/)**, with [`user.notifications.notify()`](/Notifications/notify/). It notifies the user who holds the subscription or [schedule](/Events/onSchedule/), and it runs while your app is closed. Running a handler in the background needs the user's `events:background` permission, as [`onPersistent()`](/Events/onPersistent/) describes.
To send a notification at a set time, pass `sendAt` to [`notify()`](/Notifications/notify/#send-it-later). Puter stores it and sends it then, even with your app closed, and [`cancel()`](/Notifications/cancel/) takes it back until it goes out. It counts toward the push limits and is billed when it goes out, not when you store it. When sending it has to be retried, it still goes out as one push, billed once, though an open page of your app can receive it twice, with the same `uid`. To send something different each time, such as a daily summary, run a handler on a timer with [`puter.events.onSchedule()`](/Events/onSchedule/) and call `user.notifications.notify()` from it.
To send a notification at a set time, pass `sendAt` to [`notify()`](/Notifications/notify/#send-it-later). Puter stores it and sends it then, even with your app closed, and [`cancel()`](/Notifications/cancel/) cancels it until it goes out. It counts toward the push limits and is billed when it goes out, not when you store it. When sending it has to be retried, it still goes out as one push, billed once, though an open page of your app can receive it twice, with the same `uid`. To send something different each time, such as a daily summary, run a handler on a timer with [`puter.events.onSchedule()`](/Events/onSchedule/) and call [`user.notifications.notify()`](/Notifications/notify/) from it.
An app that notifies from an events handler needs the user to allow both background events and pushes. Ask for both with [`puter.perms.request()`](/Perms/request/#batching), so the user answers one prompt:
@@ -308,7 +308,7 @@ The browser can be allowed to show notifications while the operating system hide
## Good to know
- Say who did what. "Ana shared Q3-report.pdf" reads well as a push. A push that only says there's something new can be hidden as spam on Android.
- The `data` field is handed back on click, and push services can read it, so put IDs in it and fetch the rest. Set `sensitive: true` when the title and text shouldn't leave Puter.
- The `data` field is handed back on click, and push services can read it, so put ids in it and fetch the rest. Set `sensitive: true` when the title and text shouldn't leave Puter.
- A push carries about 4 KB, with `data` at most 1 KB. A push that doesn't fit has its text shortened.
- A tap opens your domain. The `url` a handler returns has to be on your origin, and a tap on one from another origin opens your worker's folder instead.
- Pushes from your app are billed to the user who receives them. See [What pushes cost](/rate-limits-and-quotas/#what-pushes-cost). Puter's own notifications are free.
+1 -1
View File
@@ -690,7 +690,7 @@ const examples = [
},
{
title: 'Remove a Schedule',
description: 'End a schedule with the Puter.js Events API, and see what removing it twice returns. Run it in the playground.',
description: 'End a schedule with the Puter.js Events API, then read the ended schedule. Run it in the playground.',
slug: 'events-remove-schedule',
source: '/playground/examples/events-remove-schedule.html',
},
@@ -7,7 +7,7 @@
title: 'Stand-up in 10 minutes',
sendAt: Date.now() + 10 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
const schedule = await puter.events.getSchedule(later.scheduleId);
const due = schedule.nextRunAt ? new Date(schedule.nextRunAt).toLocaleTimeString() : 'nothing due';
@@ -8,7 +8,7 @@
title: 'Time for a break',
sendAt: Date.now() + 60 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
// (2) Page through everything this app holds for the user
let cursor = null;
@@ -7,17 +7,14 @@
title: 'Your trial ends tomorrow',
sendAt: Date.now() + 24 * 60 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
await puter.events.removeSchedule(later.scheduleId);
puter.print('cancelled<br>');
// Removing it twice is refused the same way an unknown id is
try {
await puter.events.removeSchedule(later.scheduleId);
} catch (error) {
puter.print(`second attempt: ${error.code}<br>`);
}
// The ended schedule can still be read for 7 days
const ended = await puter.events.getSchedule(later.scheduleId);
puter.print(`${ended.state} (${ended.endReason})<br>`);
})();
</script>
</body>
@@ -9,7 +9,7 @@
target: '/timers',
sendAt: Date.now() + 30 * 60 * 1000,
});
if (typeof later !== 'object' || !later) return; // due now: sent at once
if (typeof later !== 'object' || !later) return; // due now, so it was sent at once
puter.print(`stored ${later.scheduleId}, goes out ${new Date(later.sendAt).toLocaleTimeString()}<br>`);
// (2) The user stopped the timer, so it shouldn't go out
+12 -8
View File
@@ -673,21 +673,22 @@ These limits apply to [`onSchedule()`](/Events/onSchedule/) schedules, and to no
| Runs per hour of all recurring schedules added up | Per user, per app | 1,800 | 40 | 0 |
| Runs per hour of all recurring schedules added up | Per user, all apps | 18,000 | 120 | 0 |
A recurring schedule can run at most every **1 minute** on a paid plan and every **15 minutes** on a free one. For a `cron` schedule, that's the smallest gap between two of its runs.
A recurring schedule can run at most every 1 minute on a paid plan and every 15 minutes on a free one. For a `cron` schedule, that's the smallest gap between two of its runs.
- Over a count: `events_schedule_limit`. Over the runs per hour: `events_schedule_budget`. Under the minimum interval: `events_schedule_too_frequent`. Anonymous accounts get `events_durable_requires_account`.
- A create over a count fails with `events_schedule_limit`, one over the runs per hour with `events_schedule_budget`, and one under the minimum interval with `events_schedule_too_frequent`. Anonymous accounts get `events_durable_requires_account`.
- A recurring schedule's runs per hour are worked out when it's created. An `every` schedule counts 3,600 divided by its interval in seconds, rounded up to the next 0.001, so one that runs every minute counts 60, one that runs every 15 minutes counts 4, and one that runs every 7 minutes counts 8.572. A `cron` schedule counts the most runs it makes in any one hour, over its next 10,000 runs or 400 days. So a paid plan fits 30 schedules that run every minute in one app, and a free plan fits 10 that run every 15 minutes.
- `at` schedules and stored notifications count only toward the waiting limits, until they run or are cancelled.
- A suspended schedule still counts. Removing a schedule, or its last run finishing, frees its slot immediately.
- One-time `at` schedules and stored notifications count only toward the waiting limits, until they run or are cancelled.
- A suspended schedule still counts. A schedule stops counting as soon as it's removed or its last run finishes.
- Schedules don't count toward the persistent subscription limits.
- These limits are checked when a schedule is created, against the plan the user has then. Schedules made before a change to a plan with lower limits keep running; new ones are held to the current plan.
- These limits are checked when a schedule is created, against the plan the user has then. Schedules made before a change to a plan with lower limits keep running. New ones are held to the current plan.
| Limit | Value | Scope |
| -------------------------------------------------- | --------------------------------------- | ------------------ |
| Schedule runs | 200 per minute | Per user, per app |
| Schedule runs at once | 8 per server | Per user, all apps |
| Time from a run's slot until it starts | Usually within 60 seconds | Per run |
| Spread after the slot, recurring | Up to 30 seconds, or a tenth of the smallest gap if that's less | Per schedule |
| Spread after the slot, `at` and stored notifications | Up to 10 seconds | Per schedule |
| Spread after the slot, recurring | Under 30 seconds, or under a tenth of the smallest gap if that's less | Per schedule |
| Spread after the slot, `at` and stored notifications | Under 10 seconds | Per schedule |
| Run timeout | 30 seconds | Per run |
| Retry delay | 2 seconds, doubling, up to 5 minutes | Per run |
| Failures in a row before suspension | 5 | Per schedule |
@@ -703,10 +704,13 @@ A recurring schedule can run at most every **1 minute** on a paid plan and every
| Ended schedules kept in `listSchedules()` | 7 days | Per schedule |
| A stored notification sent within 5 seconds | Sent right away instead | Per notification |
| Attempts to send a stored notification | 5, then it's dropped | Per notification |
| A stored notification over its recipient's limits | Dropped 6 hours after `sendAt` | Per notification |
| A stored notification, as JSON | 16 KB | Per notification |
- Over the schedule runs per minute, runs wait for the next minute. They don't fail and don't count as failures. This limit is separate from the 60 handler runs a minute for subscriptions, so neither one uses up the other.
- Over the runs at once, a run waits about 3 seconds and tries again. It doesn't fail, doesn't count as a failure, and keeps its `event.id` and `missed` count.
- A run that finds 8 deploys already started waits 5 seconds and tries again.
- A stored notification over its recipient's [notification limits](#notifications) waits at least a minute and tries again, without using one of its 5 attempts.
- A `cron` create that finds 2 checks running and 8 waiting fails with `events_schedule_busy`. Try again in a moment.
- A stored notification counts toward the [notification limits](#notifications) of its recipient when it goes out, not when it's stored.
@@ -716,7 +720,7 @@ A recurring schedule can run at most every **1 minute** on a paid plan and every
| `removeSchedule()` and `notifications.cancel()` | 600/min, shared with `unsubscribe` | Per user, all apps |
| `listSchedules()` and `getSchedule()` | 120/min, shared with the subscription listings | Per user, per app |
A `listSchedules()` page holds 50 schedules by default, and up to 200. Over any of these call limits: `too_many_requests`. A create refused for its options, its handler or a missing permission still counts toward the `onSchedule()` limit, and removing a schedule that doesn't exist still counts toward the `removeSchedule()` limit.
A [`listSchedules()`](/Events/listSchedules/) page holds 50 schedules by default, and up to 200. Calls over any of these limits fail with `too_many_requests`. A create refused for its options, its handler or a missing permission still counts toward the [`onSchedule()`](/Events/onSchedule/) limit, and removing a schedule that doesn't exist still counts toward the [`removeSchedule()`](/Events/removeSchedule/) limit.
#### What events cost
@@ -95,8 +95,8 @@ shows how to check [`listSchedules()`](/Events/listSchedules/) first.
The `context` is stored with the schedule and can't be changed. When the
user's key changes, remove the schedule with
[`puter.events.removeSchedule()`](/Events/removeSchedule/) and create a new
one. [`listSchedules()`](/Events/listSchedules/) shows the names of the
`context` keys, never their values. It can be up to 4 KB.
one. The [`listSchedules()`](/Events/listSchedules/) method shows the names
of the `context` keys, never their values.
The schedule runs every 15 minutes from when it's created, which is as often
as a free plan allows. A paid plan can run one every minute. See
@@ -123,7 +123,7 @@ async function syncChanges ({ ctx, user, fetch }) {
signal: AbortSignal.timeout(5000),
});
} catch {
return; // too slow or unreachable: the next run asks again
return; // too slow or unreachable, so the next run asks again
}
if (!res.ok) return;
@@ -146,7 +146,7 @@ async function syncChanges ({ ctx, user, fetch }) {
}
```
Publish the handler again after the change. A few details:
A few details:
- The loop stops starting new pages after 20 seconds. Each page is at most
100 items (`limit`, if the API takes one), its request gives up after 5
@@ -166,9 +166,19 @@ Publish the handler again after the change. A few details:
wins, even when the other got further. A cursor that moves back only means
pages are fetched again. An item the slower run saves can be older than the
one it replaces until a later run fetches it again. If that matters, keep the
API's `updatedAt` on each item and skip writing one older than what's stored;
the key-value store has no compare-and-set, so the handler has to tolerate
the rare overlap rather than prevent it.
API's `updatedAt` on each item and skip writing one older than what's
stored. The key-value store has no compare-and-set, so the handler has to
tolerate the rare overlap rather than prevent it.
Publish the handler again after the change. A deploy script runs in a new
process each time, so its SDK hasn't seen what's published. Call
[`handlers.list()`](/Events/handlers/) before publishing changed code, or
[`publish()`](/Events/handlers/) rejects with `events_handler_conflict`:
```js
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('syncChanges', syncChanges, { appUid: app.uid });
```
## Full Code
@@ -191,7 +201,7 @@ async function syncChanges ({ ctx, user, fetch }) {
signal: AbortSignal.timeout(5000),
});
} catch {
return; // too slow or unreachable: the next run asks again
return; // too slow or unreachable, so the next run asks again
}
if (!res.ok) return;
+19 -18
View File
@@ -1,6 +1,6 @@
---
title: Remind Users Later
description: "Learn how to send a user a reminder at a time they pick with Puter.js, and take it back if they no longer need it, even while your app is closed."
description: "Learn how to send a user a reminder at a time they pick with Puter.js, and cancel it if they no longer need it, even while your app is closed."
tags: [notifications, events, kv]
order: 69
---
@@ -37,16 +37,17 @@ as `'2026-11-14T09:00:00-05:00'`, or milliseconds since the epoch. It can be
up to 366 days ahead.
The call resolves to `{ scheduleId, sendAt }`. A `sendAt` within 5 seconds,
or already past, isn't stored: the notification goes out at once and the call
or already past, isn't stored. The notification goes out at once, and the call
resolves as it would without `sendAt`, with the notification's `uid` (a
string). Check which one you got before keeping a `scheduleId`. Puter sends the
stored notification
within a few seconds of `sendAt`, as if your app called
[`notify()`](/Notifications/notify/) then. It reaches every open page of your
app, and goes out as a push when no page is open and the user turned pushes
on. [Reach a User When Your App Is Closed](/recipes/push-when-closed/) shows
how to set pushes up, and `target` is the page the user lands on when they
tap it.
string). Check which one you got before keeping a `scheduleId`.
Puter usually sends the stored notification within 15 seconds of `sendAt`, as
if your app called [`notify()`](/Notifications/notify/) then. It reaches every
open page of your app, and goes out as a push when no page is open and the
user turned pushes on.
[Reach a User When Your App Is Closed](/recipes/reach-a-user-when-your-app-is-closed/)
shows how to set pushes up, and `target` is the page the user lands on when
they tap it.
## Cancel a Reminder
@@ -61,7 +62,7 @@ async function remindLater (taskId, title, sendAt) {
target: `/tasks/${taskId}`,
sendAt,
});
// A time within 5 seconds, or past, is sent at once: nothing to cancel.
// A time within 5 seconds, or past, is sent at once, so there's nothing to cancel.
if (typeof reminder !== 'object' || !reminder) return;
await puter.kv.set(`reminder:${taskId}`, reminder.scheduleId);
}
@@ -95,10 +96,10 @@ To move a reminder to a new time, cancel it and call `remindLater()` again.
## Show the Reminders That Are Waiting
[`puter.events.listSchedules()`](/Events/listSchedules/) lists the
notifications your app has waiting for the user, along with any
[schedules](/Events/onSchedule/) it created. A stored notification has
`kind: 'deferred'`, and `nextRunAt` is when it goes out:
To list the notifications your app has waiting for the user, along with any
[schedules](/Events/onSchedule/) it created, use
[`puter.events.listSchedules()`](/Events/listSchedules/). A stored
notification has `kind: 'deferred'`, and `nextRunAt` is when it goes out:
```js
async function listReminders () {
@@ -122,8 +123,8 @@ The list doesn't include the notification's title, so match each
for.
There's a limit on how many notifications your app can have waiting for one
user, 100 on a free plan and 1,000 on a paid one. Past it,
[`notify()`](/Notifications/notify/) rejects with `events_schedule_limit`.
user. Past it, [`notify()`](/Notifications/notify/) rejects with
`events_schedule_limit`.
See [Rate Limits and Quotas](/rate-limits-and-quotas/#schedules).
## Decide When the Reminder Is Due
@@ -191,7 +192,7 @@ async function remindLater (taskId, title, sendAt) {
target: `/tasks/${taskId}`,
sendAt,
});
// A time within 5 seconds, or past, is sent at once: nothing to cancel.
// A time within 5 seconds, or past, is sent at once, so there's nothing to cancel.
if (typeof reminder !== 'object' || !reminder) return;
await puter.kv.set(`reminder:${taskId}`, reminder.scheduleId);
}
+24 -18
View File
@@ -64,7 +64,7 @@ The [`publish()`](/Events/handlers/) method checks this, and fails with
The notification reaches every open page of your app, and goes out as a push
when none is open and the user turned pushes on.
[Reach a User When Your App Is Closed](/recipes/push-when-closed/) shows how
[Reach a User When Your App Is Closed](/recipes/reach-a-user-when-your-app-is-closed/) shows how
to set pushes up. The `group` makes each morning's summary replace the one
before it.
@@ -82,7 +82,7 @@ user doesn't see a prompt. Without it,
[`onSchedule()`](/Events/onSchedule/) fails with
`events_background_consent_required`.
If the user takes the permission back later, every schedule your app holds for
If the user revokes the permission later, every schedule your app holds for
them ends. Granting it again doesn't bring them back, so your app has to
create them again. Schedules also need a Puter account. For a guest account,
[`onSchedule()`](/Events/onSchedule/) fails with
@@ -105,9 +105,9 @@ The five fields of `0 8 * * 1-5` are the minute, the hour, the day of the
month, the month and the day of the week, so it means 8:00 on Monday to
Friday. The `tz` option is the browser's time zone, such as
`'America/New_York'`. The schedule follows that zone's daylight saving time,
so the summary arrives at 8:00 local time all year.
[`onSchedule()`](/Events/onSchedule/#cron-expressions) lists the cron syntax
it accepts.
so the summary arrives at 8:00 local time all year. The
[`onSchedule()`](/Events/onSchedule/#cron-expressions) page lists the cron
syntax it accepts.
Every [`onSchedule()`](/Events/onSchedule/) call creates a new schedule, even
with the same options. If you call it each time the app opens, the user gets
@@ -140,8 +140,8 @@ permission. A few details:
time, and only your app's schedules for this user. The loop follows
`cursor` until there are no more pages.
- Ended schedules stay in the list for 7 days, so the code skips ones with
`state: 'ended'`. That includes a schedule that ended because the user took
the permission back, so the app creates a new one once it's granted again.
`state: 'ended'`. That includes a schedule that ended because the user
revoked the permission, so the app creates a new one once it's granted again.
- A suspended schedule has `state: 'suspended'`. It resumes on its own, for
example when the user adds credit, so the code keeps it.
@@ -152,12 +152,12 @@ remove the rest with
## Skip Summaries Already Sent
Puter runs each slot of a schedule at least once. A run that times out, or
fails with a network error, is retried, and a retry can repeat work the first
attempt already did. Every attempt at the same slot has the same `event.id`,
so the handler saves it after sending and skips IDs it has already sent. The
marker key expires after a week, so these keys don't pile up in the user's
key-value store:
Each run happens at least once. A run that times out, or fails with a network
error, is retried, and a retry can repeat work the first attempt already did.
Every attempt at the same run has the same `event.id`, so the handler saves
it after sending and skips ids it has already sent. The marker key expires
after a week, so old markers are removed from the user's key-value store on
their own:
```js
async function sendSummary ({ event, user }) {
@@ -180,7 +180,7 @@ async function sendSummary ({ event, user }) {
```
This skips a retry of a run that already finished. It doesn't rule out every
repeat: two attempts that overlap, such as a run still going past its 30
repeat. Two attempts that overlap, such as a run still going past its 30
seconds when its retry starts, can both pass the check, and a run that stops
after sending but before saving the key sends again when it's retried. Here a
repeat carries the same `group`, so it replaces the summary that's showing
@@ -191,6 +191,16 @@ a payment, make the call itself safe to repeat, for example by passing
Publish the handler again after the change. The schedules already created use
the new code from their next run.
A deploy script runs in a new process each time, so its SDK hasn't seen what's
published. Call [`handlers.list()`](/Events/handlers/) before publishing changed
code, or [`publish()`](/Events/handlers/) rejects with
`events_handler_conflict`:
```js
await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('sendSummary', sendSummary, { appUid: app.uid });
```
## Handle Mornings That Were Missed
A schedule can fall behind. For example, if the handler failed 5 times in a
@@ -290,10 +300,6 @@ await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('sendSummary', sendSummary, { appUid: app.uid });
```
The [`handlers.list()`](/Events/handlers/) call before publishing lets a deploy
script publish changed code. Without it, publishing a changed handler fails
with `events_handler_conflict`.
The app code runs in your app, signed in as the user:
```js