Skip to content
Docs · Start here

Quickstart: your first app

From signup to a live app with a database, browser uploads to storage and a payment link, on the free Hobby plan, with code that runs as written: one worker.js file, no server to run.

View as Markdown
All topics
On this page

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

  1. 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.
  2. Open Dashboard → Settings → API access and create a key with full access to the whole workspace. It stays on your machine for setup.
Terminal
npm install -g @harakumo/cli
export HARAKUMO_API_KEY=hk_live_…          # the key you just created
harakumo login --key $HARAKUMO_API_KEY

2. Create the project, a database, a bucket and a payment account

Terminal
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:

VariableValue
HARAKUMO_API_KEYthe project key (hk_live_…)
NOTES_DB_ID<dbId>
UPLOADS_BUCKET_ID<bucketId>
PAYMENTS_ACCOUNT_ID<accountId>
One variable with curl
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.

worker.js
// 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

Terminal
harakumo deploy <projectId> ./notes-app    # uploads the folder
harakumo deploys <projectId>               # queued → deploying → ready, in seconds

Keep 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.