# jstash Apps reference

> Private JSON documents for each signed-in user of a web app. The app keeps the login it already has (Clerk, Supabase Auth, Firebase Auth or Sign In With Google); the browser sends the signed-in person's login token, and jstash checks it. No server, no SDK, no jstash API key in the page, no security rules to write.

This is the plain-text reference for coding agents. The same facts, for people: https://jstash.app/docs/apps/ (guide) and https://jstash.app/apps/ (overview).

## When to use it

- An app already has sign-in (or is about to add one) and needs to remember things for each person: settings, progress, drafts, saved items, game state.
- The data should follow the person between devices and browsers, which `localStorage` doesn't.
- The app is a static site or front end, and you'd rather not write a backend, run a database or write security rules.

## When not to use it

- **Data people share.** Each document belongs to exactly one person. Team lists, group chats and anything collaborative need a database with its own rules.
- **Search and queries.** You load a document by name or list one person's document names. No search, filtering, sorting or queries across documents or people.
- **An admin view of users' data.** The developer can't read users' documents: no API, no dashboard screen, no export. Your app can read a person's documents only while that person is signed in.
- **Live sync.** Changes aren't pushed to other devices. Load the latest copy when you need it.
- **Apps with no login.** Every request needs a signed-in person. For public data anyone can read, use a jstash bin (https://jstash.app/docs/api.md).
- **Big or sensitive data.** Hard caps per plan (below). Don't store health, genetic, biometric or similar sensitive personal data.
- **Anything that needs backups or an uptime guarantee.** jstash keeps no backups or old versions, and has no SLA.

## Logins it accepts

| Login | The developer pastes, when creating the app | Token to send |
|---|---|---|
| Clerk | Frontend API URL (Clerk → Domains), or the publishable key | `await Clerk.session.getToken()` (React: `getToken` from `useAuth()`). The default session token, not a JWT template. Lasts 60 seconds. |
| Supabase | Project URL | `(await supabase.auth.getSession()).data.session.access_token`. `data.session` is null when nobody is signed in. |
| Firebase | Project ID | `await auth.currentUser.getIdToken()`. `currentUser` is null until sign-in finishes; wait for `onAuthStateChanged`. |
| Google | OAuth client ID (Sign In With Google) | `response.credential` from the Sign In With Google callback. Lasts one hour and can't be refreshed in the page; sign the person in again after that. |

- No other logins. GitHub sign-in works only through Clerk, Supabase or Firebase.
- Supabase projects must use JWT signing keys with an RSA or ECC (P-256) key, not the legacy JWT secret or a shared-secret (HS256) key (Supabase → Project Settings → JWT Keys).
- Clerk: development and production instances are separate (different Frontend API URLs and users), so each needs its own jstash app. Proxy and satellite-domain setups aren't supported. A Clerk user with a pending session task (such as choosing an organization) is refused until they finish it.
- Anonymous (guest) sign-ins from Supabase or Firebase are refused unless the app turns on Allow anonymous users, which needs Pro.
- Each app trusts one login: one Clerk instance, Supabase project, Firebase project or Google client. It can't change once people have saved data; create a new app instead.

## Setup (dashboard only)

Apps are created and changed only in the dashboard, never by code or API key:

1. Sign in at https://app.jstash.app with GitHub, open **Apps → New app**, and give it a name.
2. Pick the login and paste the one value from the table above.
3. Add the website addresses the app runs on, like `https://my-app.netlify.app` (up to 5). Turn on **Allow localhost** while developing.
4. The app's page shows its endpoint, `https://api.jstash.app/v1/apps/{appId}/me`, where `{appId}` looks like `app_` plus 22 characters. It also has starter code and a prompt with these values filled in.

**Coding agents:** ask the developer for the app ID or endpoint from the app's page. Don't guess or invent one.

## Website addresses

- Browsers send the page's address (the `Origin` header). jstash refuses requests from addresses the app doesn't list with `403 FORBIDDEN`, and the message names the address to add.
- Addresses are scheme and host (and port, if any), matched exactly: `https://www.example.com` and `https://example.com` are different. https only. No wildcards, so each preview URL needs its own entry. Up to 5 per app.
- **Allow localhost** accepts `localhost` and `127.0.0.1` on any port, over http or https.
- Clerk tokens also name the website they were made on; that must be one of the app's addresses too.
- Native apps and servers send no address, so the list doesn't affect them. The login token is what protects each person's data.

## Endpoints for the signed-in person

Base: `https://api.jstash.app/v1/apps/{appId}/me`. Every call sends `Authorization: Bearer <the signed-in person's login token>` and only ever reaches that person's documents. A jstash API key here is refused with `401 AUTH_INVALID`.

| Method and path | What it does |
|---|---|
| `GET /v1/apps/{appId}/me` | Lists their documents: `{"items": [{"name", "bytes", "version", "etag", "updated_at"}]}`, by name. |
| `GET /v1/apps/{appId}/me/{name}` | The document, raw, exactly as saved, with an `ETag` header. `404 DOC_NOT_FOUND` if nothing is saved under that name yet. |
| `PUT /v1/apps/{appId}/me/{name}` | Creates (`201`) or replaces (`200`) the whole document. Body: any JSON value. Returns `{"name", "bytes", "version", "etag", "updated_at"}` and an `ETag` header. |
| `DELETE /v1/apps/{appId}/me/{name}` | Deletes it. `204`, safe to repeat. |
| `DELETE /v1/apps/{appId}/me` | Deletes all of this person's documents (for your app's "delete my account"). `204`. |

