---
title: Conventions
description: The response envelope, error shapes, pagination semantics, and the scope parameter — the rules the generated reference does not state.
---

## Base URL and prefix

Every application route is prefixed with `api`. The one exception is `GET /health`, excluded in the `setGlobalPrefix()` call and therefore served at the root.

```text
http://localhost:8000/api/…      application routes
http://localhost:8000/health     liveness
http://localhost:8000/api-docs   Swagger UI
```

## The response envelope

Successful responses wrap their payload in an object with `message` and `data`:

```json title="GET /api/collections/CLa7Bk9x2Q"
{
  "message": "Collection fetched successfully",
  "data": {
    "id": "CLa7Bk9x2Q",
    "title": "Brand assets",
    "description": "Logos and wordmarks"
  }
}
```

:::note[`isFavorite` is not on this response]
It is computed by a join that only the **listing** endpoints perform — `GET /api/collections` and `GET /api/assets`. Detail reads return the raw entity, so `isFavorite` is absent rather than `false`. Treat it as optional in any shared type.
:::

Deletes return the message alone:

```json title="DELETE /api/collections/CLa7Bk9x2Q"
{ "message": "Collection deleted successfully" }
```

The SPA relies on this shape. `api/tala.ts` types it as `Envelope<T>` and unwraps `.data` for every caller:

```ts title="frontend/src/api/tala.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;
```

:::warning[Do not return a bare payload from a new endpoint]
An endpoint that returns `T` instead of `{ message, data: T }` will hand every `api<T>()` caller `undefined` with no error — the unwrap is unconditional.
:::

Two endpoints break the pattern and return `data` with no `message`:

| Endpoint | Returns |
| --- | --- |
| `POST /api/auth/refresh-token` | `{ data: { token, refreshToken } }` |
| `GET /api/auth/user` | `{ data: <user> }` |

The second matters more than it looks: `AuthProvider` calls it on every mount, so it is the most frequently hit endpoint in the app. Type an envelope as `{ data: T; message?: string }`, not as a required pair.

:::warning[`data.token` is a string on refresh and an object on login]
`POST /api/auth/refresh-token` puts the access token directly in `data.token`. `POST /api/auth/login` and `POST /api/auth/oauth/exchange` both nest it: `data.token` is `{ accessToken, refreshToken }`. Same key, two shapes, three endpoints — see [Authentication](/api/authentication#login).
:::

## Errors

Errors are **not** enveloped. They carry Nest's standard shape:

```json
{ "statusCode": 404, "message": "Collection with id CLa7Bk9x2Q not found" }
```

| Status | Raised by |
| --- | --- |
| 400 | `ValidationPipe` — a declared field failed its constraint |
| 401 | `AuthGuard` — the bearer token is missing, malformed, or expired, or the account is not `VERIFIED` |
| 403 | `OrganizationGuard` / `OrganizationRoleGuard` — no active membership, or insufficient org role |
| 404 | ownership mismatch, deliberately indistinguishable from "does not exist" |
| 409 | unhandled Postgres `23505` unique violation — **retryable** |
| 429 | `ThrottlerGuard` — over 60 requests in 60 seconds from this IP |
| 500 | anything unexpected; the real message and stack stay server-side |

:::warning[Login returns 400, not 401]
A failed login is a deliberate exception to the rule above, documented in the source. `POST /api/auth/login` answers bad credentials, an unverified account, and a suspended account all with **400**. Refresh, OAuth exchange, and `AuthGuard` still use 401. Do not write a global "401 means signed out" rule that also has to catch login.
:::

The frontend surfaces these through an `ApiError` carrying `status` and the parsed `body`:

```ts title="frontend/src/utils/api.ts"
export class ApiError extends Error {
  status: number;
  body: any;
}
```

## Pagination

Paginated endpoints take three query parameters, built by `PaginatorBuilder`:

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `page` | string → int | `0` | **Zero-based.** Clamped to ≥ 0 |
| `perPage` | string → int | `10` | Clamped to `[1, 100]` |
| `q` | string | `""` | Case-insensitive `LIKE`. Collections match on `title` and `description`; assets match on `asset_name` only |

The clamp exists so a caller cannot request `?perPage=100000000` and force a memory or database spike. A non-numeric value falls back to the default rather than erroring.

Queries carry a stable `id DESC` tiebreaker alongside their primary sort, so rows do not skip or duplicate across pages:

```ts title="backend/src/collection/repositories/collections.repository.ts"
.orderBy("collections.createdAt", "DESC")
.addOrderBy("collections.id", "DESC")
.take(paginator.perPage)
.skip(paginator.page * paginator.perPage)
```

:::danger[The envelope carries no total]
A paginated response is `{ message, data: [...] }` — there is no `total`, `pageCount`, or `hasMore`. A client cannot render "page 3 of 12" or know it has reached the end except by receiving fewer than `perPage` rows. Any real pagination UI needs a count added to the envelope first.
:::

## Scope

Listing endpoints for collections and assets take `?scope=personal|organization`, defaulting to `personal`.

```http
GET /api/collections?scope=organization&q=brand&page=0&perPage=20
GET /api/assets?scope=personal&projectId=PRa7Bk9x2Q
```

`personal` filters on the caller's `user_id`; `organization` filters on the resolved `orgContext.organizationId`. The org context is resolved by [`OrganizationGuard`](/api/organizations) regardless of scope, so a caller with no active membership gets 403 even when asking for personal rows.

`GET /api/assets` additionally accepts `projectId` and `collectionId` filters.

### `scope` is also a body field on create

Reading is not the only place scope appears. `POST /api/collections/create` takes it **in the body**, and it is the only switch that produces an organization-owned collection:

```http
POST /api/collections/create
Content-Type: application/json

{ "title": "Brand assets", "description": "Logos and wordmarks", "scope": "organization" }
```

| Field | Rules |
| --- | --- |
| `title` | required, ≤ 100 characters |
| `description` | optional, ≤ 2000 characters |
| `scope` | optional, `personal` \| `organization`, **defaults to `personal`** |

Omit `scope` and you get a personal collection every time, whatever `x-organization-id` says. `scope: "organization"` binds the row to the resolved `orgContext.organizationId` instead — and because [ownership is exclusive](/architecture/data-model#ownership-is-exclusive), that choice is permanent for the row.

Uploads use a different key for the same idea: `ownerScope` on `POST /api/upload/initiate`. See [Uploads](/api/uploads).

:::danger[An unrecognised `scope` is not rejected, and the two endpoints disagree]
Neither endpoint validates the value — there is no DTO on the query parameter, so `?scope=nonsense` returns 200. What you get back differs:

| Endpoint | Test in the service | `?scope=nonsense` returns |
| --- | --- | --- |
| `GET /api/collections` | `scope === "organization"` | **personal** collections |
| `GET /api/assets` | `scope === "personal"` | **organization** assets |

A typo in the query string therefore yields a plausible-looking, wrong result set rather than an error. Always send one of the two literals.
:::

## Idempotency and concurrency

There is no idempotency-key support. `POST /api/upload/complete` is the one write where a retry after a network failure could duplicate an asset — the client is expected to `POST /api/upload/abort` instead.

Refresh is the one endpoint with explicit replay handling, and it punishes a replay rather than ignoring it: see [token rotation](/api/authentication#rotation-is-mandatory).
