Poll an API in the Background


The Events API is in beta. Event shapes, limits, and behavior may change between releases.
Schedules are available on puter.com. A self-hosted Puter server answers events_disabled.

Some services can't tell your app when something changes, so your app has to ask them every so often. For example, importing new orders from a store, syncing a user's bookmarks from another service, or pulling the latest readings from a sensor. On other platforms you'd run a cron job on a server that calls the API for every user.

In Puter, each user gets a schedule that runs your handler in your app's events worker, every 15 minutes in this recipe, even while your app is closed. The handler asks the API for what changed since its last run, and saves it in the user's key-value store. Run Code on a Schedule covers handlers, permission and creating one schedule per user. This recipe adds the two things polling needs, a cursor and a way to finish a large batch.

The examples call an API that returns the changes after a cursor, a page at a time:

GET https://api.example.com/changes?after=<cursor>
→ { "items": [{ "id": "o-1", ... }, ...], "next": "<cursor>", "hasMore": true }

Most APIs that list changes work like this, with a cursor, a page token or a timestamp.

Publish the Handler

The handler keeps its cursor in the user's key-value store, so each run picks up where the last one stopped. Publish it as the app's developer, from a deploy script or your browser console:

const app = await puter.apps.get('order-sync');    // your app's name

async function syncChanges ({ ctx, user, fetch }) {
    const cursor = (await user.kv.get('sync:cursor')) ?? '';
    const res = await fetch(`${ctx.apiUrl}/changes?after=${encodeURIComponent(cursor)}`, {
        headers: { Authorization: `Bearer ${ctx.apiKey}` },
    });
    if (!res.ok) return;

    const page = await res.json();
    for (const item of page.items) {
        await user.kv.set(`order:${item.id}`, item);
    }
    await user.kv.set('sync:cursor', page.next);
}

await puter.events.handlers.publish('syncChanges', syncChanges, { appUid: app.uid });

The handler gets ctx, the context the schedule was created with, and fetch, which reaches outside services from the events worker. A few details:

Create the Schedule

In the app, after the user connects their account on the other service, create the schedule with the API's address and the user's key:

await puter.perms.request('backgroundEvents');

await puter.events.onSchedule({
    every: '15m',
    handlerName: 'syncChanges',
    context: { apiUrl: 'https://api.example.com', apiKey: userApiKey },
});

Create it once per user. Run Code on a Schedule shows how to check 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() and create a new one. The 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 Rate Limits and Quotas.

Finish a Large Batch

A run has 30 seconds. After a long break, for example on the first sync, the API may have more pages than one run can fetch. The handler keeps fetching while there's time, and when pages are left over it schedules one extra run a minute later, with user.events.onSchedule() and at:

async function syncChanges ({ ctx, user, fetch }) {
    const stopAt = Date.now() + 20 * 1000;
    let cursor = (await user.kv.get('sync:cursor')) ?? '';
    let hasMore = true;

    while (hasMore && Date.now() < stopAt) {
        let res;
        try {
            res = await fetch(`${ctx.apiUrl}/changes?after=${encodeURIComponent(cursor)}&limit=100`, {
                headers: { Authorization: `Bearer ${ctx.apiKey}` },
                signal: AbortSignal.timeout(5000),
            });
        } catch {
            return;    // too slow or unreachable, so the next run asks again
        }
        if (!res.ok) return;

        const page = await res.json();
        if (page.items.length > 0) {
            await user.kv.set(page.items.map((item) => ({ key: `order:${item.id}`, value: item })));
        }
        cursor = page.next;
        hasMore = page.hasMore;
        await user.kv.set('sync:cursor', cursor);
    }

    if (hasMore && !ctx.followUp) {
        await user.events.onSchedule({
            at: Date.now() + 60 * 1000,
            handlerName: 'syncChanges',
            context: { ...ctx, followUp: true },
        });
    }
}

A few details:

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() before publishing changed code, or publish() rejects with events_handler_conflict:

await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('syncChanges', syncChanges, { appUid: app.uid });

Full Code

The publish script runs as the app's developer, from a deploy script or your browser console:

const app = await puter.apps.get('order-sync');    // your app's name

async function syncChanges ({ ctx, user, fetch }) {
    const stopAt = Date.now() + 20 * 1000;
    let cursor = (await user.kv.get('sync:cursor')) ?? '';
    let hasMore = true;

    while (hasMore && Date.now() < stopAt) {
        let res;
        try {
            res = await fetch(`${ctx.apiUrl}/changes?after=${encodeURIComponent(cursor)}&limit=100`, {
                headers: { Authorization: `Bearer ${ctx.apiKey}` },
                signal: AbortSignal.timeout(5000),
            });
        } catch {
            return;    // too slow or unreachable, so the next run asks again
        }
        if (!res.ok) return;

        const page = await res.json();
        if (page.items.length > 0) {
            await user.kv.set(page.items.map((item) => ({ key: `order:${item.id}`, value: item })));
        }
        cursor = page.next;
        hasMore = page.hasMore;
        await user.kv.set('sync:cursor', cursor);
    }

    if (hasMore && !ctx.followUp) {
        await user.events.onSchedule({
            at: Date.now() + 60 * 1000,
            handlerName: 'syncChanges',
            context: { ...ctx, followUp: true },
        });
    }
}

await puter.events.handlers.list({ appUid: app.uid });
await puter.events.handlers.publish('syncChanges', syncChanges, { appUid: app.uid });

The app code runs in your app, signed in as the user, once they've connected the other service. Here userApiKey is the key your app got for them:

const allowed = await puter.perms.request('backgroundEvents');
if (allowed) {
    await puter.events.onSchedule({
        every: '15m',
        handlerName: 'syncChanges',
        context: { apiUrl: 'https://api.example.com', apiKey: userApiKey },
    });
}
← All recipes