- **Names:** 1–64 characters of letters, digits, `-`, `_` and `.`, not starting with a dot. Use a few fixed names like `settings` or `progress`, never user input.
- **Bodies:** any JSON value (object, array, string, number, `true`, `false`, `null`) as UTF-8. Content-Type doesn't matter. Stored byte for byte, so formatting and key order come back as sent.
- **No partial updates.** PUT replaces the whole document. Keep data that changes together in one document.
- **Saving identical content** returns `200` with the current details and doesn't count toward daily limits.
- Documents are served with `Cache-Control: private, no-store`. CORS exposes `ETag`, so browser code can read it.

## Safe updates with ETags

- If the same person might save from two devices at once, send the ETag from your last GET or PUT as `If-Match` on PUT. The `etag` field and the `ETag` header are the same value.
- `412 ETAG_MISMATCH` means it changed (or the person's documents were deleted while saving): load it again, reapply the change and retry.
- `If-None-Match: *` on PUT saves only if nothing exists under that name yet; otherwise `412 ETAG_MISMATCH`.

## Endpoints for the developer (API key)

Server-side only, with `Authorization: Bearer js_live_…`. Never put an API key in browser code.

| Method and path | What it does |
|---|---|
| `GET /v1/apps` | Your apps: `{"items": [{"id", "name", "login", "origins", "allow_localhost", "allow_anonymous", "status", "endpoint", "users", "documents", "bytes", "writes_today", "reads_today", "created_at", "updated_at"}]}`. Counts only, never document contents. |
| `GET /v1/apps/{appId}` | One app, same shape. |
| `DELETE /v1/apps/{appId}/users/{userId}` | Deletes one person's documents, for a deletion request. `{userId}` is their user ID in the login provider (the token's `sub`). Returns `204` whether or not anything was stored. |

Creating, changing and deleting apps is dashboard-only, so a leaked API key can't open an app to other websites.

## Limits

| Your account | Free | Pro |
|---|---|---|
| Apps | 1 | 3 |
| Users, across all your apps (a user counts once they first save) | 100 | 1,000 |
| Storage, shared with your bins | 10 MB | 1 GB |

| Per app | Free | Pro |
|---|---|---|
| Saves a day | 500 | 50,000 |
| Reads and lists a day | 5,000 | 50,000 |
| Website addresses | 5 | 5 |
| Anonymous sign-ins | Refused | If turned on |

| Per user | Free | Pro |
|---|---|---|
| Documents | 200 | 200 |
| Document size | 100 KB | 1 MB |
| Saves a day | 100 | 1,000 |
| Reads and lists a day | 1,000 | 5,000 |
| Per minute (every plan) | 60 saves, 300 reads, 30 deletes | same |

- Free needs no card and no invite. Pro is invite-only until paid plans open (US$5 a month or US$48 a year at launch), and new invites aren't being sent yet.
- Daily limits reset at 00:00 UTC. Every read and list counts, even one that finds nothing. 1 KB = 1,000 bytes; 1 MB = 1,000,000 bytes.
- At the user limit, people who already saved keep saving; new people get `APP_LIMIT_REACHED`.
- Limits are hard caps: at a limit the request is refused with an error naming it. Nothing is billed.
- An account with more apps than its plan includes (after leaving Pro) has every app read-only until it deletes down to the limit.

## Errors

Every error is JSON: `{"error": {"code": "…", "message": "…"}, "request_id": "req_…"}`. Branch on `code`, never on `message`. Log the message: it says what to fix, but it's written for developers, not for the app's users.

