Build an API with a Worker
Some of your app's code belongs outside the user's browser, such as checking input before it is saved, receiving a webhook from another service, or calling another API. That code needs a backend, which usually means running and paying for a server.
A serverless worker gives you that backend with no server to run,
like Cloudflare Workers or AWS Lambda. It is JavaScript that runs in the cloud
and answers HTTP requests at its own URL, such as
https://notes-api.puter.work. You write it as a list of routes, each one a
path and the function that handles it.
When your app calls a worker with
puter.workers.exec(), the worker knows which user is calling
and gets that user's Puter account as user.puter. A route can then read and
write the user's own key-value store, files and AI, the same way your
app does in the browser. The data stays in the user's account, and the usage is
billed to them under the User-Pays model, so you get a
backend without paying for its storage.
Define a Route
The router object is available in every worker. Register a route with the
method's name and a path, and return what the caller should get back:
router.get('/me', async ({ user }) => {
if (!user) {
return new Response('sign in required', { status: 401 });
}
const { username } = await user.puter.getUser();
return { username };
});
There is one method per HTTP verb: router.get(), router.post(),
router.put(), router.delete() and router.options().
user is only there when the request came through
puter.workers.exec(), so every route that uses it starts by
checking for it.
Store Data in the User's Account
To save something for the caller, write it with user.puter.kv. Read the JSON
body with request.json():
router.post('/notes', async ({ request, user }) => {
if (!user) {
return new Response('sign in required', { status: 401 });
}
const { text } = await request.json();
if (typeof text !== 'string' || text.length === 0) {
return new Response('text is required', { status: 400 });
}
const note = { id: crypto.randomUUID(), text, at: Date.now() };
await user.puter.kv.set(`notes:${note.id}`, note);
return note;
});
Each user's notes land in their own store, so one user can never read or
overwrite another's, whatever the request body says. These are the same keys
your app sees through puter.kv in the browser, so the app can also
read a note directly.
Read the Request
Each handler receives one object. Its request is a standard
Request, and its
params holds the parts of the path you marked with a colon:
router.get('/notes/:id', async ({ params, user }) => {
if (!user) {
return new Response('sign in required', { status: 401 });
}
const note = await user.puter.kv.get(`notes:${params.id}`);
if (!note) {
return new Response('note not found', { status: 404 });
}
return note;
});
Captured values are always strings, so convert them yourself when you need a number.
Query strings are not part of the path. To read one, parse the request URL:
router.get('/notes', async ({ request, user }) => {
if (!user) {
return new Response('sign in required', { status: 401 });
}
const q = new URL(request.url).searchParams.get('q') ?? '';
const rows = await user.puter.kv.list('notes:', true);
return rows
.map(row => row.value)
.filter(note => note.text.includes(q));
});
Send a Response
A handler that returns a plain object or array is sent as JSON, and one that
returns a string is sent as text. To choose the status code or the headers,
return a Response,
as the 401, 400 and 404 answers above do.
The worker adds CORS headers to every response, so your app can call it from any origin without extra setup.
Handle Unknown Paths
A wildcard route matches the rest of the path. Register one last, so it only
runs when no other route matched, and use it to answer with a 404:
router.get('/*path', async ({ params }) => {
return new Response(`no route for /${params.path}`, { status: 404 });
});
The wildcard needs a name, such as *path. A bare * is read as a literal
character and does not match anything else.
Routes Without a User
A worker is also an ordinary HTTP endpoint, so it can serve callers that have
never heard of Puter. A webhook from another service, a curl in a script, or a
page without Puter.js can all call it. These routes never touch user.
A route can do its work and answer, with nothing to store:
router.get('/slugify', async ({ request }) => {
const text = new URL(request.url).searchParams.get('text') ?? '';
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
});
Any HTTP client can call it:
curl "https://notes-api.puter.work/slugify?text=Hello%20World"
# hello-world
To keep what an outside service sends you, store it in your own account with
me.puter, which is available to every request whoever made it:
router.post('/webhooks/payments', async ({ request }) => {
const event = await request.json();
await me.puter.kv.set(`payments:${event.id}`, event);
return { received: true };
});
A worker can also call other APIs with fetch(), which lets it reshape a
third-party response before your app sees it:
router.get('/weather/:city', async ({ params }) => {
const res = await fetch(`https://wttr.in/${encodeURIComponent(params.city)}?format=j1`);
const data = await res.json();
return { city: params.city, tempC: data.current_condition[0].temp_C };
});
Anyone who knows the URL can call a route like these, so keep private data
behind routes that check for user.
Call It From Your App
Once the worker is deployed, call it with
puter.workers.exec(). It takes the same arguments as
fetch() and adds the signed-in user's session, which is what gives the worker
user.puter:
const API = 'https://notes-api.puter.work';
const res = await puter.workers.exec(`${API}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'Buy milk' }),
});
const note = await res.json(); // { id: '5f0c…', text: 'Buy milk', at: 1788827048741 }
const notes = await (await puter.workers.exec(`${API}/notes?q=milk`)).json();
A plain fetch() of the same URL carries no session, so user is undefined
and the notes routes answer 401. It works for
routes without a user, which is how
callers without Puter.js reach them.
To keep data that every user reads and writes together, such as a leaderboard,
store it with me.puter instead. See
Store server-side data.