---
title: REST API reference
path: reference/rest-api
status: published
---

# REST API reference

Complete reference for the ScaiAtlas REST API. All routes are under `/api/v1`.

## Base URL

```
https://api.scaiatlas.io/api/v1
```

Interactive OpenAPI docs (Swagger UI at `/docs`, ReDoc at `/redoc`) are available on
deployments running in debug mode.

## Authentication

Send a bearer token on every request (except the public auth endpoints noted below):

```
Authorization: Bearer <token>
```

Tokens prefixed `sk_` are API keys; other tokens are validated as ScaiKey JWTs.
Missing or invalid credentials return `401` with `WWW-Authenticate: Bearer`. See
[Authentication](/docs/scaiatlas/concepts/authentication) for the permission model.

## Pagination

List endpoints accept `page` (≥1) and `page_size` (1–100) query parameters and return
a `data` array alongside flat pagination fields:

```json
{
  "data": [ /* … */ ],
  "page": 1,
  "page_size": 20,
  "total": 137,
  "total_pages": 7
}
```

The Python SDK wraps this as `PaginatedResponse[T]`: iterate `.items` (an alias for
`data`) or use the `list_all()` / `*_all()` helpers to walk every page. Its
`.pagination` object surfaces `page`, `per_page` (= `page_size`), `total_count`
(= `total`), `total_pages`, and computed `has_next` / `has_prev`.

## Errors

Errors return an appropriate 4xx/5xx status and a JSON body:

```json
{ "error": { "code": "ATLAS_001", "message": "Human-readable message", "field": "name" } }
```

See [Errors](/docs/scaiatlas/reference/errors) for the full list.

## Conventions

- A version path segment may be the literal `latest` to resolve the highest semver.
- `201 Created` for creates, `202 Accepted` for async work (variant conversion), `204 No Content` for deletes.

---

## Namespaces

Base: `/api/v1/namespaces`

| Method | Path | Summary | Permission |
|---|---|---|---|
| GET | `/namespaces` | List accessible namespaces | authenticated |
| POST | `/namespaces` | Create a namespace | authenticated |
| GET | `/namespaces/{namespace}` | Get namespace details | `view` |
| PATCH | `/namespaces/{namespace}` | Update namespace | `update_namespace` |
| DELETE | `/namespaces/{namespace}` | Delete namespace | `delete_namespace` |
| GET | `/namespaces/{namespace}/members` | List members | `manage_members` |
| POST | `/namespaces/{namespace}/members` | Add a member | `manage_members` |
| PATCH | `/namespaces/{namespace}/members/{member_id}` | Update a member's role | `manage_members` |
| DELETE | `/namespaces/{namespace}/members/{member_id}` | Remove a member | `manage_members` |
| GET | `/namespaces/{namespace}/my-permissions` | Your effective permissions | authenticated |

**List** accepts a `visibility` filter (`public`\|`tenant`\|`private`). Results are
limited to public namespaces plus those in your tenant.

**Create body** (`NamespaceCreate`):

```json
{ "name": "poolnoodle", "display_name": "Pool Noodle", "description": "…", "visibility": "tenant" }
```

- `name` — 2–64 chars, `^[a-z0-9][a-z0-9-]*[a-z0-9]$` (required)
- `display_name` — ≤255 chars, optional
- `description` — optional
- `visibility` — `public`\|`tenant`\|`private` (default `tenant`)

Returns `201` with a **Namespace**. The creator becomes `owner`. `409` if the name exists.

**Namespace object:**

| Field | Type |
|---|---|
| `id` | string |
| `name` | string |
| `display_name` | string \| null |
| `description` | string \| null |
| `visibility` | `public`\|`tenant`\|`private` |
| `owner_tenant_id` | string |
| `model_count` | int |
| `created_at`, `updated_at` | datetime |

**PATCH body** (`NamespaceUpdate`): `display_name`, `description`, `visibility` (all optional).
**DELETE**: `204`; `409` if the namespace still has models.

