Remind Users Later
sendAt is available on puter.com. A self-hosted Puter server answers events_disabled.A "remind me later" button, a reminder before a meeting, or a note that a trial is about to end all need a notification that goes out at a set time, often when nobody has your app open. On other platforms you'd store the reminder in a database and run a job that checks it every minute.
With Puter.js, you pass the time to
puter.notifications.notify() as sendAt. Puter
stores the notification and sends it at that time. Your app doesn't need to
be open, and you don't need a server or a handler.
The examples build reminders for a task list app. The user picks a task and a time, and gets a notification about it then.
Send a Notification Later
Call notify() with sendAt:
const reminder = await puter.notifications.notify({
title: 'Reminder: Call the dentist',
target: '/tasks/42',
sendAt: Date.now() + 2 * 60 * 60 * 1000, // two hours from now
});
The sendAt option takes a Date, an ISO-8601 string with a time zone such
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
resolves as it would without sendAt, with the notification's uid (a
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() 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
shows how to set pushes up, and target is the page the user lands on when
they tap it.
Cancel a Reminder
When the user finishes the task first, the reminder shouldn't go out. Keep the
scheduleId with the task, in the app's
key-value store:
async function remindLater (taskId, title, sendAt) {
const reminder = await puter.notifications.notify({
title: `Reminder: ${title}`,
target: `/tasks/${taskId}`,
sendAt,
});
// 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);
}
Then cancel it with
puter.notifications.cancel() when the task is
done:
async function cancelReminder (taskId) {
const scheduleId = await puter.kv.get(`reminder:${taskId}`);
if (!scheduleId) return;
try {
await puter.notifications.cancel(scheduleId);
} catch (error) {
// Already sent, or going out right now
if (error.code !== 'events_schedule_not_found' && error.code !== 'events_schedule_sending') throw error;
}
await puter.kv.del(`reminder:${taskId}`);
}
A reminder that was already sent, or already cancelled, rejects with
events_schedule_not_found. One that's going out right now rejects with
events_schedule_sending. Either way it's too late to stop it, so the code
removes the stored id and moves on.
To move a reminder to a new time, cancel it and call remindLater() again.
Show the Reminders That Are Waiting
To list the notifications your app has waiting for the user, along with any
schedules it created, use
puter.events.listSchedules(). A stored
notification has kind: 'deferred', and nextRunAt is when it goes out:
async function listReminders () {
const waiting = [];
let cursor = null;
do {
const page = await puter.events.listSchedules({ cursor });
for (const schedule of page.items) {
if (schedule.kind === 'deferred' && schedule.state === 'active') {
waiting.push({ scheduleId: schedule.scheduleId, sendAt: schedule.nextRunAt });
}
}
cursor = page.cursor ?? null;
} while (cursor);
return waiting;
}
The list doesn't include the notification's title, so match each
scheduleId with the ids stored by remindLater() to show which task it's
for.
There's a limit on how many notifications your app can have waiting for one
user. Past it, notify() rejects with
events_schedule_limit.
See Rate Limits and Quotas.
Decide When the Reminder Is Due
A notification sent with sendAt is fixed when you store it. To decide at
the last moment, for example to skip the reminder when the task is already
done, run a handler at that time instead with
puter.events.onSchedule() and at. The handler
checks the task and sends the notification itself.
Publish the handler once, as the app's developer, from a deploy script or
your browser console. Run Code on a Schedule
explains handlers and the user object they get:
const app = await puter.apps.get('task-list'); // your app's name
async function remindIfOpen ({ event, ctx, user }) {
const sentKey = `reminded:${event.id}`;
if (await user.kv.get(sentKey)) return;
const task = await user.kv.get(`task:${ctx.taskId}`);
if (task && !task.done) {
await user.notifications.notify({
title: `Reminder: ${task.title}`,
target: `/tasks/${ctx.taskId}`,
});
}
await user.kv.set(sentKey, true, Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60);
}
await puter.events.handlers.publish('remindIfOpen', remindIfOpen, { appUid: app.uid });
Then, in the app, create a one-time schedule for the task:
await puter.perms.request('backgroundEvents');
const schedule = await puter.events.onSchedule({
at: Date.now() + 2 * 60 * 60 * 1000,
handlerName: 'remindIfOpen',
context: { taskId: '42' },
});
The handler gets context as ctx, so it knows which task to check. A run
can happen more than once, so the handler saves event.id after sending and
skips it the next time. Running a handler needs the user's
events:background permission, which a plain sendAt doesn't.
Cancel it the same way as a stored notification, with
puter.events.removeSchedule() and
schedule.scheduleId.
Change Data at a Set Time shows how to
cancel or move a one-time schedule, including one that has already run.
Full Code
The app code for reminders sent with sendAt, signed in as the user:
async function remindLater (taskId, title, sendAt) {
const reminder = await puter.notifications.notify({
title: `Reminder: ${title}`,
target: `/tasks/${taskId}`,
sendAt,
});
// 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);
}
async function cancelReminder (taskId) {
const scheduleId = await puter.kv.get(`reminder:${taskId}`);
if (!scheduleId) return;
try {
await puter.notifications.cancel(scheduleId);
} catch (error) {
// Already sent, or going out right now
if (error.code !== 'events_schedule_not_found' && error.code !== 'events_schedule_sending') throw error;
}
await puter.kv.del(`reminder:${taskId}`);
}
async function listReminders () {
const waiting = [];
let cursor = null;
do {
const page = await puter.events.listSchedules({ cursor });
for (const schedule of page.items) {
if (schedule.kind === 'deferred' && schedule.state === 'active') {
waiting.push({ scheduleId: schedule.scheduleId, sendAt: schedule.nextRunAt });
}
}
cursor = page.cursor ?? null;
} while (cursor);
return waiting;
}