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 aQueryClientProvider. The Sumvin frontend co-locates this with the JWT provider so query hooks have access to the token via useAuthEnabled().
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:
x-juno-jwt— populated from thegetToken()callbackx-sumvin-caller— populated fromoptions.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:
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:
Detail query with expand
Detail queries take an ID (and often expand[]) and bake both into the queryKey so the cache segments correctly:
The
useIPA / useIPAs hooks (and the queryKeys.ipas.* paths) are the current SDK names for Errand operations — an Errand is an agent’s scope-bound tasking run. The SDK rename to Errand is a separate effort, so these docs keep the existing hook and query-key names verbatim.!!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.
Mutation patterns
Basic mutation with invalidation
The simplest mutation callsrequest(), then invalidates affected queries on success:
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:
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.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:
| Domain | Stale time | Hook | Rationale |
|---|---|---|---|
User profile / /v0/user/me | 5 minutes | useUser | Profile fields rarely change mid-session |
Wallets / /v0/wallets/ | 5 minutes | useWallets | Wallet lists are stable; new wallets are rare |
| Wallet assets / balances | 5 minutes | useWalletAssets, useWalletBalances | Balance refresh is server-paced; clients can opt into a tighter refetchInterval per-screen |
KYC required docs / /v0/kyc/documents/required | 30 seconds | useKycRequiredDocs | Tight enough that the UI feels live during upload flows |
IPAs / /v0/user/ipa/ | 2 minutes | useIPAs, useIPA | Status transitions matter for live UX |
useWallets hook above sets staleTime: 5 * 60 * 1000. The useIPAs hook drops to 2 minutes because an Errand’s state machine moves through pending → preflight → awaiting_clarification → executing and the UI needs to feel live.
Error handling
The Platform API returns RFC 7807 Problem Details on every non-2xx response. Therequest() wrapper parses the JSON body and throws a typed 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:
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:
Always check
error_code rather than parsing detail — detail 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:
- The mutation cancels
wallets.all, not justwallets.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.
onSettledinvalidates regardless of success or failure — the invalidation is a reconciliation, not a success signal. After rollback on error, the invalidate refetches authoritative server state.
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:| Mutation | Invalidate | Why |
|---|---|---|
| Create / delete wallet | queryKeys.wallets.all | Lists, details, balances, assets are all potentially affected |
| Update wallet metadata (nickname, primary) | queryKeys.wallets.all | Setting primary cascades to other wallets in the list |
| Mutation on a single related resource | queryKeys.wallets.detail(id) | Surgical — leaves siblings cached |
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:
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 usesqueryClient.prefetchQuery wrapped in a useCallback:
onMouseEnter (links), onFocus (autocomplete suggestions), or route guards:
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.