**Members.** Add-member body (`NamespaceMemberCreate`): exactly one of `user_id` /
`group_id`, plus `role` (`owner`\|`admin`\|`member`\|`viewer`). A **NamespaceMember**
is `{ id, user: UserSummary|null, group: GroupSummary|null, role, granted_by, granted_at }`
where `UserSummary = {id, email, display_name, tenant_id}` and
`GroupSummary = {id, name, tenant_id}`. The last owner cannot be demoted/removed (`400`).

**my-permissions** returns `{ role, permissions: [...], inherited_from: [{type, role, group_id, group_name}] }`.

---

## Models

Base: `/api/v1/namespaces/{namespace}/models`

| Method | Path | Summary | Permission |
|---|---|---|---|
| GET | `/models` | List models | `view` |
| POST | `/models` | Create a model | `create_model` |
| GET | `/models/{model}` | Get model | `view` |
| PATCH | `/models/{model}` | Update model | `update_own_model` (or `update_any_model`) |
| DELETE | `/models/{model}` | Delete model | `delete_own_model` (or `delete_any_model`) |

**List** accepts a `type` filter (a [model type](/docs/scaiatlas/reference/formats#model-types)).
Returns `ModelSummary` items.

**Create body** (`ModelCreate`):

```json
{
  "name": "noodletron",
  "type": "llm",
  "display_name": "Noodletron",
  "description": "…",
  "license": "apache-2.0",
  "capabilities": { "context_length": 8192 },
  "modalities": { "input": ["text"], "output": ["text"] },
  "metadata": { "params": "8B" },
  "tags": ["demo"]
}
```

- `name` — 1–128 chars, `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` (required)
- `type` — model type (**required**)
- `display_name` (≤255), `description`, `license` (≤64), `capabilities` (object), `modalities` (`{input[], output[]}`), `metadata` (object), `tags` (string[]) — all optional

Returns `201` with a **Model**. `409` if the name exists in the namespace.

**Model object:** `id`, `namespace`, `name`, `display_name`, `description`, `type`,
`license`, `capabilities`, `modalities`, `metadata`, `tags`, `version_count`,
`download_count`, `created_by`, `created_at`, `updated_at`. (`ModelSummary` is a
lighter projection used in list/search: `id`, `namespace`, `name`, `display_name`,
`type`, `license`, `modalities`, `tags`, `latest_version`, `download_count`, timestamps.)

**PATCH body** (`ModelUpdate`): `display_name`, `description`, `license`,
`capabilities`, `modalities`, `metadata`, `tags` — all optional.

---

## Versions

Base: `/api/v1/namespaces/{namespace}/models/{model}/versions`

| Method | Path | Summary | Permission |
|---|---|---|---|
| GET | `/versions` | List versions (semver desc) | `view` |
| POST | `/versions` | Create a version (starts upload) | `upload_version` |
| GET | `/versions/{version}` | Get version (`latest` supported) | authenticated |
| PATCH | `/versions/{version}` | Update version | `update_own_model` (or any) |
| DELETE | `/versions/{version}` | Delete version | `delete_own_model` (or any) |

**Create body** (`VersionCreate`):

```json
{
  "version": "1.0.0",
  "format": "safetensors",
  "release_notes": "Initial release.",
  "requirements": { "transformers": ">=4.40" },
  "quantization": null,
  "files": [ { "path": "model.safetensors", "size": 1234567, "checksum_sha256": "…" } ]
}
```

- `version` — semver `^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$` (required)
- `format` — 1–32 chars, validated against active formats, lowercased (required)
- `release_notes`, `requirements` (object), `quantization` — optional
- `files` — list of `{ path, size, checksum_sha256 }`

Returns `201` with a **Version** (`total_size` summed from files, checksum `pending`
until finalized). `400` for an unsupported format, `409` if the version exists.

**Version object:** `id`, `model_id`, `namespace`, `model`, `version`, `full_name`
(`namespace/model:version`), `version_major`/`minor`/`patch`, `version_prerelease`,
`release_notes`, `requirements`, `format`, `quantization`, `files[]` (each `{path,
size, checksum_sha256}`, also exposed as `name`/`sha256`), `total_size`,
`checksum_sha256`, `status`, `variant_count`, `created_by`, `created_at`,
`updated_at`, `published_at`.

**PATCH body** (`VersionUpdate`): `release_notes`, `status`
([version status](/docs/scaiatlas/reference/formats#version-status)) — optional.

---

## Variants

Base: `/api/v1/namespaces/{namespace}/models/{model}/versions/{version}/variants`

| Method | Path | Summary |
|---|---|---|
| GET | `/variants` | List variants |
| POST | `/variants` | Request a variant conversion (`202`) |
| GET | `/variants/{variant_name}` | Get a variant |
| DELETE | `/variants/{variant_name}` | Delete a variant |

**Create body** (`VariantCreate`):

```json
{ "variant_name": "gguf-q4_k_m", "format": "gguf", "quantization": "Q4_K_M" }
```

- `variant_name` — ≤64 chars (required)
- `format` — `safetensors`\|`pytorch`\|`gguf`\|`onnx`\|`other` (required)
- `quantization` — ≤32 chars, optional

Returns `202 Accepted` with a **Variant** in `pending` status. `409` if the variant
name exists; deleting the **primary** variant returns `400`.

**Variant object:** `id`, `version_id`, `variant_name`, `format`, `quantization`,
`total_size`, `checksum_sha256`, `is_primary`, `status`
([variant status](/docs/scaiatlas/reference/formats#variant-status)), `files[]`
(`{path, size, checksum_sha256}`), `created_at`.

---

## Upload

Base: `…/versions/{version}/upload` — multipart, proxied to storage. Requires
`upload_version`.

| Method | Path | Body | Returns |
|---|---|---|---|
| POST | `/upload/init` | `UploadInit` | `UploadInitResponse` |
| PUT | `/upload/{upload_id}/parts/{part_number}` | raw bytes | `{ part_number, etag }` |
| GET | `/upload/{upload_id}/parts` | — | `{ upload_id, parts: [...] }` |
| POST | `/upload/{upload_id}/complete` | `UploadComplete` | `{ status: "complete", upload_id }` |
| DELETE | `/upload/{upload_id}` | — | `204` (abort) |

- `UploadInit` = `{ file_path, file_size (≥1), checksum_sha256 (64 hex), content_type }`
- `UploadInitResponse` = `{ upload_id, file_id, part_size, part_count }`
- `UploadComplete` = `{ parts: [ { part_number, etag } ] }`

`init` returns `400` if the version isn't in `uploading` state.

---

## Download

Base: `…/versions/{version}/download` — proxied streaming. Requires `download`.

| Method | Path | Summary |
|---|---|---|
| GET | `/download` | File metadata + URLs for the version |
| GET | `/download/files/{file_path}` | Stream a file (Range-aware) |

- `/download` query: `variant` (name), `file` (specific path). Returns `{ version_id, variant, total_size, file_count, files: [{path, size, checksum_sha256, url}] }`.
- `/download/files/{path}` query: `variant`. Honors `Range: bytes=start-end` (→ `206 Partial Content`, `Content-Range`); sets `X-Checksum-SHA256`, `Accept-Ranges: bytes`. `416` for an unsatisfiable range.

The version/variant must be in an `available`/`ready` state (`400` otherwise).

---

## Manifests (P2P)

Base: `…/versions/{version}/manifest`

| Method | Path | Summary |
|---|---|---|
| GET | `/manifest` | Get the distribution manifest |
| POST | `/manifest` | Generate it (query `chunk_size`, default 64 MB) → `201` |

**DistributionManifest:** `{ version_id, variant_id, chunk_size, total_size,
total_chunks, checksum_sha256, files: [ { path, size, checksum_sha256, chunks: [ {
index, offset, size, checksum_sha256 } ] } ] }`. `GET` returns `404` until generated;
`POST` returns `409` if it already exists.

---

## Search

Base: `/api/v1/search`

| Method | Path | Query |
|---|---|---|
| GET | `/search/models` | `q`, `type`, `namespace`, `tag`, `sort` (`updated`\|`created`\|`name`\|`downloads`), `order` (`asc`\|`desc`), pagination |
| GET | `/search/namespaces` | `q`, pagination |

Model search returns `ModelSummary` items; namespace search returns lightweight
`{id, name, display_name, description, visibility}` objects. Both are scoped to public
+ same-tenant results.

---

## Options

Base: `/api/v1/options` — the catalog of what the deployment supports.

| Method | Path | Summary |
|---|---|---|
| GET | `/options/formats` | Active formats |
| GET | `/options/quantizations` | Active quantizations (filter `format_name`) |

`Format` = `{ id, name, display_name, description, file_extensions[], is_active,
created_at, updated_at }`. `Quantization` = `{ id, name, display_name, description,
format_id, bits, is_active, created_at, updated_at }`.

---

## P2P

Base: `/api/v1/p2p` — returns `503` if P2P is disabled on the deployment.

| Method | Path | Summary |
|---|---|---|
| POST | `/p2p/announce` | Announce peer availability |
| GET | `/p2p/peers` | List peers (`active_only`, pagination) |
| GET | `/p2p/peers/by-version` | Peers for a `version_id` (+ `variant_id`) |
| GET | `/p2p/stats` | Tracker statistics |
| DELETE | `/p2p/peers/{node_id}` | Remove a peer |

`by-version` returns `{ version_id, variant_id, total_peers, seeders, leechers, peers:
[ { node_id, address, port, is_seeding, chunks_available, chunks_total, last_seen } ] }`.

---

## Auth

Base: `/api/v1/auth`. Endpoints marked **public** need no token.

| Method | Path | Auth | Summary |
|---|---|---|---|
| GET | `/auth/config` | public | OAuth config for clients |
| GET | `/auth/me` | required | Current user info |
| POST | `/auth/token` | public | Exchange auth code for tokens (PKCE) |
| POST | `/auth/refresh` | public | Refresh an access token |
| POST | `/auth/revoke` | public | Revoke a token |
| POST | `/auth/cli/init` | public | Start a device-flow session |
| GET | `/auth/cli/status` | public | Poll device-flow status |
| GET | `/auth/cli/session` | public | Look up a session by user code |
| POST | `/auth/cli/authorize` | required | Authorize a device-flow session from the browser |

`GET /auth/me` → `{ id, email, display_name, tenant_id, permissions[], groups[] }`.

---

## Admin

Base: `/api/v1/admin`. Elevated permissions required (tiered admin; formats/quantizations
require super-admin).

| Area | Representative endpoints |
|---|---|
| Stats | `GET /admin/stats` — dashboard counters |
| Formats | `GET/POST /admin/formats`, `GET/PATCH/DELETE /admin/formats/{id}` |
| Quantizations | `GET/POST /admin/quantizations`, `PATCH/DELETE /admin/quantizations/{id}` |
| API keys | `GET/POST /admin/api-keys`, `GET/PATCH/DELETE /admin/api-keys/{id}` |
| Users | `GET /admin/users`, `GET /admin/users/{id}`, `GET/POST/DELETE …/{id}/permissions[/…]` |
| Groups | `GET /admin/groups`, `GET /admin/groups/{id}`, `GET …/{id}/members` |

Creating an API key (`POST /admin/api-keys`) returns the plaintext `key` **once**.
Key bodies support `permission_mode` (`explicit`\|`inherited`\|`restricted`),
`linked_user_id`, `namespace_ids`, and `expires_in_days` (1–365).

---

## Access logs

`GET /api/v1/access-logs` — filter by `model_id`, `user_id`, `action`, plus pagination;
ordered newest-first. Non-super-admins see only their own tenant's logs. Each entry:
`{ id, model_id, version_id, user_id, tenant_id, action, ip_address, user_agent,
created_at, model_name, user_email }`.

---

## Files (filesystem backend)

When the deployment uses filesystem storage, byte transfer happens through
short-lived **signed URLs** (no bearer token; HMAC signature + expiry):

- `PUT /api/v1/files/part?upload_id=…&part_number=…&expires=…&signature=…` — upload a part.
- `GET /api/v1/files/{key}?expires=…&signature=…` — download a file.

(The same routes are also mounted under `/api/v1/upload`.) Invalid/expired signatures
return `403`.
