---
title: API client
description: fetchJSON and its single-flight 401 refresh, the typed facade in api/tala.ts, and the per-feature hooks that predate it — including their known gaps.
---

## `fetchJSON`

Every request goes through one function in `src/utils/api.ts`. It attaches credentials, retries once on 401, and normalises errors.

```ts
export async function fetchJSON<T>(
  url: string,
  options: RequestInit = {},
  retryOnAuthFailure = true,
): Promise<T>;
```

What it does, in order:

1. Sets `Content-Type: application/json` when there is a string body and no explicit header.
2. Attaches `Authorization: Bearer <token>` from `localStorage`.
3. Attaches `x-organization-id` from `localStorage` when an active org is set.
4. On a 401 with a refresh token present, refreshes once and replays the request.
5. On a failed refresh, clears both tokens so the app can re-authenticate.
6. On a 2xx, returns `{}` for a 204 and the parsed JSON otherwise.
7. On any other status, throws `ApiError` carrying `status` and the parsed body.

### The retry reuses the built headers

```ts
// Reuse the headers we already built (which include Content-Type for
// JSON bodies) and just swap in the fresh token — rebuilding from
// options.headers would drop Content-Type and 400 every retried write.
headers.set("Authorization", `Bearer ${newToken}`);
return fetchJSON<T>(url, { ...options, headers }, false);
```

The `false` argument prevents an infinite retry loop. The header reuse is the subtle part — rebuilding from `options.headers` would lose the `Content-Type` that step 1 added, and every retried `POST` or `PATCH` would 400.

:::danger[A network failure does not throw `ApiError`]
The list above covers every HTTP outcome and no transport outcome. `fetch` is not wrapped in a `try`, so when the request never reaches the server — offline, DNS failure, CORS rejection, connection reset — a raw `TypeError` propagates instead.

This matters because the obvious handler silently drops those:

```ts
try {
  await projects();
} catch (error) {
  if (error instanceof ApiError) showMessage(error.body.message);
  // a TypeError lands here and produces no message at all
}
```

Always give the non-`ApiError` branch a fallback:

```ts
catch (error) {
  const message =
    error instanceof ApiError
      ? error.body?.message ?? "Something went wrong"
      : "Could not reach the server. Check your connection.";
  showMessage(message);
}
```
:::

### Refresh is single-flight

Many requests can 401 simultaneously. Only one may call the refresh endpoint, because [replaying a rotated token revokes every session](/api/authentication#rotation-is-mandatory). A module-scoped promise dedupes them, and is cleared in a `finally` so a failed refresh does not poison later attempts.

## Token storage

| Key | Holds |
| --- | --- |
| `tala_token` | access token |
| `tala_refresh_token` | refresh token |
| `tala_organization_id` | active organization |

```ts
getAccessToken();
getRefreshToken();
setTokens(accessToken?, refreshToken?);   // only writes truthy values
clearTokens();                            // clears both
getActiveOrganizationId();
setActiveOrganizationId(id | null);       // also fires tala:organization-change
```

:::danger[Always go through `setTokens()`]
It skips falsy values, so a refresh response missing one token cannot blank the other. Writing `localStorage` directly bypasses that and is how a client ends up replaying a revoked refresh token — which logs the user out everywhere.
:::

## The typed facade

`src/api/tala.ts` wraps the collaboration surface and unwraps the envelope, so callers deal in domain objects:

```ts
type Envelope<T> = { data: T; message?: string };

const api = async <T>(path: string, options?: RequestInit) =>
  (await fetchJSON<Envelope<T>>(`${API_BASE_URL}/api${path}`, options)).data;

const json = (method: string, body?: unknown): RequestInit => ({
  method,
  body: body === undefined ? undefined : JSON.stringify(body),
});
```

It exports interfaces for `Organization`, `Member`, `Invitation`, `Project`, `Task`, `TaskComment`, `Collection`, `AssetDetail`, `AssetVersion`, `Plan`, and `DashboardSummary`, plus the functions that return them.

:::warning[Two exports are dead]
`getAssetVersions` and `favoriteCollection` are both exported and imported by nothing.

`restoreAssetVersion` **is** called, so version restore ships without any way to list the versions first — the UI restores a version number the user has no way to see. `favoriteCollection` is unused because collection favourites never leave `localStorage`; see [below](#collection-favourites-disagree-with-the-server).
:::

## The hooks

| Hook | Endpoints | Notes |
| --- | --- | --- |
| `useRemoteData` | any | generic fetch-state hook; used by Home, Library, Timeline, Teams, Workspace, Invitation |
| `useCollections` | list, create, rename, delete | favourites are `localStorage`-only |
| `useCollectionDetails` | assets by collection, asset update, asset delete | sends `collectionId` on update and delete |
| `useAssetUpload` | initiate, PUT, complete, abort | 5 MB chunks, reads `ETag` per part |
| `useAssetLogs` | `GET /api/assets/:id/asset-logs` | |
| `useLogin`, `useSignUp`, `useForgotPassword`, `useResetPassword` | auth forms | call `fetchJSON` directly |

### Collection favourites disagree with the server

```ts title="src/hooks/useCollections.ts"
const toggleFavorite = async (id: string) => {
  const favs = getFavs();
  const newFavs = favs.includes(id) ? favs.filter((f) => f !== id) : [...favs, id];
  saveFavs(newFavs);
  // …no network call
};
```

`PATCH /api/collections/:id/favorite` exists and `tala.ts` exports a working `favoriteCollection()`. Neither is called. **Asset** favourites do hit the server, so the two behave differently: favourite an asset and it follows you to another device; favourite a collection and it does not.
