API Client

createApiClient()

createApiClient is the low-level factory. In a normal app, LimestoneProvider wires it automatically from your defineConfig. Call createApiClient directly only in scripts or custom setups.

import { createApiClient, createStore } from '@objectifthunes/limestone-sdk';
import { createExpoSecureStore } from '@objectifthunes/limestone-sdk/expo-secure-store';

const store = createStore({ auth: { status: 'idle', user: null, tokens: null }, network: { connected: true, type: 'wifi' }, mutations: [] });
const storage = createExpoSecureStore();

const client = createApiClient({
  baseUrl: 'https://api.example.com',
  store,
  storage,
  auth: {
    refreshEndpoint: '/auth/refresh',
    onUnauthorized: () => router.push('/login'),
  },
  retry: {
    maxAttempts: 3,
    backoff: 'exponential',
    backoffDelayMs: 500,
  },
  offline: { queueMutations: true },
  headers: { 'X-App-Version': '2.0.0' },
  timeout: 10_000,
  fetchFn: globalThis.fetch,
});

Config reference

FieldTypeRequiredDescription
baseUrlstringYesPrepended to every path
storeStore<AppState>YesSource of auth tokens and network state
storageSecureStorageProviderNoRequired for offline mutation queue
auth.refreshEndpointstringNoPath to call for token refresh
auth.onUnauthorized() => voidNoCalled when refresh fails or is not configured
retry.maxAttemptsnumberNoTotal attempts including first. Default 1 (no retry)
retry.backoff'exponential' | 'linear' | 'none'NoDelay strategy between attempts
retry.backoffDelayMsnumberNoBase delay in ms
offline.queueMutationsbooleanNoQueue POST/PUT/PATCH/DELETE when offline
headersRecord<string, string>NoDefault headers on every request
timeoutnumberNoAbort after N milliseconds
fetchFntypeof globalThis.fetchNoInjectable for tests

Typed methods

All methods are generic. The resolved type is the response body.

// GET
const user = await client.get<User>('/users/me');

// POST
const session = await client.post<Session>('/sessions', {
  body: { email, password },
  skipAuth: true,     // omit Bearer header for this request
});

// PUT
await client.put<User>('/users/me', { body: { name: 'Alice' } });

// PATCH
await client.patch<Post>('/posts/1', { body: { title: 'Updated' } });

// DELETE
await client.delete('/posts/1');

RequestOptions

Pass as the second argument to any method.

FieldTypeDescription
bodyunknownSerialized as JSON
headersRecord<string, string>Merged with default headers
skipAuthbooleanOmit the Authorization header
signalAbortSignalFor manual cancellation

Auth lifecycle

When auth.refreshEndpoint is configured:

  1. Every request automatically receives Authorization: Bearer <accessToken> from store.auth.tokens.
  2. If the server returns 401, the client calls refreshEndpoint with the current refresh token.
  3. The access token in the store is updated.
  4. The original request is replayed once with the new token.
  5. If refresh fails, onUnauthorized is called and the error is thrown.
// Skip auth header for login and registration endpoints
await client.post('/sessions', { body: { email, password }, skipAuth: true });
await client.post('/users', { body: { email, password }, skipAuth: true });

GET request deduplication

Concurrent identical GET requests share one in-flight promise. The second and subsequent callers wait for the first and receive the same response.

// These three calls fire simultaneously — only ONE fetch goes to the network
const [a, b, c] = await Promise.all([
  client.get<User>('/users/me'),
  client.get<User>('/users/me'),
  client.get<User>('/users/me'),
]);
// a === b === c (same resolved object)

Deduplication is keyed on the path plus any per-request headers. A different headers value produces a separate in-flight entry.

Offline mutation queue

When offline.queueMutations: true and storage is provided, any POST, PUT, PATCH, or DELETE made while the device is offline is serialized to SecureStorageProvider instead of being dropped.

// Network is down — this enqueues rather than throwing
await client.post('/events', { body: { name: 'page_view' } });
// Returns undefined immediately; resolves when flushed

The queue is flushed automatically when the network reconnects. Each queued mutation is a QueuedMutation:

interface QueuedMutation {
  method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
  path: string;
  body?: Record<string, unknown>;
  headers?: Record<string, string>;
}

Retry with backoff

Configure retry to retry failed requests (network errors or 5xx responses).

const client = createApiClient({
  baseUrl: 'https://api.example.com',
  store,
  retry: {
    maxAttempts: 3,      // 1 initial + 2 retries
    backoff: 'exponential',
    backoffDelayMs: 500, // 500ms, 1000ms, 2000ms
  },
});
StrategyDelay formula
exponentialbackoffDelayMs * 2^(attempt - 1)
linearbackoffDelayMs * attempt
none0ms — retries fire immediately

useApi() hook

useApi returns the ApiClient wired by LimestoneProvider. It throws if api was not included in the config.

import { useApi } from '@objectifthunes/limestone-sdk';

function ProfileScreen() {
  const api = useApi();
  const [user, setUser] = React.useState<User | null>(null);

  React.useEffect(() => {
    api.get<User>('/users/me').then(setUser);
  }, [api]);

  return <Text>{user?.name ?? 'Loading...'}</Text>;
}

Error handling

A non-2xx response throws an AppError-compatible error with a status property. Use isAppError to narrow in catch blocks.

import { isAppError } from '@objectifthunes/limestone-sdk';

try {
  const user = await client.get<User>('/users/me');
} catch (e) {
  if (isAppError(e)) {
    console.error(e.code, e.message);
  }
}