Skip to main content

Overview

Sumvin’s web app integrates with the Platform API through TanStack Query. The hook patterns documented here are lifted from production code and cover the recommended way to consume /v0/* endpoints from a React app: stale-while-revalidate caching, non-retryable error gating, optimistic mutations with rollback, and a query-key invalidation pyramid. If you’re integrating from React, copy these patterns. If you’re integrating from another stack, the underlying conventions — RFC 7807 errors, idempotency keys, x-juno-jwt auth — are documented in the authentication guide.
All examples use real exports from the production frontend. Hook names, query-key paths, and the request() wrapper signature match src/lib/api/hooks/ 1:1.

Setup

Wrap the app in a QueryClientProvider. The Sumvin frontend co-locates this with the JWT provider so query hooks have access to the token via useAuthEnabled().
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { JWTProvider } from '@/lib/auth/jwt-provider';

export function Providers({ children }: { children: React.ReactNode }) {
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <JWTProvider>
      <QueryClientProvider client={client}>{children}</QueryClientProvider>
    </JWTProvider>
  );
}
The request() wrapper centralises auth, error parsing, and request deduplication. It expects four positional arguments — method, endpoint, options, getToken — and throws a typed ApiError on non-2xx responses:
// src/lib/api/core/client.ts (simplified)
export async function request<T>(
  method: HttpMethod,
  endpoint: string,
  options?: RequestOptions<T>,
  getToken?: () => string | null
): Promise<T>;
The wrapper sets two headers automatically:
  • x-juno-jwt — populated from the getToken() callback
  • x-sumvin-caller — populated from options.caller, used for backend telemetry. Format: frontend:<resource>:<action> (e.g. frontend:wallets:update)
useAuthEnabled() is the small hook that gates queries on JWT readiness:
// src/lib/auth/use-auth-enabled.ts
export function useAuthEnabled(options?: UseAuthEnabledOptions): UseAuthEnabledResult {
  const { getToken, token, isLoading: isTokenLoading } = useJWT();
  const enabled = !!token && !isTokenLoading && (options?.enabled ?? true);
  return { enabled, getToken };
}
Use it in every query that requires auth — without the gate, queries fire before the JWT is ready and get a 401 on every cold load.

Query patterns

Basic list query

useWallets is the canonical exemplar. It pulls a token, gates on auth, calls request() with a typed response, and configures stale-while-revalidate plus retry behaviour:
'use client';

import { useQuery } from '@tanstack/react-query';
import { useAuthEnabled } from '@/lib/auth/use-auth-enabled';
import { request } from '../../core/client';
import { isNonRetryable } from '../../errors';
import { queryKeys } from '../../query-keys';
import type { WalletFilters, WalletListResponse } from '../../types/wallet';

export function useWallets(filters?: WalletFilters) {
  const { getToken, enabled } = useAuthEnabled();

  return useQuery({
    queryKey: queryKeys.wallets.list(filters),
    queryFn: () =>
      request<WalletListResponse>(
        'GET',
        '/v0/wallets/',
        {
          params: buildWalletParams(filters),
          caller: 'frontend:wallets',
        },
        getToken
      ),
    enabled,
    staleTime: 5 * 60 * 1000,
    retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 3,
  });
}

Detail query with expand

Detail queries take an ID (and often expand[]) and bake both into the queryKey so the cache segments correctly:
export function useIPA(id: string | null) {
  const { getToken, token, isLoading: isTokenLoading } = useJWT();

  return useQuery({
    queryKey: queryKeys.ipas.detail(id ?? ''),
    queryFn: () =>
      request<IPADetailResponse>(
        'GET',
        `/v0/user/ipa/${id}`,
        { caller: 'frontend:ipas' },
        getToken
      ),
    enabled: !!token && !isTokenLoading && !!id,
    staleTime: 2 * 60 * 1000,
    retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 3,
  });
}
Note the !!id clause in enabled — when id is null (e.g. a detail page with no selection), the query stays idle instead of firing against /v0/user/ipa/null.
When passing expand arrays through the API, include them in the query key (queryKeys.user.me(['wallets', 'kyc_documents'])). Different expand sets are different cache entries.

Mutation patterns

Basic mutation with invalidation

The simplest mutation calls request(), then invalidates affected queries on success:
export function useCreateWallet() {
  const queryClient = useQueryClient();
  const { getToken } = useJWT();

  return useMutation({
    mutationFn: (data: WalletCreateRequest) =>
      request<WalletResponse>(
        'POST',
        '/v0/wallets/',
        { body: data, caller: 'frontend:wallets:create' },
        getToken
      ),
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: queryKeys.wallets.all });
    },
  });
}

Forwarding Idempotency-Key

For non-idempotent endpoints (IPA creation, payment-link creation, transaction submission), pass the idempotency key through options.headers. The request() wrapper merges it into the request headers verbatim. Adapt this shape on top of any existing mutation hook that targets a non-idempotent endpoint:
// Illustrative — wrap an existing create mutation to accept an idempotency key
// from the caller and forward it as a header on the request().
export function useCreateIPAWithIdempotency() {
  const queryClient = useQueryClient();
  const { getToken } = useJWT();

  return useMutation({
    mutationFn: ({
      data,
      idempotencyKey,
    }: {
      data: CreateIPARequest;
      idempotencyKey: string;
    }) =>
      request<IPADetailResponse>(
        'POST',
        '/v0/user/ipa/',
        {
          body: data,
          caller: 'frontend:ipa:create',
          headers: { 'Idempotency-Key': idempotencyKey },
        },
        getToken
      ),

    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: queryKeys.ipas.all });
      toast.success('Purchase authorisation created');
    },

    retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 2,
  });
}
The production useCreateIPA hook does not currently take an idempotency key — the snippet above shows the recommended shape for callers that need 208-Already-Reported semantics on retry. The request() wrapper accepts arbitrary headers via options.headers.
Generate the idempotency key once per logical request — typically when the user opens the form, not when they click submit. Re-generating on submit defeats the purpose: a duplicate submit produces a fresh key, and the backend creates a duplicate resource.

Stale-while-revalidate

staleTime is how long a cached response is considered fresh. While fresh, hooks return cached data instantly without a network request. Once stale, the next mount or window-focus triggers a background refetch — but the cached value is still served immediately. Pick a value based on how fast the underlying data actually changes server-side:
DomainStale timeHookRationale
User profile / /v0/user/me5 minutesuseUserProfile fields rarely change mid-session
Wallets / /v0/wallets/5 minutesuseWalletsWallet lists are stable; new wallets are rare
Wallet assets / balances5 minutesuseWalletAssets, useWalletBalancesBalance refresh is server-paced; clients can opt into a tighter refetchInterval per-screen
KYC required docs / /v0/kyc/documents/required30 secondsuseKycRequiredDocsTight enough that the UI feels live during upload flows
IPAs / /v0/user/ipa/2 minutesuseIPAs, useIPAStatus transitions matter for live UX
The useWallets hook above sets staleTime: 5 * 60 * 1000. The useIPAs hook drops to 2 minutes because IPA state machines move through pending → preflight → awaiting_clarification → executing and the UI needs to feel live.
If a screen needs second-by-second freshness (e.g. a balance display during a transfer), set staleTime: 0 on that specific call rather than dropping the global default — most queries don’t need it.

Error handling

The Platform API returns RFC 7807 Problem Details on every non-2xx response. The request() wrapper parses the JSON body and throws a typed ApiError:
export interface ProblemDetail {
  type: string;
  title: string;
  status: number;
  detail: string | ValidationError[];
  instance: string;
  error_code: string;
  trace_id?: string;
}

export class ApiError extends Error {
  constructor(
    public status: number,
    public problem: ProblemDetail,
    public isNetworkError = false,
    public isAlreadyReported = false
  ) {
    super(formatDetail(problem.detail));
    this.name = 'ApiError';
  }
}

isNonRetryable — gate retries by status code

Retrying a 4xx is wasted bandwidth and a worse user experience: 401/403 won’t change without a new token, 404/409/422 won’t change without a different request body. The isNonRetryable helper is the single source of truth for which status codes should bypass the retry loop:
export function isNonRetryable(error: unknown): boolean {
  if (error instanceof CircuitOpenError) return true;
  if (error instanceof ApiError) {
    return [400, 401, 403, 404, 409, 422].includes(error.status);
  }
  return false;
}
Wire it into every query and mutation that retries:
retry: (failureCount, error) => !isNonRetryable(error) && failureCount < 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 8000),
This caps retries at 3 attempts with exponential backoff (1s, 2s, 4s, max 8s). Mutations typically use a lower failureCount < 2 ceiling.

Surfacing error_code to the UI

The error_code field on ProblemDetail is stable across versions and safe to map to user-facing copy:
function WalletErrorBanner({ error }: { error: unknown }) {
  if (!(error instanceof ApiError)) return null;
  const code = error.problem.error_code;

  if (code === 'WAL-403-003') {
    return <Banner>This wallet credential is not verified on your account.</Banner>;
  }
  if (code === 'WAL-409-001') {
    return <Banner>This wallet is already registered to another user.</Banner>;
  }
  return <Banner>{error.message}</Banner>;
}
Always check error_code rather than parsing detaildetail is a free-form string (or a list of validation errors) and not safe to switch on.

Optimistic mutations

Optimistic mutations apply the expected change to the cache immediately, before the network round-trip resolves. The user sees zero latency on rename, set-primary, and delete operations. The pattern is three-step: onMutate (cancel + snapshot + apply), onError (rollback from snapshot), onSettled (invalidate to reconcile). useUpdateWallet is the cleanest exemplar in the codebase:
export function useUpdateWallet() {
  const queryClient = useQueryClient();
  const { getToken } = useJWT();

  return useMutation({
    mutationFn: ({ walletId, ...data }: WalletUpdateRequest & { walletId: string }) =>
      request<WalletResponse>(
        'PATCH',
        `/v0/wallets/${walletId}`,
        { body: data, caller: 'frontend:wallets:update' },
        getToken
      ),

    onMutate: async ({ walletId, is_primary, nickname }) => {
      // 1. Cancel any in-flight refetches that would clobber our optimistic write
      await queryClient.cancelQueries({ queryKey: queryKeys.wallets.all });

      // 2. Snapshot the current cache so onError can roll back
      const previous = queryClient.getQueryData<WalletListResponse>(queryKeys.wallets.list());

      // 3. Apply the optimistic update
      if (previous && (is_primary !== undefined || nickname !== undefined)) {
        queryClient.setQueryData<WalletListResponse>(queryKeys.wallets.list(), {
          ...previous,
          wallets: previous.wallets.map((w) => {
            if (w.id !== walletId) {
              // Setting a new primary demotes all other wallets
              return is_primary ? { ...w, is_primary: false } : w;
            }
            return {
              ...w,
              ...(is_primary !== undefined && { is_primary }),
              ...(nickname !== undefined && { nickname }),
            };
          }),
        });
      }

      return { previous };
    },

    onError: (_err, _vars, context) => {
      // Roll back to the snapshot if the request failed
      if (context?.previous) {
        queryClient.setQueryData(queryKeys.wallets.list(), context.previous);
      }
    },

    onSettled: () => {
      // Always reconcile against the server, success or failure
      queryClient.invalidateQueries({ queryKey: queryKeys.wallets.all });
    },
  });
}
A few non-obvious details in this hook:
  • The mutation cancels wallets.all, not just wallets.list() — pending detail-query refetches need to be cancelled too, otherwise they’ll overwrite the optimistic write when they resolve.
  • The optimistic update demotes peers when promoting a primary — the backend enforces “only one primary wallet” so the optimistic update mirrors that invariant. Without this, the UI briefly shows two primaries.
  • onSettled invalidates regardless of success or failure — the invalidation is a reconciliation, not a success signal. After rollback on error, the invalidate refetches authoritative server state.
Optimistic updates are only safe when the client can correctly predict the server response. For mutations that depend on server-side computation (e.g. promoting a wallet to primary may trigger a 202 Accepted Safe creation flow), don’t optimistically update fields the server controls.

Invalidation strategy

Query keys are structured as a pyramid: a domain root, narrower list keys, narrowest detail keys. Invalidation targets the appropriate level so the cache stays coherent without overfetching. The wallets domain illustrates the structure:
// src/lib/api/query-keys.ts
export const queryKeys = {
  wallets: {
    all: ['wallets'] as const,
    list: (filters?: WalletFilters) => ['wallets', 'list', filters] as const,
    detail: (externalId: string) => ['wallets', 'detail', externalId] as const,
    balances: (walletExternalId: string) => ['wallets', 'balances', walletExternalId] as const,
    assets: (walletExternalId: string) => ['wallets', 'assets', walletExternalId] as const,
    assetTransactions: (walletExternalId: string, assetSymbol: string) =>
      ['wallets', 'assets', walletExternalId, assetSymbol, 'transactions'] as const,
  },
  // ...
} as const;
Invalidation uses prefix matching, so the call site picks the broadest key that’s still correct:
MutationInvalidateWhy
Create / delete walletqueryKeys.wallets.allLists, details, balances, assets are all potentially affected
Update wallet metadata (nickname, primary)queryKeys.wallets.allSetting primary cascades to other wallets in the list
Mutation on a single related resourcequeryKeys.wallets.detail(id)Surgical — leaves siblings cached
The useUpdateWallet hook above invalidates wallets.all because promoting a wallet to primary changes the is_primary flag on every wallet in the list, not just the target. A nickname-only edit could safely scope to wallets.detail(id), but the broader invalidate is harmless and keeps the hook simple. For mutations that affect multiple domains, invalidate each:
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: queryKeys.ipas.all });
  queryClient.invalidateQueries({ queryKey: queryKeys.agentTasks.all });
  toast.success('Purchase authorisation created');
},
useCreateIPA invalidates both ipas.all (list page should show the new IPA) and agentTasks.all (the originating agent task picks up a new linked IPA).

Prefetching

For predictable navigation paths — hovering a list row, focusing a search result — prefetch the detail query so the destination renders cache-hot. The pattern uses queryClient.prefetchQuery wrapped in a useCallback:
export function usePrefetchUser() {
  const queryClient = useQueryClient();
  const { getToken } = useAuthEnabled();

  return useCallback(
    (options?: UseUserOptions) => {
      return queryClient.prefetchQuery({
        queryKey: queryKeys.user.me(options?.expand),
        queryFn: () =>
          request<GetUserResponse>(
            'GET',
            '/v0/user/me',
            {
              ...(options?.expand?.length ? { params: { expand: options.expand } } : {}),
              caller: 'frontend:user',
            },
            getToken
          ),
        staleTime: 5 * 60 * 1000,
      });
    },
    [queryClient, getToken]
  );
}
Wire it to onMouseEnter (links), onFocus (autocomplete suggestions), or route guards:
function ProfileLink() {
  const prefetch = usePrefetchUser();
  return (
    <Link href="/profile" onMouseEnter={() => prefetch({ expand: ['wallets'] })}>
      View profile
    </Link>
  );
}
Prefetching respects staleTime — a prefetch against a fresh cache entry is a no-op, so it’s safe to wire it aggressively without worrying about request storms.