Data fetching in the Expo app | Expo Supabase SaaS Kit

Learn how the native app reads and writes data through the versioned /api/v1 endpoints, how the api() client and React Query are wired, and how to add your own endpoint and hook end to end.

Every screen in apps/native that shows server data gets it the same way: a React Query hook calls api(), which calls a versioned route handler in apps/web/app/api/v1/*. This page explains that path, what the query provider already handles for you, and then walks through adding a new endpoint and the hook that consumes it, from the database to the screen.

Which door to use

Two clients reach the backend from the app, and picking the wrong one is the most common mistake when extending it.

ConcernClientWhere
Reading and writing your dataapi()/api/v1/*apps/native/features/core/api-client.ts
Sign-in, sign-up, sign-out, session refresh@supabase/supabase-js directlyapps/native/features/core/supabase.ts
MFA: enroll, challenge, verify, list factors@supabase/supabase-js directlysupabase.auth.mfa.*
Uploading avatars and team logos@supabase/supabase-js directlysupabase.storage.from('account_image')

The rule behind the table: the Supabase SDK is used only where an HTTP wrapper would make things worse. Auth needs the session in the client so the SDK can refresh it; MFA challenge and enrollment are GoTrue APIs with no data of yours in them; and pushing an image through a JSON endpoint would mean base64 in a request body. Everything else — anything that is your product's data or your product's rules — goes through /api/v1.

Why data goes through /api/v1 rather than supabase.from(...)

Supabase's JS SDK queries tables from React Native perfectly well. The kit deliberately does not do that, for one reason: a mobile binary is not a deployment you control.

Installed copies update on the user's schedule, and old versions stay in the field for months. If the rules about which roles a caller may assign, when a seat count changes, or whether team creation is allowed lived in the app, then every rule would exist twice — once in web's server actions and once in the app — and an old binary would keep enforcing an old copy of them. Putting them behind a route handler means:

  • One implementation per rule. The /api/v1 handlers call the same services as web's server actions. POST /api/v1/accounts/teams/[slug]/invitations and web's createInvitationsAction both end up in createAccountInvitationsService.sendInvitations, so a change to invitation behaviour lands on both platforms at once.
  • Rules you can fix without a store release. A server-side gate is redeployed with apps/web.
  • A versioned contract. Fields may be added within v1; removing or renaming one means /api/v2. An app from six months ago keeps working.
  • Server-side enforcement of things web only gates in the UI. POST /api/v1/accounts/teams re-checks enableTeamAccounts and enableTeamCreation, and POST /api/v1/accounts/teams/[slug]/leave refuses the primary owner — both places where web relies on the UI not offering the action.

RLS has not gone anywhere. enhanceRouteHandler builds the Supabase client from the caller's own JWT, so a handler that queries a table is subject to exactly the same policies the app would have been. The endpoints add authorization and business rules on top of RLS; they do not replace it.

The api() client

apps/native/features/core/api-client.ts is roughly a hundred lines and is the only place in the app that calls fetch. Its signature:

export async function api<T>(path: string, init?: RequestInit): Promise<T>

path is relative to the API version prefix — you pass /me/workspace, not /api/v1/me/workspace. Everything else is the RequestInit you already know.

BehaviourDetail
Base URLprocess.env.EXPO_PUBLIC_API_BASE_URL, wrapped in resolveDevHost(), with /api/v1 appended before your path.
Authsupabase.auth.getSession() on every call; the access_token becomes Authorization: Bearer <jwt>. Nothing is cached, so a token the SDK refreshed in the background is picked up immediately.
HeadersBuilt with the Headers API from init.headers, then Content-Type: application/json and the bearer are set() — a caller cannot accidentally shadow either with a differently-cased key.
Timeout15 seconds, via an AbortController.
Empty responsesA 204, or a content-length: 0, returns undefined rather than throwing on an empty JSON parse. Type it as api<undefined>(...).
FailuresAny non-ok response becomes an ApiError — never a raw Response.

The resolveDevHost wrap comes from apps/native/features/core/dev-host.ts. On Android in dev it rewrites a loopback host to 10.0.2.2, the emulator's fixed alias for the host machine's localhost, so a single committed .env.development serves the iOS simulator and the Android emulator without an override. LAN IPs and remote URLs pass through untouched. docs/native/running-locally.mdoc covers the device and env setup.

Signals compose

The timeout controller does not replace a caller's signal — the two are wired together:

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
const onCallerAbort = () => controller.abort();
if (init?.signal?.aborted) {
controller.abort();
} else {
init?.signal?.addEventListener('abort', onCallerAbort);
}

The request aborts on whichever fires first, and both the timer and the listener are cleaned up in a finally. This matters because React Query passes a signal into every queryFn: pass it through and an unmounted screen's in-flight request is cancelled instead of running to its full 15 seconds.

queryFn: ({ signal }) => api<Project[]>('/accounts/teams/' + slug + '/projects', { signal }),

Two guard clauses

api() throws before it reaches the network in two cases.

No base URL. A missing EXPO_PUBLIC_API_BASE_URL throws with the file to set it in. An unset EXPO_PUBLIC_* variable is undefined at runtime rather than a build error, so without this guard the first request would fail as fetch('undefined/api/v1/...').

Cleartext in a production build. When __DEV__ is false and the base URL does not start with https://, api() refuses to run:

if (!__DEV__ && !API_BASE_URL.startsWith('https://')) {
throw new Error(
'EXPO_PUBLIC_API_BASE_URL must use https:// in a production build.',
);
}

Every request carries a Supabase access token in an Authorization header. Over plain HTTP that token is readable by anything on the network path, and it is a working credential for the user's whole account until it expires. The default .env.development points at http://localhost:3000, which is correct locally and catastrophic in a release build — and because EXPO_PUBLIC_* values are baked in at build time, a stale one ships silently. The guard turns that into a loud failure on the first request instead. docs/native/config-requirements.mdoc documents EXPO_PUBLIC_API_BASE_URL and what changing it forces elsewhere; docs/native/building-shipping.mdoc covers per-environment builds.

Errors: ApiError and the code contract

Every failed request throws an ApiError carrying three things:

FieldMeaning
statusThe HTTP status.
codeThe machine-readable discriminator from the response body's error field, or 'unknown' for a non-JSON body.
messageThe handler's human-readable message when it supplies one (policy denials do), otherwise the code itself.

code is the contract you branch on. It mirrors the discriminator enhanceRouteHandler produces for auth failures — unauthenticated or mfa-required — and, beyond that, whatever string a handler returns in { error: '...' }. The per-endpoint codes are listed in apps/web/app/api/v1/AGENTS.md; branch on them rather than on status, since one status covers several distinct causes.

The invitations screen shows the pattern (apps/native/app/(home)/invitations.tsx):

onError: (error) => {
if (isAuthError(error)) {
return; // the query provider already handled it
}
if (error instanceof ApiError && error.code === 'already-member') {
goToTeam(invitation.account.slug);
return;
}
toast.error(tAuth('acceptInviteError'));
}

isAuthError(error) returns true for exactly the two auth discriminators. Use it to bail out of local error handling, because those two are owned globally — see the next section.

For display, describeError in apps/native/features/core/errors.ts renders "401 · mfa-required" for an ApiError and the message for anything else. It is a diagnostic, not a user-facing string: ErrorState shows the generic common.genericError to users and only renders describeError behind a __DEV__ check, matching web's rule that error detail never leaks to a user.

What the query provider does for you

apps/native/lib/react-query-provider.tsx wraps the app and configures the QueryClient once. Understanding what it already handles keeps it out of your hooks.

WiringEffect
QueryCache / MutationCache onErrorAny ApiError with code === 'unauthenticated' triggers supabase.auth.signOut(); code === 'mfa-required' does router.replace(ROUTES.mfaChallenge). Applies to every query and every mutation in the app.
retry predicateReturns false for those same two codes, failureCount < 3 otherwise. A rejected token is not retried three times.
staleTime: 60 * 1000The same 60-second default as web's provider, so a screen re-entered within a minute paints from cache.
onlineManagerDriven by expo-networkNetwork.addNetworkStateListener, set once at module scope. React Query pauses and resumes with the device's connectivity.
focusManagerDriven by React Native's AppState, so returning from the background refetches stale queries the way tab focus does on web.

The first row is the important one: you do not write 401 handling in a hook or a screen. A signed-out token anywhere in the app flips auth state, the (home) guard redirects to sign-in, and the authenticated stack unmounts — which dismisses any modal presented above it. An expired MFA session lands on the challenge screen. Your onError only handles the codes that are specific to your feature, and calls isAuthError first to skip the rest. docs/native/authentication.mdoc covers the state machine those two paths feed into.

Hook conventions

Hooks live with their feature, split by kind:

apps/native/features/<domain>/hooks/queries/use-<thing>.ts
apps/native/features/<domain>/hooks/mutations/use-<verb>-<thing>.ts

One-off hooks that are neither (use-resend-cooldown.ts) sit in hooks/ directly. There are no barrel files — import the deep path. The layering rules are in docs/native/project-structure.mdoc.

A query hook

apps/native/features/team-accounts/hooks/queries/use-team-members.ts, in full:

import { useQuery } from '@tanstack/react-query';
import type { Database } from '@kit/supabase/database';
import { teamAccountKeys } from '@kit/team-accounts/shared';
import { api } from '~/features/core/api-client';
import { useActiveWorkspace } from '~/lib/workspace/workspace-context';
export type TeamMember =
Database['public']['Functions']['get_account_members']['Returns'][number];
export function useTeamMembers() {
const active = useActiveWorkspace();
const slug = active.kind === 'team' ? active.slug : undefined;
return useQuery({
queryKey: teamAccountKeys.members(slug ?? ''),
queryFn: () => api<TeamMember[]>('/accounts/teams/' + slug + '/members'),
enabled: !!slug,
staleTime: 30_000,
});
}

Four conventions are doing work here:

  • The response type is derived, not hand-written. Database comes from @kit/supabase/database, the same generated types web uses, so a column change breaks the typecheck instead of the screen.
  • The key comes from @kit/team-accounts/shared. teamAccountKeys and accountKeys are plain objects (detail, members, invitations; data) that both platforms import, so a key shape is defined once. Where no shared key exists yet the hooks use a local literal — ['user-workspace', userId], ['team-account-roles', slug] — which is fine, but keep the literal out of two files: useMyInvitations exports myInvitationsKey for useAcceptInvitation to invalidate.
  • enabled: !!slug keeps the query from firing with undefined in the URL while no team is active. Any hook whose path depends on a value that can be absent needs this.
  • staleTime is overridden per hook where the default is wrong. Members are 30 s (they change during a session); the role list is 30 minutes (useAccountRoles, it barely changes); pending invitations are 5 minutes in useMyInvitations because it is mounted in the header on every screen.

A mutation hook

apps/native/features/team-accounts/hooks/mutations/use-invite-members.ts, in full:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { teamAccountKeys } from '@kit/team-accounts/shared';
import { api } from '~/features/core/api-client';
export interface InviteMembersPayload {
invitations: { email: string; role: string }[];
}
export function useInviteMembers(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['accounts', 'invite-members', slug],
mutationFn: (payload: InviteMembersPayload) =>
api<{ success: boolean }>('/accounts/teams/' + slug + '/invitations', {
method: 'POST',
body: JSON.stringify(payload),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: teamAccountKeys.invitations(slug),
});
},
});
}

Notes:

  • body is stringified by the caller. api() sets the content type but does not serialize for you.
  • onSuccess invalidates, it does not write to the cache. Re-reading through the endpoint is the only way to see what the server actually did — a role the service normalized, a seat count it changed.
  • Invalidate everything the mutation moved. useUpdatePersonalAccount invalidates both accountKeys.data(userId) and ['user-workspace', userId], because the display name appears in the workspace switcher too.
  • Use onSettled when a failure is also a change. useAcceptInvitation does, because already-member means the membership exists regardless of the 409.
  • The mutation is slug-scoped by argument. Passing slug into the hook, rather than reading the active workspace inside it, keeps a mutation targeting the team the screen is about.

Adding an endpoint end to end

The walkthrough below adds a per-team projects list: any member can read it, and a member with settings.manage can create one. Follow the same five steps for any resource.

1. The table and its RLS policies

Add the table under apps/web/supabase/schemas/ and generate a migration, as described in docs/development/database-schema.mdoc and docs/development/migrations.mdoc. Scope it to an account and gate it with public.has_role_on_account(account_id) — this is the floor your endpoint sits on, and it is what makes a bug in the handler a failed request rather than a tenant leak. Regenerate the Supabase types afterwards so Database knows about the table.

2. Shared schema and query keys

Anything both platforms need — a Zod schema, a response type, a query key — must live in a @kit/* package under a /shared subpath. apps/native depends on @kit/accounts, @kit/auth, @kit/team-accounts, @kit/supabase, @kit/shared and @kit/mobile-ui; it cannot import from apps/web, and the /shared entrypoints are the ones free of React DOM and Next.js imports, which is what makes them safe in a Metro bundle.

For a team-scoped resource, add the schema to packages/features/team-accounts/src/schema/ and export it from packages/features/team-accounts/src/shared/index.ts. For a domain of your own, create a package with its own ./shared export — docs/development/adding-turborepo-package.mdoc covers that.

// packages/features/team-accounts/src/schema/create-project.schema.ts
import * as z from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(2).max(100),
});

Add the key alongside the existing ones in packages/features/team-accounts/src/shared/keys.ts:

export const teamAccountKeys = {
detail: (slug: string) => ['team-account:detail', slug] as const,
members: (slug: string) => ['team-account:members', slug] as const,
invitations: (slug: string) => ['team-account:invitations', slug] as const,
projects: (slug: string) => ['team-account:projects', slug] as const,
};

Now web's form and the native form validate against the same schema, and both invalidate the same key.

3. The service

Business logic goes in a service — a factory taking a Supabase client and returning an object of methods, with no knowledge of HTTP, React or Next.js. That is what lets web's server action and the native-facing route handler share it instead of reimplementing it. apps/web/lib/server/ is the place for logic specific to your app; packages/features/<domain>/src/server/services/ is the place for a reusable domain. The /service-builder skill scaffolds one.

// apps/web/lib/server/projects/projects.service.ts
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
export function createProjectsService(client: SupabaseClient<Database>) {
return new ProjectsService(client);
}
class ProjectsService {
private readonly namespace = 'projects';
constructor(private readonly client: SupabaseClient<Database>) {}
async getProjects(params: { accountId: string }) {
const { data, error } = await this.client
.from('projects')
.select('id, name, created_at')
.eq('account_id', params.accountId)
.order('created_at', { ascending: false });
if (error) {
throw error;
}
return data;
}
async createProject(params: { accountId: string; name: string }) {
const logger = await getLogger();
const ctx = { name: this.namespace, accountId: params.accountId };
const { data, error } = await this.client
.from('projects')
.insert({ account_id: params.accountId, name: params.name })
.select('id, name, created_at')
.single();
if (error) {
logger.error({ ...ctx, error }, 'Failed to create project');
throw error;
}
logger.info(ctx, 'Project created');
return data;
}
}

Two rules from packages/features/AGENTS.md apply: use getLogger() from @kit/shared/logger rather than console.log, and mark the file import 'server-only' so it can never be pulled into a client bundle.

The payoff is on the web side — the server action for the same feature calls the identical service:

export const createProjectAction = authActionClient
.inputSchema(CreateProjectActionSchema)
.action(async ({ parsedInput: data }) => {
const client = getSupabaseServerClient();
return createProjectsService(client).createProject({
accountId: data.accountId,
name: data.name,
});
});

authActionClient from @kit/next/safe-action is web's entry point, enhanceRouteHandler is native's, and the service in the middle is the same object. packages/next/AGENTS.md and the /server-action-builder skill cover the action side.

4. The route handler

Create apps/web/app/api/v1/accounts/teams/[slug]/projects/route.ts. The conventions for this directory — no [locale] segment, JSON only, never redirect() — are in apps/web/app/api/v1/AGENTS.md.

import { NextResponse } from 'next/server';
import { enhanceRouteHandler } from '@kit/next/routes';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import { CreateProjectSchema } from '@kit/team-accounts/shared';
import { createProjectsService } from '~/lib/server/projects/projects.service';
/**
* GET /api/v1/accounts/teams/[slug]/projects
*
* Returns the team's projects. RLS scopes the read to teams the caller
* belongs to, so a non-member gets an empty array.
*/
export const GET = enhanceRouteHandler(
async ({ client, params }) => {
const slug = params.slug;
if (!slug) {
return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
}
const { data: account, error } = await client
.from('accounts')
.select('id')
.match({ slug, is_personal_account: false })
.single();
if (error || !account) {
return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
}
try {
const projects = await createProjectsService(client).getProjects({
accountId: account.id,
});
return NextResponse.json(projects);
} catch (error) {
const logger = await getLogger();
logger.error({ name: 'projects.list', slug, error }, 'Failed to list projects');
return NextResponse.json(
{ error: 'projects-fetch-failed' },
{ status: 500 },
);
}
},
{ auth: true },
);
/**
* POST /api/v1/accounts/teams/[slug]/projects
*
* Creates a project. Requires `settings.manage` on the team.
*/
export const POST = enhanceRouteHandler(
async ({ user, body, client, params }) => {
const logger = await getLogger();
const slug = params.slug;
if (!slug) {
return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
}
const { data: account, error } = await client
.from('accounts')
.select('id')
.match({ slug, is_personal_account: false })
.single();
if (error || !account) {
return NextResponse.json({ error: 'team-not-found' }, { status: 404 });
}
const { data: allowed } = await client.rpc('has_permission', {
user_id: user.id,
account_id: account.id,
permission_name:
'settings.manage' as Database['public']['Enums']['app_permissions'],
});
if (!allowed) {
return NextResponse.json(
{
error: 'projects-forbidden',
message: 'You do not have permission to create projects',
},
{ status: 403 },
);
}
try {
const project = await createProjectsService(client).createProject({
accountId: account.id,
name: body.name,
});
return NextResponse.json(project, { status: 201 });
} catch (createError) {
logger.error(
{ name: 'projects.create', slug, userId: user.id, error: createError },
'Failed to create project',
);
return NextResponse.json(
{ error: 'project-create-failed' },
{ status: 500 },
);
}
},
{ auth: true, schema: CreateProjectSchema },
);

