Catch Up on Notifications


The Events API is in beta. Event shapes, limits, and behavior may change between releases.

Puter posts notifications to a user's account when something needs their attention, for example when a persistent subscription your app made for them ends, or, for the app's owner, when its worker finishes deploying. With the Events API your app can show these as they arrive, and catch up on the ones that came in while it was closed. Puter creates notifications itself. An app can't post its own.

This recipe builds on Watch for Changes, which covers what every subscription shares: sign-in, gaps, stopping and reconnects. The examples call showNotification(event), your own function that shows one notification, such as a toast or a row in a panel.

Show New Notifications

To show notifications as they arrive, subscribe to notif:app-user with puter.events.onLocal():

const sub = await puter.events.onLocal('notif:app-user', ({ event }) => {
    if (event.op === 'gap') return;
    showNotification(event);
});

A gap carries no notification, so this handler skips it. Catch Up, Then Keep Listening handles gaps properly. The fields to use are:

Tell notifications apart by id only. The seq field is only a position within one page or one delivery, two notifications can share a ts, and the same notification's ts can differ between its live and fetched copies.

Which Notifications Your App Sees

A notif: subject names one part of the user's notifications:

Apps and websites never see notif:account, which holds account notifications such as a file shared with the user. Reading your app's own notifications needs no permission. Puter fills in your app ID, so the subject never needs one, and another app's notifications are never visible to yours. All the subjects are listed under Notifications.

Read What You Missed

To read the notifications that arrived while your app was closed, call puter.events.fetch(). It returns a page of notifications, oldest first, in the same shape as live ones. Pass each page's cursor back as cursor until a page comes back without one:

let cursor;
do {
    const page = await puter.events.fetch({ subject: 'notif:app-user', cursor });
    page.items.forEach(showNotification);
    cursor = page.cursor;
} while (cursor);

Without a cursor, fetch() starts from the oldest notification Puter still keeps. Puter deletes old notifications after a while, so a user who was away longer than that starts from what is left.

The fetch() method only reads. It doesn't mark anything as read, and it also returns notifications the user already dismissed, so your app has to remember what it has shown. The next section does that.

Call fetch() on start and on a gap. The subscription tells you when something new arrives, so there's no need to call it on a timer.

Only notif: subjects can be fetched. To catch up on key-value or file changes, read the data itself again, as in Watch for Changes.

Catch Up, Then Keep Listening

To show each notification once, across visits, combine the two. Subscribe, then fetch from where the last visit stopped, and save the cursor with puter.kv so the next visit starts there:

const subject = 'notif:app-user';
const saved = await puter.kv.get('notifications') ?? {};
let cursor = saved.cursor;
const seen = new Set(saved.seen);

function showOnce (event) {
    if (seen.has(event.id)) return;
    seen.add(event.id);
    showNotification(event);
}

async function save () {
    await puter.kv.set('notifications', { cursor, seen: [...seen].slice(-200) });
}

async function catchUp () {
    let page;
    do {
        page = await puter.events.fetch({ subject, cursor });
        page.items.forEach(showOnce);
        cursor = page.cursor ?? cursor;
    } while (page.cursor);
    await save();
}

await puter.events.onLocal(subject, async ({ event }) => {
    if (event.op === 'gap') return catchUp();
    showOnce(event);
    await save();
});
await catchUp();

Subscribe first, then fetch. A notification created while catchUp() runs then reaches the handler. Because of that, a notification can arrive both live and from fetch(), and showOnce() drops the second copy by id.

The last page has no cursor, so the saved cursor stays on the page before it, and the next visit reads that page again. That is why the ids are saved along with the cursor. Only the newest ones can come back, so the last 200 are enough. On a first visit there is no saved cursor, so cursor is undefined and catchUp() shows every notification Puter still keeps.

A gap means notifications were not delivered, and one also arrives after a reconnect. Calling catchUp() on a gap reads what was missed, and showOnce() skips the rest.

← All recipes