Skip to main content

API client

Status: Scaffolded 2026-09-24

All network access goes through one function. Screens and hooks never call fetch and never build a URL string; they pick an entry from ENDPOINTS and pass it to request. That keeps the auth header, the timeout, and the error shape in one file, and makes "which routes does the app call" a grep.

src/constants/api.ts​

export const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
if (!API_BASE_URL) throw new Error('EXPO_PUBLIC_API_BASE_URL is not set');

export const ENDPOINTS = {
auth: { register: '/api/auth/register', login: '/api/auth/login', magicLink: '/api/auth/magic-link', ... },
me: { root: '/api/me', sessions: '/api/me/sessions', session: (id: string) => `/api/me/sessions/${id}` },
places: { list: '/api/places', bySlug: (slug: string) => `/api/places/${slug}`, ... },
events: { ... }, groves: { ... }, friends: { ... }, feed: { ... }, posts: { ... },
conversations: { ... }, media: { ... }, guides: { ... }, actions: { ... }, companion: { ... },
} as const;

The registry mirrors the API endpoints one for one. Adding a route to the API means adding it here in the same PR; the registry is typed, so a typo in a screen is a compile error. Paths are relative; request prepends the base URL.

EXPO_PUBLIC_API_BASE_URL is inlined at build time by Expo. It comes from .env locally and from the EAS profile's env block for builds. It is the value check-prod-ready.sh guards against being localhost in a store build (build and release).

src/lib/api/client.ts​

request<T>(path: string, options?: {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
skipAuth?: boolean;
timeoutMs?: number; // default 15000
}): Promise<T>

Behavior, in order:

  1. Auth header. Unless skipAuth, read the token from authStore (already in memory after restore; SecureStore is not hit per request) and set Authorization: Bearer <token>. skipAuth is for register, login, magic link, and public reads before sign-in. A request without a token and without skipAuth is rejected locally with unauthenticated rather than sent.
  2. Body. JSON.stringify with Content-Type: application/json. No FormData: images go to S3 with a presigned URL from POST /api/uploads/image/presign, video to Bunny.
  3. Timeout. An AbortController fires at timeoutMs. The abort becomes { status: 0, code: 'timeout' }.
  4. Response. 2xx with a body parses to T; 204 resolves undefined. Anything else is normalized and thrown.

Error normalization​

Every failure is an ApiError with { message, status, code }:

Sourcestatuscode
API envelope { error: { code, message } }the HTTP statusthe envelope's code
Non-JSON response (proxy error page)the HTTP statushttp_error
Timeout0timeout
No network, DNS, TLS0network_error

This is the same table the web client uses (error handling).

401 and 403​

A 401 means the session is gone: the client calls authStore.signOut() once (guarded so ten parallel failures do not sign out ten times) and AuthGate sends the member to the welcome screen. The ApiError is still thrown so the calling query settles.

A 403 is not a session problem. It is thrown to the screen, which renders a denied state (an organizer-only list, an admin route). It never signs out. A 404 on a resource the member expected is rendered as "not available" without distinguishing missing from hidden, matching the API's own rule.

Retries​

None inside the client. TanStack Query retries reads once; mutations do not retry. A 429 rate_limited carries Retry-After and the screen shows it.