This is the canonical guide for the browser-side API layer in RemitWise.
Primary page-level CTAs and main flow submit or confirm actions expose stable data-testid hooks via lib/cta-testids.ts. Use those hooks for browser automation and analytics instrumentation instead of visible button text when the button is in the shared primary-CTA scope.
Use it when you are adding or reviewing client code that talks to /api/*. The goal is simple: authenticated browser requests should go through the shared client helpers so session refresh, expiry handling, and logout stay consistent across the app.
Use apiClient for authenticated browser requests to RemitWise API routes.
Use raw fetch only when one of these is true:
- The request is public and does not need session expiry handling.
- The code runs on the server, in a route handler, or in a server action.
- You are calling a third-party service rather than the app's own API routes.
- You intentionally do not want the shared retry and
401 -> refresh -> retry oncebehavior.
Related modules:
lib/client/apiClient.ts: shared request wrapper.lib/client/sessionHandler.ts: session-expiry detection, refresh, and redirect flow (exportsSIGN_IN_PATH,getSignInUrl(),sessionHandler).lib/client/useSessionExpiry.ts: hook that turns window events into UI state.components/SessionExpiryProvider.tsx: mounts the notification globally.lib/client/logout.ts: logout helper and post-auth redirect helper.
Real call sites in the repo:
app/send/page.tsx: authenticatedPOSTwithnullhandling and API error handling.app/bills/page.tsx: parallel authenticatedGETrequests.lib/hooks/useFormAction.ts: generic form submission wrapper.components/WalletButton.tsxandcomponents/Nav/MobileNav.tsx: logout entry points.app/layout.tsx: globalSessionExpiryProvidermount point.
For widget-style read surfaces that should stay inline while transient failures clear, use lib/client/widgetFetchRetry.ts on top of apiClient. That helper retries failed reads up to 3 times with exponential backoff before the UI shows its inline retry CTA.
apiClient exposes:
apiClient.request(url, options?)apiClient.get(url, options?)apiClient.head(url, options?)apiClient.post(url, options?)apiClient.put(url, options?)apiClient.patch(url, options?)apiClient.delete(url, options?)apiClient.getJson<T>(url, options?)
ApiClientOptions extends RequestInit and adds:
retries?: number— max retry attempts for idempotent (GET/HEAD) requests. Default3. Ignored for writes.backoff?: number— base backoff in ms; doubles each attempt and is jittered. Default1000.timeout?: number— per-request timeout in ms before the request is aborted. Default10000. Pass0to disable.
Return type:
Promise<Response | null>for the verb helpers andrequest.Promise<T | null>forgetJson<T>(parsed/validated body, ornullon session expiry).
Interpret the result like this:
Response: the request completed and did not enter the terminal session-expiry flow.null: the session-expiry flow already ran, local auth state was cleared, and the UI was notified and scheduled for redirect. Callers should stop work and not show a duplicate auth error.
Every request (regardless of method) runs under a per-request timeout enforced
with an AbortController. The default is 10000 ms and is configurable via the
timeout option (0 disables it). When the timeout fires, the in-flight fetch
is aborted. For idempotent requests this counts as a retryable failure; once
retries are exhausted (or for writes) the call rejects with a TimeoutError
DOMException.
The timeout is composed with any caller-supplied signal, so a component
unmount or useFormAction's latest-wins abort still cancels the request
immediately (see Aborting requests).
Before session-expiry logic runs, apiClient uses fetchWithRetry.
Defaults:
retries = 3(idempotent methods only)backoff = 1000timeout = 10000
Automatic retries happen only for idempotent methods (GET and HEAD), on:
- HTTP
5xx - HTTP
429 - Rejected fetches such as network failures and timeouts
Write methods (POST, PUT, PATCH, DELETE) are never auto-retried, even
on 5xx/429/network errors. This is deliberate: replaying a write could cause
a double-submit (e.g. sending a remittance twice). Writes still get a timeout;
they just fail fast instead of retrying.
Backoff is exponential with jitter: the delay for each retry is
base * 2^attempt, of which half is fixed and half is randomized ("equal
jitter") so many clients don't reconverge on the server. When a response carries
a Retry-After header (e.g. on 429), that value is honored instead of the
computed backoff (clamped to 30s).
A caller-initiated abort is never retried — it rejects immediately.
Responses such as 400, 403, and non-expiry 401 values are returned to the
caller without this retry loop treating them as session failures.
Note: this is the browser client. Do not confuse it with the server-side RPC retry in
lib/soroban/client.ts; the two layers are independent and should not be stacked on the same call path.
getJson<T>(url, options?) is a convenience wrapper over get for JSON reads:
- Sends an idempotent
GET(with the shared timeout + retry behavior). - Returns
nullif the session-expiry flow ran. - Throws
ApiClientErrorif the response is not OK or the body is not valid JSON. - Optionally runs a
validate(data) => Tfunction (e.g. a Zod schema'sparse) and returns the typed, validated result.
import { apiClient, ApiClientError } from '@/lib/client/apiClient';
const insights = await apiClient.getJson('/api/insights', {
validate: (raw) => InsightsSchema.parse(raw),
});
if (insights === null) return; // session-expiry flow already handled
// insights is typed and validated hereFor authenticated browser requests, the session flow is:
apiClientsends the request.- If the final response is not a session-expiry response, it is returned unchanged.
sessionHandler.isSessionExpired()treats a response as expired only when:status === 401- the JSON body contains
message === "Session expired"
- On the first expired response,
apiClientcallssessionHandler.refreshSession(). refreshSession()sendsPOST /api/auth/refresh.- If refresh succeeds,
apiClientreplays the original request once. - If refresh fails, or the replayed request is still an expired-session
401,sessionHandler.handleSessionExpiry()runs andapiClientreturnsnull.
Important details:
- Retry-once semantics are enforced with an internal
_isRetryflag. The original request is replayed at most once after refresh. - Concurrent
401responses share one in-flight refresh request.sessionHandler.refreshSession()memoizes the active refresh promise so only one/api/auth/refreshcall is made at a time. - Each waiting request still retries its own original request once after the shared refresh resolves successfully.
- Refresh failure does not call the
logout()helper. It runs the session-expiry handler directly, which clears local client auth state, emits the expiry event, stores a post-auth redirect path, and schedules a redirect to the sign-in page (/) with the current route preserved in?next=.
apiClient does not parse JSON or normalize error payloads for you. It returns the raw Response so callers should handle three cases:
response === nullThe session-expiry flow already started. Do not show a duplicate "please log in" error.!response.okThe request reached the API, but the route returned an error status. Parse the body and surface the route-specific error.catch (error)Fetch rejected after retries were exhausted, usually due to network failure or another transport-level error.
Pattern used in app/send/page.tsx:
try {
const response = await apiClient.post('/api/send', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipient, amount, currency }),
});
if (response === null) return;
const data = await response.json();
if (!response.ok || !data.success) {
// Show API-level error from route
return;
}
// Success path
} catch {
// Show network / transport error
}import { apiClient } from '@/lib/client/apiClient';
export async function loadBills() {
try {
const response = await apiClient.get('/api/bills');
if (response === null) return null;
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new Error(errorBody?.message || 'Failed to load bills');
}
return await response.json();
} catch (error) {
throw new Error(
error instanceof Error ? error.message : 'Network error while loading bills'
);
}
}import { apiClient } from '@/lib/client/apiClient';
export async function createGoal(payload: { name: string; targetAmount: number }) {
try {
const response = await apiClient.post('/api/goals', {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response === null) return { kind: 'session-expired' as const };
const data = await response.json().catch(() => null);
if (!response.ok) {
return {
kind: 'api-error' as const,
message: data?.message || 'Unable to create goal',
};
}
return { kind: 'success' as const, data };
} catch {
return {
kind: 'network-error' as const,
message: 'Network error. Please try again.',
};
}
}Pattern used in app/bills/page.tsx:
const [billsRes, statsRes] = await Promise.all([
apiClient.get('/api/bills'),
apiClient.get('/api/bills/total-unpaid'),
]);
if (!billsRes || !statsRes) return;
if (!billsRes.ok || !statsRes.ok) {
throw new Error('Failed to load bills data');
}The UI side of expiry handling is event-driven.
app/layout.tsx mounts components/SessionExpiryProvider.tsx near the top of the client tree.
The provider:
- calls
useSessionExpiry() - renders
components/SessionExpiryNotification.tsx - passes through the current phase, message, countdown, and actions
The notification has two phases:
warningexpired
useSessionExpiry() listens for three window events:
session-expiringsession-expiredsession-refresh
Behavior by event:
session-expiringSetsphasetowarning, shows the message, and starts a one-second countdown.session-expiredSetsphasetoexpiredimmediately and stops any active countdown.session-refreshClears the local warning/expired UI state.
Current contract to know:
staySignedIn()dispatchessession-refreshand resets the local notification state.- In the current codebase, the hook/provider do not themselves call
/api/auth/refreshwhen that event fires. - The automatic refresh request currently happens in
apiClientafter an expired-session401, not in the proactive warning UI path.
So if you add proactive "session about to expire" server support, you must also add a listener that turns the warning-phase session-refresh event into a real refresh request.
Expired flow:
- An authenticated request returns
401withmessage: "Session expired". apiClientattempts one refresh.- If refresh cannot recover the request,
sessionHandler.handleSessionExpiry()clears local auth state. handleSessionExpiry()storesredirect_after_authwhen the current path is not/.- It dispatches
session-expired. - The provider shows the expired notification.
- A redirect to the sign-in page with
?next=<current_route>is scheduled after 15 seconds (e.g./?next=%2Fdashboard).
Warning flow:
- Some caller dispatches
session-expiring, usually throughsessionHandler.dispatchSessionExpiring(countdown, message). - The provider shows the warning notification and countdown.
- Clicking "Stay signed in" clears the notification state locally by dispatching
session-refresh. - If the countdown reaches zero first, the hook transitions the UI into the expired state.
logout() in lib/client/logout.ts is the explicit sign-out helper. It is used by the wallet/menu UI.
Contract:
POST /api/auth/logout- Clear local auth state with
sessionHandler.clearAuthState()even if the request fails - Redirect with
window.location.href, defaulting to/
clearAuthState() currently removes these localStorage keys:
wallet_addresswallet_connectedauth_state
handleSessionExpiry() additionally stores:
redirect_after_auth
and redirects to /?next=<encoded_current_path> (via getSignInUrl()).
Use getPostAuthRedirect() after a successful wallet reconnect or login to read and clear that stored path.
Example:
import { getPostAuthRedirect } from '@/lib/client/logout';
const redirectPath = getPostAuthRedirect();
router.push(redirectPath || '/dashboard');If /api/auth/refresh fails or returns a non-OK status, apiClient does not retry the original request again. It runs handleSessionExpiry() and returns null.
The original request is replayed at most once after a successful refresh. A second expired-session 401 on the replay triggers the terminal expiry flow.
You can pass an AbortSignal through ApiClientOptions because the options extend RequestInit.
Current implementation detail:
- The caller's
signalis combined with the internal per-request timeout signal, so either source can cancel the in-flight fetch. - A caller-initiated abort fails fast: it is surfaced to the caller immediately
and is never retried, regardless of
retries. - A timeout abort, by contrast, is treated as a retryable failure for idempotent
GET/HEADrequests.
This makes apiClient safe to use with abort-on-unmount patterns such as
useFormAction, where the latest submit aborts the previous in-flight request.
Multiple requests can discover an expired session at the same time. They share a single refresh attempt, but each request still replays itself once after that shared refresh succeeds.
Before opening a PR for client-side API work:
- Use
apiClientfor authenticated browser requests. - Handle
null,!response.ok, and thrown transport errors separately. - Avoid page-specific session-expiry toasts or redirects when
apiClientalready owns that flow. - Use
logout()for explicit sign-out buttons. - If you surface warning-phase expiry, make sure the provider path is wired for a real refresh request if that is part of your feature.