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.
| Concern | Client | Where |
|---|---|---|
| Reading and writing your data | api() → /api/v1/* | apps/native/features/core/api-client.ts |
| Sign-in, sign-up, sign-out, session refresh | @supabase/supabase-js directly | apps/native/features/core/supabase.ts |
| MFA: enroll, challenge, verify, list factors | @supabase/supabase-js directly | supabase.auth.mfa.* |
| Uploading avatars and team logos | @supabase/supabase-js directly | supabase.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/v1handlers call the same services as web's server actions.POST /api/v1/accounts/teams/[slug]/invitationsand web'screateInvitationsActionboth end up increateAccountInvitationsService.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/teamsre-checksenableTeamAccountsandenableTeamCreation, andPOST /api/v1/accounts/teams/[slug]/leaverefuses 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.
usePersonalAccount (apps/native/features/accounts/hooks/queries/use-personal-account.ts) calls fetchPersonalAccount from @kit/accounts/shared, which selects the user's own accounts row through RLS — the same shared helper web uses. It is a rule-free read of a row the user owns, so there is nothing for a handler to enforce. Treat it as the exception, not the pattern: anything with a rule attached to it belongs behind /api/v1.
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.
| Behaviour | Detail |
|---|---|
| Base URL | process.env.EXPO_PUBLIC_API_BASE_URL, wrapped in resolveDevHost(), with /api/v1 appended before your path. |
| Auth | supabase.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. |
| Headers | Built 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. |
| Timeout | 15 seconds, via an AbortController. |
| Empty responses | A 204, or a content-length: 0, returns undefined rather than throwing on an empty JSON parse. Type it as api<undefined>(...). |
| Failures | Any 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:
| Field | Meaning |
|---|---|
status | The HTTP status. |
code | The machine-readable discriminator from the response body's error field, or 'unknown' for a non-JSON body. |
message | The 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.
| Wiring | Effect |
|---|---|
QueryCache / MutationCache onError | Any 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 predicate | Returns false for those same two codes, failureCount < 3 otherwise. A rejected token is not retried three times. |
staleTime: 60 * 1000 | The same 60-second default as web's provider, so a screen re-entered within a minute paints from cache. |
onlineManager | Driven by expo-network — Network.addNetworkStateListener, set once at module scope. React Query pauses and resumes with the device's connectivity. |
focusManager | Driven 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>.tsapps/native/features/<domain>/hooks/mutations/use-<verb>-<thing>.tsOne-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.
Databasecomes 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.teamAccountKeysandaccountKeysare 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:useMyInvitationsexportsmyInvitationsKeyforuseAcceptInvitationto invalidate. enabled: !!slugkeeps the query from firing withundefinedin the URL while no team is active. Any hook whose path depends on a value that can be absent needs this.staleTimeis 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 inuseMyInvitationsbecause 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:
bodyis stringified by the caller.api()sets the content type but does not serialize for you.onSuccessinvalidates, 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.
useUpdatePersonalAccountinvalidates bothaccountKeys.data(userId)and['user-workspace', userId], because the display name appears in the workspace switcher too. - Use
onSettledwhen a failure is also a change.useAcceptInvitationdoes, becausealready-membermeans the membership exists regardless of the 409. - The mutation is slug-scoped by argument. Passing
sluginto 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.tsimport * 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.tsimport '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: trueverifies the caller. It detectsAuthorization: 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 gets401 { error: 'unauthenticated' | 'mfa-required' }as JSON, because a native client cannot follow a redirect to a sign-in page.schemaparses the JSON body and returns400on a mismatch, sobodyarrives typed. Path segments are not validated —paramsisRecord<string, string>, so validate anything you use, as the kit's handlers do for numeric invitation ids and uuids.clientanduserare handed to you. Do not rebuild either.- Nothing else is authorized for you. RLS is the floor; a permission gate like the
has_permissioncall 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.tsimport { 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 activeexport 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.tsimport { 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, notisLoading, for a query withenabledgating: a disabled query never loads, but it is pending, so a screen keyed onisLoadingrenders an empty list before the slug arrives.apps/native/app/(home)/[account]/(tabs)/members/index.tsxgates onisPending.- A
RefreshControlon the list, driven byisFetching, giving pull-to-refresh for free. - Degrade a section, not the screen. The members screen renders
ErrorStatewhen members fail, but anInlineErrorStaterow 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
| Step | File |
|---|---|
| Table + RLS + regenerated types | apps/web/supabase/schemas/ |
| Schema and query key, both platforms import them | packages/features/<domain>/src/shared/ |
| Service holding the business rules | apps/web/lib/server/ or packages/features/<domain>/src/server/services/ |
| Route handler calling the service | apps/web/app/api/v1/**/route.ts |
| Query and mutation hooks | apps/native/features/<domain>/hooks/{queries,mutations}/ |
| Screen with loading and error states | apps/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.
| Endpoint | Method | Native hook |
|---|---|---|
/me/workspace | GET | useUserWorkspace — features/accounts/hooks/queries/use-user-workspace.ts |
/me/invitations | GET | useMyInvitations — features/team-accounts/hooks/queries/use-my-invitations.ts |
/me/invitations/[id]/accept | POST | useAcceptInvitation — features/team-accounts/hooks/mutations/use-accept-invitation.ts |
/accounts/personal | PATCH | useUpdatePersonalAccount — features/accounts/hooks/mutations/use-update-personal-account.ts |
/accounts/personal | DELETE | useDeletePersonalAccount — features/accounts/hooks/mutations/use-delete-personal-account.ts |
/otp/send | POST | useSendOtpEmail — features/auth/hooks/mutations/use-send-otp-email.ts |
/accounts/teams | POST | useCreateTeamAccount — features/team-accounts/hooks/mutations/use-create-team-account.ts |
/accounts/teams/[slug] | GET | useTeamWorkspace, useTeamPermissions — features/team-accounts/hooks/queries/use-team-workspace.ts |
/accounts/teams/[slug] | PATCH | useUpdateTeamAccount — features/team-accounts/hooks/mutations/use-update-team-account.ts |
/accounts/teams/[slug]/leave | POST | useLeaveTeamAccount — features/team-accounts/hooks/mutations/use-leave-team-account.ts |
/accounts/teams/[slug]/members | GET | useTeamMembers — features/team-accounts/hooks/queries/use-team-members.ts |
/accounts/teams/[slug]/members/[userId] | PATCH, DELETE | useUpdateMemberRole, useRemoveMember — features/team-accounts/hooks/mutations/use-manage-members.ts |
/accounts/teams/[slug]/invitations | GET | useTeamInvitations — features/team-accounts/hooks/queries/use-team-invitations.ts |
/accounts/teams/[slug]/invitations | POST | useInviteMembers — features/team-accounts/hooks/mutations/use-invite-members.ts |
/accounts/teams/[slug]/invitations/[id] | PATCH, DELETE | useUpdateInvitationRole, useDeleteInvitation — features/team-accounts/hooks/mutations/use-manage-invitations.ts |
/accounts/teams/[slug]/invitations/[id]/renew | POST | useRenewInvitation — features/team-accounts/hooks/mutations/use-manage-invitations.ts |
/accounts/teams/[slug]/roles | GET | useAccountRoles — features/team-accounts/hooks/queries/use-account-roles.ts |
Paths in the third column are relative to apps/native/.
/api/v1 is consumed by installed binaries you cannot update. Adding a field is safe within v1; removing or renaming one breaks every copy of the app in the field that reads it, so that belongs in /api/v2. apps/web/app/api/v1/AGENTS.md lists the authorization invariants to re-verify after any change to a handler's gate or a wrapped service.
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.