# Use Harakumo from a phone app

Harakumo does not build or publish phone apps. You build the app with Xcode, Android Studio, Expo or Flutter, and Apple and Google review it and ship it. Harakumo is the app's backend (sign-in for its users, an API you write as a function, a database, file storage, AI, mail and payments) and it hosts the small website the stores ask for.

The app holds one Harakumo value: the sign-in pool's client id. Everything else goes through your function, which keeps the keys.

## Who does what

| Who | What they do |
| --- | --- |
| You | Write the app (Swift, Kotlin, React Native, Expo or Flutter). Create a project with a sign-in pool and a function, put the client id in the app, and deploy a small companion website. |
| Apple and Google | Review, sign and distribute the app and its updates; run in-app purchases; deliver push notifications. |
| Harakumo | Sign-in for the app's users, the function that is the app's API, the database, file storage, AI, mail, payments and push sending behind it, and the companion website. |

## How it fits together

1. Sign-in: the app calls `https://harakumo.com/api/auth-pools/public/<clientId>/<action>` to sign up, log in (with a password, or with Apple or Google), verify an email and reset a password, and gets back a token that lasts 7 days unless the pool sets a shorter lifetime, and, when it asks, a refresh token that keeps the phone signed in for months.
2. Everything else: the app calls your function (`https://hk-<project-slug>--<function-name>.harakumo.app`) and sends the token. The function checks it, then calls Harakumo's database, storage, AI, mail and payments APIs with keys it holds as environment variables.
3. Files: the function signs an upload or download link, and the app sends or fetches the bytes straight to storage.
4. Payments: the function creates a checkout link, the app opens it in the phone's browser, and the buyer comes back to the app through the app's own link (`appReturnUrl`) or an https link.
5. Push: the app registers each device's push token for the signed-in user, and your function sends to users by id; Harakumo delivers through Apple's and Google's push services with your keys.
6. The companion website: a static site on the same project serves the privacy, support and delete-account pages and the two app-link files, which Harakumo can write for you.

> With an AI assistant connected (see AI connector), setup_app_backend creates what the app needs (a sign-in pool, a database, storage, a key limited to the project) and stores every value as a project environment variable.

## What may ship inside the app

Anything inside an app can be read by anyone who downloads it, however it is stored or hidden. Only the client id is made to be read.

| Value | In the app? | Why |
| --- | --- | --- |
| Client id (`ap_…`) | Yes | Publishable. It can only sign up (while public sign-up is on), sign in (with a password, Apple or Google), refresh, send and check emailed codes, reset a password, check a session and, with a signed-in user's own token, change or delete that user's account and register their device for push; and, apart from the deletion page's actions while that page is on, only while app sign-in is on. |
| Client secret (`as_…`) | No, server only | It acts for the pool from your server, outside the limits the client-id endpoints have. |
| JWT secret (`js_…`) | No, server only | Whoever holds it can make a token for any user, and your function would accept it. |
| API keys (`hk_live_…`), read-only ones too | No, server only | A key reaches the project's data. Even a read-only key lists every user's email and signs download links for any file. |

## Turn on app sign-in

1. Create the pool: setup_app_backend from an AI assistant, Dashboard → the project → Auth, or `POST /api/projects/:id/auth`.
2. Turn on app sign-in: the switch "Allow sign-in from browsers and apps" on the project's Auth page, `PATCH /api/auth-pools/:id/settings { "publicEnabled": true }`, or the update_auth_settings tool (owner or admin). It is off by default; until it is on, every call to the client-id endpoints answers 403 `browser_access_off`, except the deletion page's two actions while that page is on.
3. If people create their accounts in the app, also turn on "Let anyone sign up" (`publicSignup: true`). It is a separate switch, off by default; until it is on, sign-up answers 403 `signup_closed` and log-in still works.
4. A native app sends no Origin header, so it needs no allowed origin. The allowed origins are for web pages, and for a website wrapped as an app (Capacitor, Ionic, Cordova, Tauri), which sends `capacitor://localhost`, `ionic://localhost`, `app://localhost` or `tauri://localhost`: add that one origin (`addOrigins` in update_auth_settings), or make the calls through native HTTP, which sends none.

