apphaven client SDK and REST API
Every app serves a small SDK. The frontend talks to its own origin, so there's no URL to configure and no CORS.
<!-- static site -->
<script type="module">
import { createClient, filter } from "/_apphaven/sdk.js";
const app = createClient();
</script>
// Vite project (pre-installed during the build; add "@apphaven/client": "*" to dependencies)
import { createClient, filter } from "@apphaven/client";
const app = createClient();
Auth
const user = await app.auth.signUp({ email, password, name }); // password: 8+ characters; no session yet (below)
const user = await app.auth.signIn({ email, password });
await app.auth.signOut();
const user = await app.auth.getUser(); // { id, email, name, role, image } or null
const stop = app.auth.onChange((user) => render(user)); // now + on every sign in/out
Every password sign-up confirms its address first: signUp creates the account and emails a
confirmation link, but returns no session (getUser() stays null), so show "check your
email, then sign in". signIn answers 403 until the link is opened (each attempt before that
re-sends the link). Opening it confirms the address and normally signs the person in. In preview,
opening it on a device without Apphaven access confirms the address and asks them to return to
the test version and sign in. A duplicate address gets the same answer as a new one, not an
error, so don't look for a 422.
Errors throw ApphavenError with .status, .code and a readable .message
(e.g. wrong password → 401, address not confirmed yet → 403). Show .message to the user.
Email flows (links are emailed by apphaven; nothing to configure):
await app.auth.requestPasswordReset({ email }); // emails a link; always resolves
// The link opens /_apphaven/reset-password?token=… (a plain page every app has). To use your
// own page: requestPasswordReset({ email, redirectTo: "/reset" }) and on that page:
await app.auth.resetPassword({ newPassword }); // token read from ?token= (or pass { token })
await app.auth.sendMagicLink({ email, name }); // auth.magic_link: true; opening the link signs in
// and returns to the current page (or callbackURL)
await app.auth.sendVerificationEmail({ email }); // re-sends the confirmation link
With magic_link, a sign-in form can be just an email field; the link confirms the address by
being opened. Say "check your email" after these calls. In preview, only the owner's Apphaven
account email address is eligible for delivery. Other recipients' mail is captured for
inspection: use read_emails, then read the matching message by id to get its link. Check
the actual status even for the owner; failed, limited and suppressed need attention, and
sent does not prove the message reached an inbox.
Testing sign-in in preview
The tools open the private preview automatically. That access is separate from being signed
in inside the app. as: "alice" or as: "bob" signs in an already-confirmed test user; use
those accounts for authenticated screens and access rules. They do not test ordinary
sign-up, the confirmation email or the app's sign-in form.
Test the ordinary flow separately after deploying, leaving as out throughout:
- Use
view_pagewithstepsto submit the app's real sign-up form. Use a fresh synthetic address such asverification-1@example.invalidand a test-only password, not the owner's credentials. Choose a new address for each run: a duplicate sign-up gives a generic answer. Do not use@apphaven.testfor this check; those addresses are automatically confirmed. Check that the page asks the person to confirm their address and does not treat them as signed in yet. - Check the unconfirmed account cannot sign in. For the exact API response, use
test_api_writewithmethod: "POST",path: "/api/auth/sign-in/email"andbody: { "email": "verification-1@example.invalid", "password": "<your test password>" }. Inspect the error code/message as well as the status. This attempt can send another confirmation email. - Call
read_emailsfor preview, find the latest confirmation message for that exact address, then call it with that message'sid.capturedis expected for this non-owner address. Open the confirmation link usingview_page, passing its app-local path and complete query string aspath. Keep tokens out of app code and chat messages. An expired link needs a fresh confirmation email, not a change to the auth backend. - In a new
view_pagecall, usestepsto fill and submit the app's real sign-in form with that account, then exercise an authenticated action and check the resulting screen in the same call. Check the phone layout too. A screenshot taken withaswould bypass the sign-in form and would not establish that this flow works.
Each view_page call starts a fresh browser context; ordinary sign-in cookies do not carry
over to the next call. test_api / test_api_write also do not retain ordinary sign-in
cookies between calls. Address confirmation persists on the account, so step 4 can sign in
again; a later signed-out test_api call is not a check of that browser session.
The view_page report's signed-out/test-user label describes the starting identity; inspect
the steps and resulting UI to check a sign-in performed through the form.
Test password reset using the same mail inspection process, then sign in with the new test
password through the real form. If magic links are enabled, open the captured sign-in link
and check the authenticated UI within that view_page call. Its session will not survive
into a later call. The owner walkthrough and private-preview link rules are in the overview.
Troubleshooting sign-in
Use the endpoint and response body to distinguish these cases; a status alone is insufficient.
test_api / test_api_write show the response body, while view_page reports failed requests
and visible page text. Keep the UI's error message useful instead of replacing every failure
with "Sign-in failed".
| Evidence | Next step |
|---|---|
401 preview_locked |
The owner must sign in to Apphaven to open the private preview. Tool calls get preview access automatically; if a tool still gets this error, report it rather than changing the app's auth provider. |
| An auth response says the email or password is invalid | Check the app, environment and account. Apphaven accounts, preview app accounts and live app accounts have separate credentials; resetting preview removes copied live passwords. Use the app's password-reset flow when needed. |
| An auth response says the email is not verified/confirmed (403) | Find that address's confirmation email with read_emails, open the link, then retry sign-in. Check the message and link before resending. |
403 forbidden from a model or operation |
Check the app user's role and the relevant access rule. This is not evidence that their email is unconfirmed. |
Preview mail is captured |
Expected for non-owner addresses. Inspect the message and follow its link with the tools; retrying will not make it arrive in that recipient's inbox. |
Mail is failed, limited or suppressed |
Read the mail log's error or limit/suppression detail and explain the specific problem. Do not assume the app needs an external email or auth provider. |
App auth supports email/password and optional magic links. Google/Apple sign-in on an app is not currently supported; Google sign-in to the Apphaven platform does not enable it for apps. Explain the limitation and offer a supported flow. See the overview before proposing an external provider or changing where the app's accounts and data live.
Data
app.db.<Model> for every model:
const { items, has_more } = await app.db.Recipe.list({
filter: "published == true", // same language as rules; fields of this model only
sort: "-created_at,title", // '-' = descending; default -created_at
limit: 20, offset: 0, // default 50, max 200
expand: ["author", "category"], // include linked records under item.expand
});
const r = await app.db.Recipe.get(id, { expand: ["author"] });
const r = await app.db.Recipe.create({ title: "Soup", servings: 4 });
const r = await app.db.Recipe.update(id, { title: "Better soup" }); // partial update
await app.db.Recipe.delete(id);
Counts and totals (computed by the database, over the records the user may list):
// votes per book, most votes first, with the book record attached
const { items } = await app.db.Vote.aggregate({ group_by: ["book"], sort: "-count", expand: ["book"] });
// items: [{ book: "id…", count: 7, expand: { book: { title, … } } }, …]
const { items: [totals] } = await app.db.Run.aggregate({ sum: ["km"], avg: ["minutes"], filter: "date >= '2026-10-01'" });
// totals: { count: 42, sum_km: 318.5, avg_minutes: 37.2 }
Use this instead of loading every record to count them. Options: group_by, count (default
true), sum, avg (number fields), min, max, filter, sort (by an output name, e.g.
-count, -sum_km), limit (default 100, max 1000), expand (group fields).
- Records come back as
{ id, created_at, updated_at, ...fields }. expandon auserfield gives{ id, name, image }(never the email). On areffield it gives the linked record if the reader maygetit, elsenull.- Use
filter(tagged template) whenever values come from user input, so quotes are escaped:app.db.Recipe.list({ filter: filter`contains(title, ${search})` }) - Filters can use
$user.idand$now, e.g."owner == $user.id". Access rules always apply on top of filters; you don't need to repeat them. - Signed-out visitors can submit at most 20 writes a minute (contact forms, orders); more get 429
rate_limited— show the message. - Errors: 400
validation_failed(.detailsmaps field → problem), 401unauthorized(sign in), 403forbidden, 404not_found(also when a rule hides the record), 409conflict(unique value taken).
Operations
await app.ops.publishRecipe({ id }); // params at the top level
await app.ops.quickNote({ data: { text: "hi" } }); // create/update input under `data`
const { items } = await app.ops.myDrafts({ limit: 20 });
Files
File values in create, update and an operation's data are uploaded and checked first, then
saved by id: app.db.Profile.update(id, { photo: input.files[0] }). For progress, upload first
with app.files.upload(file, { model, field, onProgress }). Reads return each file with url and
thumb_url. Signed out, files go through a form (a create operation). All of it, with examples:
get_docs "files".
REST API (what the SDK calls)
| method | path | body / query |
|---|---|---|
| GET | /api |
describes models, fields and operations |
| GET | /api/me |
{ user } |
| POST | /api/auth/sign-up/email |
{ email, password, name } |
| POST | /api/auth/sign-in/email |
{ email, password } |
| POST | /api/auth/sign-out |
{} |
| GET | /api/models/:Model |
?filter=&sort=&limit=&offset=&expand=a,b |
| GET | /api/models/:Model/:id |
?expand= |
| POST | /api/models/:Model |
record JSON |
| PATCH | /api/models/:Model/:id |
partial record JSON |
| DELETE | /api/models/:Model/:id |
|
| GET | /api/models/:Model/_aggregate |
?group_by=a,b&sum=x&avg=y&min=&max=&count=false&filter=&sort=-count&limit=&expand= |
| POST | /api/ops/:name |
{ ...params, data: {...} } (GET with query params also works for reads) |
Send Content-Type: application/json. Sessions are cookies, so use credentials: "include" if
you call fetch yourself. Errors are { "error": { "code", "message", "details"? } }.