What enhanceRouteHandler gives you, and what it does not:

  • auth: true verifies the caller. It detects Authorization: Bearer <jwt> and builds an RLS-bound client from that token; with no bearer it falls back to the cookie client, so the same handler serves the browser. A bearer caller that fails gets 401 { error: 'unauthenticated' | 'mfa-required' } as JSON, because a native client cannot follow a redirect to a sign-in page.
  • schema parses the JSON body and returns 400 on a mismatch, so body arrives typed. Path segments are not validated — params is Record<string, string>, so validate anything you use, as the kit's handlers do for numeric invitation ids and uuids.
  • client and user are handed to you. Do not rebuild either.
  • Nothing else is authorized for you. RLS is the floor; a permission gate like the has_permission call above is yours to add. If your service needs the admin client to do its work, then RLS is not protecting the write and every check has to be explicit — that is why the invitations handler ports web's permission checks in full.

Resolve the account id with the RLS-bound client before anything else, as above. A slug the caller has no membership in fails that lookup, so the 404 falls out of RLS rather than out of a check you had to remember to write.

Return codes your client can branch on. { error: 'projects-forbidden' } becomes ApiError.code === 'projects-forbidden' on the device; a message alongside it becomes ApiError.message.

5. The native hooks

// apps/native/features/projects/hooks/queries/use-team-projects.ts
import { useQuery } from '@tanstack/react-query';
import type { Database } from '@kit/supabase/database';
import { teamAccountKeys } from '@kit/team-accounts/shared';
import { api } from '~/features/core/api-client';
import { useActiveWorkspace } from '~/lib/workspace/workspace-context';
export type Project = Pick<
Database['public']['Tables']['projects']['Row'],
'id' | 'name' | 'created_at'
>;
// Fetches the active team's projects from
// `/api/v1/accounts/teams/[slug]/projects`. Enabled only while a team is active
export function useTeamProjects() {
const active = useActiveWorkspace();
const slug = active.kind === 'team' ? active.slug : undefined;
return useQuery({
queryKey: teamAccountKeys.projects(slug ?? ''),
queryFn: ({ signal }) =>
api<Project[]>('/accounts/teams/' + slug + '/projects', { signal }),
enabled: !!slug,
staleTime: 30_000,
});
}
// apps/native/features/projects/hooks/mutations/use-create-project.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type * as z from 'zod';
import type { CreateProjectSchema } from '@kit/team-accounts/shared';
import { teamAccountKeys } from '@kit/team-accounts/shared';
import { api } from '~/features/core/api-client';
import type { Project } from '~/features/projects/hooks/queries/use-team-projects';
type CreateProjectPayload = z.infer<typeof CreateProjectSchema>;
export function useCreateProject(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['projects', 'create', slug],
mutationFn: (payload: CreateProjectPayload) =>
api<Project>('/accounts/teams/' + slug + '/projects', {
method: 'POST',
body: JSON.stringify(payload),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: teamAccountKeys.projects(slug),
});
},
});
}