| Status | Code | Meaning and what to do |
|---|---|---|
| 400 | `INVALID_REQUEST` | The document name isn't allowed. |
| 400 | `INVALID_JSON` | The body is empty, not UTF-8 or not JSON; the message says where. |
| 401 | `AUTH_REQUIRED` | No token. Wait until the person is signed in. |
| 401 | `AUTH_INVALID` | The token is expired, from a different project or instance, edited, or a jstash API key. Get a fresh token and retry once; for Google, sign in again. |
| 403 | `FORBIDDEN` | The website isn't one of the app's addresses (the message names it), the Clerk token was made on an unlisted website, anonymous sign-ins are refused, or the app is read-only. |
| 403 | `APP_LIMIT_REACHED` | 200 documents for this person, or the account's user limit (only new people are refused). |
| 403 | `STORAGE_LIMIT_REACHED` | The developer's jstash storage is full. |
| 403 | `APP_SUSPENDED` | jstash moderators took the app offline. Don't retry. |
| 404 | `APP_NOT_FOUND` | The app ID is wrong, or the app was deleted. |
| 404 | `DOC_NOT_FOUND` | Nothing saved under that name yet. Usually treat it as empty. |
| 412 | `ETAG_MISMATCH` | See Safe updates. |
| 413 | `PAYLOAD_TOO_LARGE` | The document is over the plan's size limit. |
| 429 | `RATE_LIMITED` | Over a per-minute limit, or too many bad tokens from one network. Wait a minute (`Retry-After: 60`). |
| 429 | `WRITE_LIMIT_REACHED` | A daily save or read limit is used up. Don't retry before 00:00 UTC. |
| 500 | `INTERNAL_ERROR` | Retry once. |
| 503 | `LOGIN_PROVIDER_UNAVAILABLE`, `R2_UNAVAILABLE`, `DATABASE_UNAVAILABLE` | Retry shortly. |

## Security rules

- Send the person's login token, never a jstash API key. API keys (`js_live_…`) are for servers only and are refused by these endpoints.
- Get a fresh token right before each request instead of storing one.
- Drop any pending save when the person signs out or switches account, so one person's data is never saved under another.
- jstash checks each token's signature against the login provider's published keys, plus its issuer, audience (Clerk: website) and expiry. It never sees passwords and never runs sign-in.
- Tokens pass through jstash and aren't stored or logged. A token still works with its login provider until it expires (about a minute for Clerk, usually an hour for the others).
- Documents are filed under a one-way hash of the person's user ID. jstash stores no passwords, email addresses or tokens.
- jstash keeps no backups or old versions. A deleted or replaced document can't be recovered.

## Starter module

Plain `fetch`, nothing to install. Shown with Clerk; for another login, only `token()` changes (see the table above).

```js
const JSTASH = 'https://api.jstash.app/v1/apps/app_…/me'; // the endpoint from the app's page

// In React, use getToken from useAuth() instead
function token() {
  if (!Clerk.session) throw new Error('Not signed in');
  return Clerk.session.getToken(); // Clerk refreshes it for you
}

export async function save(name, value) {
  const res = await fetch(`${JSTASH}/${name}`, {
    method: 'PUT',
    headers: { Authorization: `Bearer ${await token()}` },
    body: JSON.stringify(value),
  });
  if (!res.ok) throw await failure(res);
}

export async function load(name) {
  const res = await fetch(`${JSTASH}/${name}`, {
    headers: { Authorization: `Bearer ${await token()}` },
  });
  if (res.ok) return res.json();
  const err = await failure(res);
  if (err.code === 'DOC_NOT_FOUND') return null; // nothing saved yet
  throw err;
}

// jstash errors are JSON: {"error": {"code", "message"}}
async function failure(res) {
  const { error } = await res.json()
    .catch(() => ({ error: { message: `jstash returned ${res.status}` } }));
  return Object.assign(new Error(error.message), { code: error.code });
}
```

Other `token()` functions:

```js
// Supabase: use the app's existing client
async function token() {
  const { data } = await supabase.auth.getSession();
  return data.session.access_token;
}

// Firebase: use the app's existing getAuth() instance
function token() {
  return auth.currentUser.getIdToken(); // Firebase refreshes it for you
}

// Google: set idToken in your Sign In With Google callback: idToken = response.credential
let idToken;
function token() {
  return idToken; // valid for one hour; sign in again after that
}
```

Use it once someone is signed in:

```js
const settings = (await load('settings')) ?? { theme: 'light' }; // null: nothing saved yet
await save('settings', { ...settings, theme: 'dark' });
```

- Call `load` once someone is signed in, and `save` when the data changes (debounce rapid edits), never on a timer.
- If the app lets people delete their account, also call `DELETE` on the endpoint with their token.
- Add a short note to the project's AGENTS.md (or CLAUDE.md): where the module is, that it sends the login token and never a jstash API key, and a link to this file.

## Deleting data

- A person deletes their account in your app: `DELETE /v1/apps/{appId}/me` with their token.
- Someone asks the developer to delete their data: **Delete a user's data** on the app's page, or `DELETE /v1/apps/{appId}/users/{userId}` with an API key.
- Deleting the app stops it at once; its documents are then deleted, usually within two hours.

## More

- Guide: https://jstash.app/docs/apps/
- Examples (Clerk + Vite, Sign In With Google in plain HTML, Supabase + Vite; MIT): https://github.com/jstash-app/jstash-examples
- Bins API (public JSON at a URL, server-side writes): https://jstash.app/docs/api.md
- Pricing: https://jstash.app/pricing/
- Terms: https://jstash.app/terms/ · Privacy: https://jstash.app/privacy/
- Support: support@jstash.app