**React Native or Expo**

```js
import * as SecureStore from 'expo-secure-store';

const AUTH = 'https://harakumo.com/api/auth-pools/public/ap_…';   // the client id: the only Harakumo value in the app

export async function logIn(email, password) {
  const r = await fetch(AUTH + '/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  const data = await r.json();
  if (!r.ok) throw new Error(data.error);   // data.code: invalid_credentials, locked, rate_limited …
  await SecureStore.setItemAsync('session', data.token);   // Keychain on iOS, Keystore on Android
  return data.user;
}

// On launch: still signed in?
export async function currentUser() {
  const token = await SecureStore.getItemAsync('session');
  if (!token) return null;
  const r = await fetch(AUTH + '/session', { headers: { authorization: 'Bearer ' + token } });
  if (r.status === 401) { await SecureStore.deleteItemAsync('session'); return null; }   // expired or revoked
  return (await r.json()).user;
}
```

**Swift**

```swift
let auth = URL(string: "https://harakumo.com/api/auth-pools/public/ap_…")!

struct Session: Decodable { let token: String }

func logIn(email: String, password: String) async throws -> String {
    var request = URLRequest(url: auth.appendingPathComponent("login"))
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(["email": email, "password": password])
    let (data, response) = try await URLSession.shared.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.userAuthenticationRequired) }
    return try JSONDecoder().decode(Session.self, from: data).token   // keep it in the Keychain
}
```

> Bodies are JSON with `content-type: application/json`. In Flutter, `jsonEncode` the body and set that header: a plain map is sent as a form and answers 400.

## Keep the token on the phone

- Store the token in the Keychain on iOS and in Keystore-backed storage on Android (expo-secure-store and flutter_secure_storage do this), never in AsyncStorage, SharedPreferences or a plain file.
- Send it to your function as `Authorization: Bearer <token>`. No cookies are involved.
- On launch, `GET …/session` with the token says who is signed in; 401 means sign in again.
- A token lasts 7 days unless the pool sets a shorter `tokenTtl`. To keep people signed in, ask for a refresh token at sign-in (`refresh: true`, with a `device` label such as "Sam's iPhone"), keep it in the Keychain or Keystore as well, and trade it with `refresh { refreshToken }` before the token runs out: each refresh answers a new token and a new refresh token. The session ends after 60 days without a refresh, or a year after sign-in. See Auth pools → Keep people signed in.
- To sign out, call `logout { refreshToken }` and delete both tokens from the phone. `logout { everywhere: true }` with the user's token, or `PATCH /api/auth-pools/:id/users/:userId { "action": "revoke-sessions" }` from your function, signs them out on every device.

## The JWT secret stays on the server

Tokens are signed with the pool's JWT secret (HS256), so whoever holds it can make tokens. The app never checks a token itself; your function does, in one of two ways:

- Ask Harakumo: `POST /api/auth-pools/:id/verify { "token" }` with the client secret. It also refuses the tokens of deleted users, of users signed out everywhere and of devices signed out.
- Check it in the function with the JWT secret from its environment: the HS256 signature, `iss` (the pool's issuer), `aud` (the client id) and `exp`. No call per request, but a revoked token is accepted until it expires.

## Your function is the app's API

One function can answer every screen of the app. It reads its settings as `env.NAME`, from the project's environment variables; the names below are the ones setup_app_backend stores. Functions deploy on every plan, including the free Hobby plan.

**Function**

```js
const API = 'https://harakumo.com/api';

function call(path, key, body) {
  return fetch(API + path, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  }).then(r => r.json());
}

export default {
  async fetch(request, env) {
    // Who is calling: check the token the app sent.
    const token = (request.headers.get('authorization') || '').replace(/^Bearer /, '');
    const { valid, user } = token
      ? await call('/auth-pools/' + env.HARAKUMO_AUTH_POOL_ID + '/verify', env.HARAKUMO_AUTH_CLIENT_SECRET, { token })
      : { valid: false };
    if (!valid) return Response.json({ error: 'Sign in again' }, { status: 401 });

    const url = new URL(request.url);
    if (request.method === 'GET' && url.pathname === '/orders') {
      const { results } = await call('/databases/' + env.HARAKUMO_DATABASE_ID, env.HARAKUMO_API_KEY, {
        sql: 'SELECT id, total_cents, status FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20',
        params: [user.id],
      });
      return Response.json({ orders: results });
    }
    if (request.method === 'POST' && url.pathname === '/avatar') {
      const { size } = await request.json();   // the photo's length in bytes, from the app
      const signed = await call('/storage/' + env.HARAKUMO_BUCKET_ID, env.HARAKUMO_API_KEY, {
        key: 'users/' + user.id + '/avatar.jpg', method: 'put', contentType: 'image/jpeg',
        size, maxBytes: 5 * 1024 * 1024,
      });
      if (!signed.url) return Response.json({ error: signed.error }, { status: 400 });
      return Response.json({ upload: signed.url });   // the app PUTs exactly size bytes there, Content-Type: image/jpeg
    }
    return Response.json({ error: 'Not found' }, { status: 404 });
  },
};
```

> Every query names its user (`WHERE user_id = ?`): the function is the only thing between one user and another user's data. HARAKUMO_API_KEY is limited to the project, and it also sends mail and calls AI for the project; which key each service takes is in API keys & authentication.

## Files

1. The app asks your function for an upload link and says how big the file is. The function picks the key (for example `users/<id>/avatar.jpg`) and signs a PUT with `POST /api/storage/:id { "key", "method": "put", "contentType", "size" }`.
2. The app starts the PUT within 15 minutes, with the same Content-Type and exactly that many bytes. It is one plain request, so it works as a background upload on iOS and Android.
3. For downloads the function signs a GET link: 15 minutes by default, up to 7 days. Sign a long link once and reuse it for pictures many people see.

> With `size`, the link accepts exactly that many bytes, so nobody who gets hold of it can upload more; `maxBytes` refuses a bigger file before any link is made (413 `file_too_large`). `size` is optional, but a link without it takes any file of up to 5 GB from whoever holds it, so a function signing for an app always sends it. Upload links last at most 15 minutes. One upload is at most 5 GB (with `size`, 1 GB on Hobby). A single PUT cannot resume: a dropped one starts again with a new link. For big files or bad networks, have the function start a resumable upload instead and hand the app a link per part (Storage → Resumable uploads): a part that fails is sent again on its own, and the upload carries on later from the parts already sent. Sign links only for signed-in users, one key at a time.

## Push notifications

Harakumo sends push notifications straight to Apple's push service (APNs) and Firebase Cloud Messaging, with your own keys: save the APNs key and the Firebase service account once, on the Push notifications panel of the project's Auth page or with `PUT /api/push/:poolId/credentials`. They are sealed and never shown again.

After sign-in, the app registers the device's push token for the user: `register-push { pushToken, platform: "ios" | "android" }` at the public endpoint with the user's token, and `unregister-push { pushToken }` on sign-out. Your function sends to users by id: `POST /api/push/:poolId/send { userIds, title, body, data? }` with the client secret. Details and limits are in Auth pools → Push notifications.

Functions run only when called (no schedules yet), so a notification goes out when something calls the function, such as a payment webhook or another user's action. For work on a timer, an agent can run on a schedule (see Agents).

## Payments in a store app

- Harakumo checkout takes one-off card payments in US dollars. In a store app it fits physical goods and real-world services: orders, food, bookings, tickets, deposits, fees and one-to-one sessions.
- Digital goods used inside the app (subscriptions, credits, premium features, game currency) must be sold through Apple's in-app purchase and Google Play Billing, with exceptions that depend on the country and on each store's programs, such as linking out to a web purchase in the US. Check each store's current rules before you link out. Harakumo does not check store purchases yet; record what each user bought from your function.
- Open the checkout link in the phone's browser or its in-app browser sheet (SFSafariViewController on iOS, Custom Tabs on Android; expo-web-browser and url_launcher do this), never in a web view of your own: wallets and bank checks can fail there.
- To come back to the app, give the checkout `appReturnUrl`, the app's own link (such as `myapp://checkout/done`), instead of `successUrl` and `cancelUrl`: Harakumo's own done or cancelled page hands the buyer back to the app with `session_id` and `status` added. Or point `successUrl` (http or https) at a page on your companion website that is also a universal link (iOS) or app link (Android), so the phone opens the app; the page itself says "Back to the app" for anyone without it.
- Reaching the success page is not proof of payment. Your function confirms it with the session lookup or the payment.succeeded webhook, then records the purchase.
- Refunds are made by the workspace owner in the dashboard (the project's Payments page), not by the app: API keys get 403 `payouts_require_owner`. An admin screen in the app can link there.

## The companion website

Both stores ask for web pages, and app links need two files on your domain. Put them in a static site on the same project; static sites deploy on every plan, including Hobby.

The site can deploy from the app's own repository: a site/ or docs/ folder with an index.html is served as the site, or an Expo app exports its web version with a build script (`"build": "expo export -p web"`). A Flutter web build is not run here, because the build machines have no Flutter toolchain: run `flutter build web` on your machine and upload the `build/web` folder.

A repository that holds only the phone app is not deployed: when its start script only runs a phone development server (`expo start`, `expo run:ios`, `react-native start`, `flutter run` and the like, also through `npm run`, `yarn` or `pnpm`) or there is none, the deploy is refused with code `phone_app` before anything builds. A start script that runs a real server (for example an Expo app's server output on Express) deploys as a server app, on Pro and up.

- Harakumo writes both app-link files from your app's ids: `PUT /api/projects/:id/app-links { ios: { appIds: ["<TeamID>.<bundle id>"], paths? }, android: { apps: [{ package, sha256 }] } }` (the connector's set_app_links does the same) builds them and writes them into the static site that is live now. A new deploy replaces the site's files, so write them again after each deploy (`POST /api/projects/:id/app-links { action: "write" }`), or keep the two files in the site's `.well-known` folder, which every deploy then carries. A server app or an edge program serves its own paths: the answer has the two files for it to serve.

| Path | What it is for |
| --- | --- |
| `/privacy` | The privacy policy: linked in App Store Connect, in Play Console and inside the app. What Harakumo keeps about your app's users lists what Harakumo holds, for how long and who else receives it, for the policy and the stores' privacy forms. |
| `/support` | The support URL App Store Connect asks for: how people reach you. |
| `/delete-account` | How someone deletes their account and how long it takes. Google Play asks for a link that works after the app is uninstalled, so it can take the request itself: a form that runs the pool's deletion actions, or a link to Harakumo's hosted deletion page (Let people delete their account, below). |
| `/.well-known/apple-app-site-association` | Universal links on iOS: your Team ID and bundle id, as JSON, in a file with no extension. |
| `/.well-known/assetlinks.json` | App links on Android: your package name and the SHA-256 fingerprint of the Play App Signing key, not your upload key. |

> Each address works without `.html`: `/privacy` is served from `privacy.html` (what Next.js and Expo static exports write), and `/privacy/` redirects there, so a page needs no folder of its own (`privacy/index.html`). Give the stores the address without a trailing slash. `apple-app-site-association` is served as `application/json` though it has no extension. Files under `/.well-known/` are served; other dotfiles are not. A single-page app that wants every route answered with its index.html adds a `_redirects` file with the one rule `/* /index.html 200`; that fallback never answers under `/.well-known/` or for a path with a file extension, so the app-link files are the real files or a real 404.

## Let people delete their account

Both stores require that people who create an account in an app can delete it from the app, and Google Play also asks for the web link. The sign-in pool does both:

1. In the app: Settings → Delete account calls `delete-account { password }` at the public endpoint with `Authorization: Bearer <token>` (an account with no password, made with Apple or Google, asks for a code with `send-deletion-code` and sends `{ code }`). The account goes at once, with its sessions, push devices and codes, and the verify call refuses its tokens from then on. It cannot be undone, so the app asks first.
2. On the web: turn on the hosted page (`PATCH /api/auth-pools/:id/settings { deletionPage: true, deletionNote }`) and give the stores `https://harakumo.com/account/delete/<clientId>`; or build your own `/delete-account` page with `request-deletion { email }` and `confirm-deletion { email, code }`.
3. Your server hears `user.deleted` on the pool's webhook (Auth pools → Hear when a user is deleted) and deletes that person's rows and files. Your function can also delete a user itself with `DELETE /api/auth-pools/:id/users/:userId`.

> What Harakumo keeps about your app's users says what to remove from each service, and what stays.

## Limits today

- Database: every query from your function is an HTTPS call through Harakumo, and a workspace may make 600 per 5 minutes, shared by database queries, vector calls, memory calls and video status checks. All workspaces together also share one platform ceiling on the calls among these that reach the edge, and it is lower than one workspace's own allowance, so the platform as a whole can also refuse calls before your workspace reaches its number, and sooner while other workspaces are busy (429 `rate_limited`, with Retry-After). So do not plan on a steady rate per second. That suits a beta with a few testers, not yet an app many people use at once. Batch statements (a batch is one call) and cache what you can in the function; direct database connections for functions are planned.
- Sign-in is email and password, Sign in with Apple and Sign in with Google: no other social sign-in, and no multi-factor sign-in for the app's users yet. Apple asks for Sign in with Apple when an app offers another company's sign-in, such as Google's.
- Emailed codes asked for from the app may use at most 20% of the workspace's 30-day email quota: 20 a month on Hobby.
- Push has no topics, no sending to every user at once, no scheduled sends and no delivery receipts yet; functions have no logs and no schedules yet.
- Checkout is one-off payments in US dollars, with no subscriptions, and nothing checks App Store or Google Play purchases yet.

## Reference

**REST**

```http
# From the app: the client id only
POST   /api/auth-pools/public/:clientId/signup | login | sign-in-with | send-verification | verify-email | forgot-password | reset-password | refresh | logout
POST   /api/auth-pools/public/:clientId/delete-account | send-deletion-code | register-push | unregister-push    Authorization: Bearer <token>
GET    /api/auth-pools/public/:clientId/session      Authorization: Bearer <token>

# From your function: secrets in its environment
POST   /api/auth-pools/:id/verify                     { "token" }                                      # Bearer client secret
POST   /api/auth-pools/:id                            { "action": "login", "email", "password" }       # Bearer client secret
POST   /api/databases/:id                            { "sql", "params"? }                             # Bearer API key
POST   /api/storage/:id                               { "key", "method": "put"|"get", "contentType"?, "size"?, "maxBytes"? }
POST   /api/payments/:accountId                       { "amountCents", "reference"?, "successUrl"? | "appReturnUrl"? }
GET    /api/payments/:accountId/sessions/:sessionId   → { paid, payment, session }
POST   /api/push/:poolId/send                         { "userIds", "title"?, "body"?, "data"? }        # Bearer client secret
DELETE /api/auth-pools/:id/users/:userId              # account deletion from your server

# Once, for the companion website's app-link files (developer or above)
PUT    /api/projects/:id/app-links                    { "ios"?: { "appIds" }, "android"?: { "apps": [{ "package", "sha256" }] } }

# Once, by a developer or above (the connector tool, update_auth_settings: owner or admin)
PATCH  /api/auth-pools/:id/settings                   { "publicEnabled": true, "publicSignup"?: true, "allowedOrigins"?: [] }
```