6. The screen

Consume it with the shared states from apps/native/features/makerkit/components/. LoadingState is a centred ActivityIndicator; ErrorState renders the generic message, the __DEV__-only diagnostic, and a retry — with InlineErrorState for a failed section inside a screen that otherwise rendered.

import { FlatList, RefreshControl, View } from 'react-native';
import { Text } from '@kit/mobile-ui/text';
import { ErrorState } from '~/features/makerkit/components/error-state';
import { LoadingState } from '~/features/makerkit/components/loading-state';
import { useTeamProjects } from '~/features/projects/hooks/queries/use-team-projects';
export default function ProjectsScreen() {
const projects = useTeamProjects();
if (projects.isPending) {
return <LoadingState />;
}
if (projects.isError) {
return (
<ErrorState
error={projects.error}
onRetry={() => void projects.refetch()}
/>
);
}
return (
<FlatList
data={projects.data}
keyExtractor={(project) => project.id}
className="bg-background"
contentContainerClassName="p-4 gap-2"
refreshControl={
<RefreshControl
refreshing={projects.isFetching}
onRefresh={() => void projects.refetch()}
/>
}
renderItem={({ item }) => (
<View className="border-border rounded-lg border p-4">
<Text className="text-foreground font-medium">{item.name}</Text>
</View>
)}
/>
);
}

