You will build a small notes app that stores notes in an edge SQLite database, lets the browser upload attachments straight to object storage, and sells an upgrade through a hosted checkout. The whole app is one file, worker.js: it serves the page and its API from the edge at https://<slug>.harakumo.app, with nothing to keep awake.
Every step works on the free Hobby plan. An app that needs a long-running Node.js server (Express, Next.js with a server) deploys the same way as a server app, which is on Pro and up.
Replace the values in angle brackets with the ones each step prints.
1. Sign up and create a setup key
- Sign up at harakumo.com/signup and enter the 6-digit code emailed to you. Verify now: live payment links need a verified workspace-owner email.
- Open Dashboard → Settings → API access and create a key with full access to the whole workspace. It stays on your machine for setup.
npm install -g @harakumo/cli
export HARAKUMO_API_KEY=hk_live_… # the key you just created
harakumo login --key $HARAKUMO_API_KEY2. Create the project, a database, a bucket and a payment account
harakumo projects create notes-app # ✓ Created project notes-app #<projectId>
harakumo db create <projectId> notes # ✓ Creating notes (sqlite, #<dbId>)
harakumo db query <dbId> "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL, attachment TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)"
harakumo storage create <projectId> uploads
harakumo storage <projectId> # lists buckets with their ids: note <bucketId>
# Payments are live as soon as they are enabled:
curl -X POST https://harakumo.com/api/projects/<projectId>/payments \
-H "Authorization: Bearer $HARAKUMO_API_KEY"
# → { "account": { "id": <accountId>, … } }3. Give the app its own key
Your setup key can do anything in the workspace; the deployed app should not. In Settings → API access create a second key with full access and Project set to notes-app. It works on this project's database, buckets and payment account and is refused everywhere else.
Store it and the resource ids as the project's environment variables — in the dashboard (project → Environment) or with the API:
| Variable | Value |
|---|---|
| HARAKUMO_API_KEY | the project key (hk_live_…) |
| NOTES_DB_ID | <dbId> |
| UPLOADS_BUCKET_ID | <bucketId> |
| PAYMENTS_ACCOUNT_ID | <accountId> |
curl -X POST https://harakumo.com/api/projects/<projectId>/env \
-H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
-d '{"key":"NOTES_DB_ID","value":"<dbId>"}'Environment variables are copied into the app when it deploys. After changing one, redeploy.
4. Write the app
One file in a folder called notes-app. A worker.js at the root deploys as an edge program: it answers every request to the app, reads the project's environment variables as env.NAME, and calls the Harakumo API with plain fetch. It needs no npm install and no build.
// The whole notes app: the page and its API, on the edge.
const API = 'https://harakumo.com/api';
// One call to the Harakumo API with the project's key (step 3).
async function hk(env, path, body) {
const res = await fetch(API + path, {
method: body === undefined ? 'GET' : 'POST',
headers: { authorization: 'Bearer ' + env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Harakumo answered ' + res.status);
return data;
}
const json = (data, status = 200) => Response.json(data, { status });
export default {
async fetch(request, env) {
const url = new URL(request.url);
const route = request.method + ' ' + url.pathname;
try {
if (route === 'GET /') {
return new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } });
}
// Notes live in the edge SQLite database; every query is one HTTPS call.
if (route === 'GET /api/notes') {
const { results } = await hk(env, '/databases/' + env.NOTES_DB_ID, {
sql: 'SELECT id, body, attachment, created_at FROM notes ORDER BY id DESC LIMIT 50',
});
return json(results);
}
if (route === 'POST /api/notes') {
const { body, attachment = null } = await request.json();
await hk(env, '/databases/' + env.NOTES_DB_ID, {
sql: 'INSERT INTO notes (body, attachment) VALUES (?, ?)',
params: [String(body), attachment],
});
return json({ ok: true }, 201);
}
// The browser uploads straight to storage with a signed URL; the bytes never pass through here.
if (route === 'POST /api/upload-url') {
const { filename, contentType } = await request.json();
const key = 'attachments/' + Date.now() + '-' + String(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
const signed = await hk(env, '/storage/' + env.UPLOADS_BUCKET_ID, { key, method: 'put', contentType });
return json({ url: signed.url, key });
}
// A hosted checkout. The buyer comes back to /thanks?session_id=…
if (route === 'POST /api/checkout') {
const checkout = await hk(env, '/payments/' + env.PAYMENTS_ACCOUNT_ID, {
amountCents: 500,
description: 'Notes Pro',
successUrl: url.origin + '/thanks',
cancelUrl: url.origin + '/',
});
return json({ url: checkout.url });
}
// Confirm the payment here; never trust the redirect alone.
if (route === 'GET /thanks') {
const { paid, payment } = await hk(env, '/payments/' + env.PAYMENTS_ACCOUNT_ID + '/sessions/'
+ encodeURIComponent(url.searchParams.get('session_id') || ''));
return new Response(paid
? 'Paid $' + (payment.amount_cents / 100).toFixed(2) + ', thank you!'
: 'Payment not completed yet.');
}
return new Response('Not found', { status: 404 });
} catch (err) {
return json({ error: err.message }, 502);
}
},
};
// The page: a tab icon and a phone layout from the start.
const PAGE = `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%234F46E5'/%3E%3Ctext x='32' y='44' font-family='system-ui' font-size='36' font-weight='700' fill='white' text-anchor='middle'%3EN%3C/text%3E%3C/svg%3E">
<style>body { font: 16px/1.5 system-ui, sans-serif; max-width: 560px; margin: 40px auto; padding: 0 16px; }</style>
<form id="note"><input name="body" placeholder="Write a note" required> <input type="file" name="file"> <button>Save</button></form>
<ul id="list"></ul>
<button id="buy">Upgrade for $5</button>
<script type="module">
const json = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
async function load() {
const notes = await (await fetch('/api/notes')).json();
document.getElementById('list').replaceChildren(...notes.map(n => {
const li = document.createElement('li');
li.textContent = n.body + (n.attachment ? ' (attachment)' : '');
return li;
}));
}
document.getElementById('note').onsubmit = async (e) => {
e.preventDefault();
const form = new FormData(e.target);
const file = form.get('file');
let attachment = null;
if (file && file.size) {
const type = file.type || 'application/octet-stream';
const { url, key } = await json('/api/upload-url', { filename: file.name, contentType: type });
await fetch(url, { method: 'PUT', headers: { 'content-type': type }, body: file }); // same Content-Type as signed
attachment = key;
}
await json('/api/notes', { body: form.get('body'), attachment });
e.target.reset();
load();
};
document.getElementById('buy').onclick = async () => {
location.href = (await json('/api/checkout', {})).url;
};
load();
</script>
`;5. Deploy
harakumo deploy <projectId> ./notes-app # uploads the folder
harakumo deploys <projectId> # queued → deploying → ready, in secondsKeep secrets in environment variables, never in the file. After changing a variable, deploy again: an edge program reads them when it deploys.
What happens
The pipeline finds worker.js at the root and deploys it as an edge program at https://<slug>.harakumo.app, with the project's production environment variables. Nothing builds and nothing sleeps: it answers from the edge location nearest each visitor. When the status is ready, open the address.
- Notes are saved through the database API, so they survive every deploy.
- Attachments go from the browser straight to the bucket with a signed link.
- Need a full Node.js server instead (Express, Next.js)? The same deploy runs it as a server app, which is on Pro and up.
6. Take a payment
Press Upgrade on the live app. The hosted checkout charges a real card: to try the flow, pay yourself and refund it from Dashboard → Payments (refunds are for the workspace owner). You return to /thanks?session_id=…, and the app confirms the payment with the session lookup.
The payment appears in Dashboard → Payments as Earnings: $5.00 less the 3.9% + 30¢ fee. Each payment is held 7 days before the workspace owner can withdraw it.
Next
- Databases — batch statements, export, limits and errors.
- Storage — download links, listings and folders.
- Payments — webhooks, metadata and refunds.
- Auth pools — add sign-in for your users.
- Limits & scaling — what this app can handle, and where the walls are.