Three things worth copying from the real screens:

  • isPending, not isLoading, for a query with enabled gating: a disabled query never loads, but it is pending, so a screen keyed on isLoading renders an empty list before the slug arrives. apps/native/app/(home)/[account]/(tabs)/members/index.tsx gates on isPending.
  • A RefreshControl on the list, driven by isFetching, giving pull-to-refresh for free.
  • Degrade a section, not the screen. The members screen renders ErrorState when members fail, but an InlineErrorState row when only the invitations query fails, so a working list is not replaced by an error page.

Where the create form goes, and how useCreateProject binds to react-hook-form and the toast, is in docs/native/forms.mdoc. Adding the route the screen lives at is in docs/native/routing-navigation.mdoc.

Checklist

StepFile
Table + RLS + regenerated typesapps/web/supabase/schemas/
Schema and query key, both platforms import thempackages/features/<domain>/src/shared/
Service holding the business rulesapps/web/lib/server/ or packages/features/<domain>/src/server/services/
Route handler calling the serviceapps/web/app/api/v1/**/route.ts
Query and mutation hooksapps/native/features/<domain>/hooks/{queries,mutations}/
Screen with loading and error statesapps/native/app/

Then run pnpm typecheck, which covers apps/web, apps/native and @kit/mobile-ui in one pass.

Endpoint reference

Every /api/v1 route in the kit and the hook that calls it. The request and response shapes, the full list of error codes per endpoint, and the authorization invariants to re-verify after touching one are in apps/web/app/api/v1/AGENTS.md; the services underneath are documented in docs/api/team-account-api.mdoc, docs/api/account-api.mdoc, docs/api/user-workspace-api.mdoc, docs/api/account-workspace-api.mdoc and docs/api/otp-api.mdoc.

EndpointMethodNative hook
/me/workspaceGETuseUserWorkspacefeatures/accounts/hooks/queries/use-user-workspace.ts
/me/invitationsGETuseMyInvitationsfeatures/team-accounts/hooks/queries/use-my-invitations.ts
/me/invitations/[id]/acceptPOSTuseAcceptInvitationfeatures/team-accounts/hooks/mutations/use-accept-invitation.ts
/accounts/personalPATCHuseUpdatePersonalAccountfeatures/accounts/hooks/mutations/use-update-personal-account.ts
/accounts/personalDELETEuseDeletePersonalAccountfeatures/accounts/hooks/mutations/use-delete-personal-account.ts
/otp/sendPOSTuseSendOtpEmailfeatures/auth/hooks/mutations/use-send-otp-email.ts
/accounts/teamsPOSTuseCreateTeamAccountfeatures/team-accounts/hooks/mutations/use-create-team-account.ts
/accounts/teams/[slug]GETuseTeamWorkspace, useTeamPermissionsfeatures/team-accounts/hooks/queries/use-team-workspace.ts
/accounts/teams/[slug]PATCHuseUpdateTeamAccountfeatures/team-accounts/hooks/mutations/use-update-team-account.ts
/accounts/teams/[slug]/leavePOSTuseLeaveTeamAccountfeatures/team-accounts/hooks/mutations/use-leave-team-account.ts
/accounts/teams/[slug]/membersGETuseTeamMembersfeatures/team-accounts/hooks/queries/use-team-members.ts
/accounts/teams/[slug]/members/[userId]PATCH, DELETEuseUpdateMemberRole, useRemoveMemberfeatures/team-accounts/hooks/mutations/use-manage-members.ts
/accounts/teams/[slug]/invitationsGETuseTeamInvitationsfeatures/team-accounts/hooks/queries/use-team-invitations.ts
/accounts/teams/[slug]/invitationsPOSTuseInviteMembersfeatures/team-accounts/hooks/mutations/use-invite-members.ts
/accounts/teams/[slug]/invitations/[id]PATCH, DELETEuseUpdateInvitationRole, useDeleteInvitationfeatures/team-accounts/hooks/mutations/use-manage-invitations.ts
/accounts/teams/[slug]/invitations/[id]/renewPOSTuseRenewInvitationfeatures/team-accounts/hooks/mutations/use-manage-invitations.ts
/accounts/teams/[slug]/rolesGETuseAccountRolesfeatures/team-accounts/hooks/queries/use-account-roles.ts

Paths in the third column are relative to apps/native/.

Realtime

supabase.channel() works from React Native with no extra setup — the same client in apps/native/features/core/supabase.ts that handles auth and storage. Nothing in the app subscribes to it today, and the notification surface that would be its first consumer is not built. docs/native/covered-features.mdoc is the authority on what is and is not implemented.