---
title: Downloads API
path: reference/api-endpoints/downloads
status: published
---

# Downloads — publish SDK releases

`POST /api/v1/downloads/{product}` and friends let a product team's CI
pipeline ship SDK artifacts to the ScaiLabs `/downloads` page using the
same `dev-<product>` API key that publishes their docs. The downloads
scope (`downloads:{read,write,delete}:<product>`) was added to every
existing dev key on 2026-06-14, so no key rotation or extra setup is
needed.

> **Prefer the official SDKs over raw `curl`.** Each SDK has a
> `Downloads.publish()` method that handles MIME inference, multipart
> form-data, retry, and typed errors — see [Use an official SDK](#use-an-official-sdk)
> below. The raw cURL pipeline still works and is documented further
> down.

## How a release is modelled

A release is **one row in the `download` content type** on the ScaiLabs
site:

| Field | Type | Notes |
|---|---|---|
| `product` | text | Required. The product slug, e.g. `scaigrid`. The scope check binds the row to this. |
| `version` | text | Required. Semver recommended (`1.2.3`, `2.0.0-rc1`). |
| `language` | text | Optional. `python`, `typescript`, `dotnet`, `go`, `rust`, `java`, `csharp`, `curl`, etc. Used as a badge, as the per-language colour on the `/downloads` page, and as the "latest" grouping key (latest is per `(product, language)` pair). |
| `file` | asset | Required. Uploaded to S3 under `/sdks/<product>/`. |
| `file_size` | number | Server-set from the uploaded file. |
| `sha256` | text | Optional. Server stores it; the page surfaces it for verification. |
| `release_notes` | markdown | Rendered in the SDK card on `/downloads`. |
| `is_latest` | boolean | Defaults to `true`; on publish, prior `(product, language)` releases are flipped to `false` (but stay listed under "older versions"). |
| `published_at` | datetime | Server-set on create. |

The `download` content type exists on ScaiLabs only. To add publishing on
another site, seed the same content type there (slug `download`) with the
field set above before issuing scopes.

## Use an official SDK

Three first-party SDKs ship with full `downloads` support. Each one has
identical resource shape — see [the SDKs section on `/downloads`](https://www.scailabs.ai/downloads)
for current artifacts.

### Python

```bash
pip install scaicms
```

```python
from scaicms import ScaiCMS

with ScaiCMS(
    base_url="https://www.scailabs.ai",
    api_key="scai_live_…",
    site_id="30847722-3fae-477a-b7ce-34e6fce390fe",
) as cms, open("dist/scaigrid-1.2.3-py3-none-any.whl", "rb") as f:
    rel = cms.downloads.publish(
        product="scaigrid", file=f, version="1.2.3",
        language="python", release_notes="- Bug fixes",
    )
print(rel.file_url)
```

CLI shortcut (`pip install scaicms[cli]`):

```bash
scaicms downloads publish scaigrid dist/scaigrid-1.2.3-py3-none-any.whl \
    --version 1.2.3 --language python
```

### TypeScript / JavaScript

```bash
npm install @scaicms/client
```

```ts
import { ScaiCMS } from "@scaicms/client";
import { readFile } from "node:fs/promises";

const cms = new ScaiCMS({
  baseUrl: "https://www.scailabs.ai",
  apiKey: process.env.SCAICMS_API_KEY,
  siteId: process.env.SCAICMS_SITE_ID,
});

const buf = await readFile("./dist/scaigrid-1.2.3.tgz");
const rel = await cms.downloads.publish({
  product: "scaigrid",
  file: { data: buf, filename: "scaigrid-1.2.3.tgz" },
  version: "1.2.3", language: "typescript",
  releaseNotes: "- Bug fixes",
});
```

### .NET

```bash
dotnet add package ScaiCMS
```

```csharp
using ScaiCMS;

using var cms = new ScaiCmsClient(new ScaiCmsOptions
{
    BaseUrl = "https://www.scailabs.ai",
    ApiKey = Environment.GetEnvironmentVariable("SCAICMS_API_KEY"),
    SiteId = Environment.GetEnvironmentVariable("SCAICMS_SITE_ID"),
});

await using var fs = File.OpenRead("./dist/ScaiGrid.1.2.3.nupkg");
var rel = await cms.Downloads.PublishAsync(
    product: "scaigrid",
    file: fs,
    filename: "ScaiGrid.1.2.3.nupkg",
    version: "1.2.3",
    language: "dotnet",
    releaseNotes: "- Bug fixes");
```

All three SDKs map `403 SCOPE_DENIED` to a typed `PermissionError` (Python),
`PermissionError` (TS), or `PermissionException` (.NET) so your pipeline
can react cleanly. The Python and .NET SDKs are sync-by-default with async
variants; the TypeScript SDK is promise-only.

## Endpoints

### `POST /api/v1/downloads/{product}`

Publish a new release. Multipart body.

| Form field | Required | Notes |
|---|---|---|
| `file` | yes | The artifact. Accepted MIME (from the global asset allowlist): `application/zip`, `application/x-tar`, `application/gzip`, `application/x-gzip`, `application/java-archive`. The endpoint additionally infers MIME from the filename when the client sends `application/octet-stream` (covers `.zip`, `.whl`, `.nupkg`, `.jar`, `.war`, `.tar`, `.gz`, `.tgz`, `.tar.gz`). Up to 100 MB by default. |
| `version` | yes | Free text. |
| `language` | no | Free text. |
| `title` | no | Display name. Default `"<Product> <language> <version>"`. |
| `release_notes` | no | Markdown. |
| `sha256` | no | Optional checksum. |
| `is_latest` | no | Defaults to `true`. Set `false` if back-publishing an old release. |

Required headers: `Authorization: Bearer <key>`, `X-Site-ID: <scailabs-site>`.

Required scope: `downloads:write:<product>` (or a wildcard such as
`downloads:*` for a global key).

Returns `201` with the created release shape:

```json
{
  "id": "8bc7…",
  "product": "scaigrid",
  "version": "2.0.0",
  "language": "python",
  "title": "Scaigrid python 2.0.0",
  "file_url": "/media/sites/<site>/assets/2026/06/<uuid>/scaigrid-2.0.0-py3-none-any.whl",
  "file_size": 1247831,
  "sha256": null,
  "release_notes": "- Bug fixes",
  "is_latest": true,
  "published_at": "2026-06-14T15:06:28.125559",
  "slug": "scaigrid-python-2-0-0",
  "status": "published"
}
```

Side effects:

- A new asset row is created with `folder=/sdks/<product>/`.
- If `is_latest=true`, all prior `(product, language)` rows are flipped to
  `is_latest=false` — they stay listed under "older versions" on the
  public page but lose the "latest" tag.

### `GET /api/v1/downloads/{product}`

List releases. Query parameters:

| Param | Notes |
|---|---|
| `language=<x>` | Filter to one language/platform. |
| `latest_only=true` | Only rows with `is_latest=true`. |

Required scope: `downloads:read:<product>`.

Returns `{"items": [...], "total": N}` with each item shaped like the POST
response. Order: newest first.

### `PATCH /api/v1/downloads/{product}/{release_id}`

In-place edit of release metadata. JSON body — any field omitted is
preserved as-is. Only these fields are mutable:

| Field | Notes |
|---|---|
| `title` | Display name on the public page. |
| `release_notes` | Markdown. |
| `sha256` | Optional checksum. |
| `is_latest` | When set to `true`, demotes any other release for the same `(product, language)` pair — same behaviour as the publish path. |

The artifact (`file`) and immutable identifiers (`product`, `version`,
`language`, `slug`, `file_size`) are deliberately **not** editable here:
republish if you need to change those.

Required scope: `downloads:write:<product>`. Returns the updated release
shape on `200`.

```bash
curl -X PATCH "https://www.scailabs.ai/api/v1/downloads/scaigrid/<release_id>" \
  -H "Authorization: Bearer $DOCS_TOKEN" \
  -H "X-Site-ID: 30847722-3fae-477a-b7ce-34e6fce390fe" \
  -H "Content-Type: application/json" \
  -d '{"title": "Cleaner name", "release_notes": "- Fix typo"}'
```

### `DELETE /api/v1/downloads/{product}/{release_id}`

By default a **soft** delete: the row is flagged `is_deleted=1` and
disappears from listings and the public `/downloads` page. The asset
stays in S3 — it's referenced by `storage_key` and can still be served
from `/media/...` for anyone who has the link.

**The slug is freed immediately on soft-delete** (as of the schema fix
on 2026-06-15). You can republish the same `(product, language, version)`
without further cleanup — the soft-deleted audit row stays in the DB
but its slug is excluded from the unique constraint via a generated
column.

`SLUG_TAKEN` (`409`) only fires when an actively-published release already
holds the slug — i.e. you're trying to publish v1.0.0/python twice without
deleting the first one. The response is:

```json
{
  "detail": {
    "code": "SLUG_TAKEN",
    "message": "A published release with slug 'scaigrid-python-1-0-0' already exists. Delete it first (DELETE /api/v1/downloads/scaigrid/<release_id>) or publish with a different version.",
    "slug": "scaigrid-python-1-0-0"
  }
}
```

#### `?hard=true` — purge the audit row too

The default soft-delete preserves the row for audit. Pass `?hard=true` to
fully remove it (the row and its `content_fields`):

```bash
curl -X DELETE "https://www.scailabs.ai/api/v1/downloads/scaigrid/<release_id>?hard=true" \
  -H "Authorization: Bearer $DOCS_TOKEN" \
  -H "X-Site-ID: 30847722-3fae-477a-b7ce-34e6fce390fe"
```

`?hard=true` works on already-soft-deleted rows too, so soft → hard
two-step cleanup is supported.

Required scope: `downloads:delete:<product>`. Returns `204` on success.

## Scope shape and enforcement

Scopes follow the same `{resource}:{action}:{product}` shape as docs:

```
downloads:read:scaigrid
downloads:write:scaigrid
downloads:delete:scaigrid
```

Wildcards work the same way too — `downloads:*` is the equivalent of
"all download actions, any product." Sub-products inherit when the
group's docs scopes cover them: `dev-scaigrid` holds the matching
`downloads:read:scaibot`, `downloads:write:scaibot`, `downloads:delete:scaibot`
on top of the scaigrid set, because that's how its docs scopes were
arranged. (The three actions are stored as three separate entries rather
than a single `downloads:*:scaibot` — they're equivalent at check time
but the literal form is the per-action one.)

If a key tries to publish for a product it doesn't cover, the endpoint
returns `403`:

```json
{
  "detail": {
    "code": "SCOPE_DENIED",
    "message": "API key scope does not permit 'write' on downloads for product 'scaicore'"
  }
}
```

## How releases appear on the public page

`/downloads` (on ScaiLabs) renders an `sdk_downloads` block that groups
releases by product, then by language. The newest release per
`(product, language)` is the **featured card**, with the language's
canonical colour, a tag like `PY`/`TS`/`.NET`/`GO`, the version,
download CTA, and the release notes you supplied. Prior releases collapse
into a "*N older versions*" disclosure below the card.

Nothing on the publishing side controls this layout — you write release
metadata, the template pack handles the visual.

## Cheatsheet — the raw cURL pipeline

If you can't use the SDK (CI environment without Python/Node/.NET, or
you're calling from a shell script):

```bash
curl -X POST "https://www.scailabs.ai/api/v1/downloads/scaicms" \
  -H "Authorization: Bearer $DOCS_TOKEN" \
  -H "X-Site-ID: 30847722-3fae-477a-b7ce-34e6fce390fe" \
  -F "file=@dist/scaicms-1.2.3-py3-none-any.whl" \
  -F "version=1.2.3" \
  -F "language=python" \
  --form-string "release_notes=- Bug fixes" \
  -F "sha256=$(sha256sum dist/scaicms-1.2.3-py3-none-any.whl | awk '{print $1}')"
```

Three things teams trip on:

- **cURL's `@` is special in form fields.** A `title` or `release_notes`
  that starts with `@` makes cURL read it as a filename. Use
  `--form-string title="@latest beta"` (or
  `-F "title=<dash>"` with the value piped in) instead of `-F` for any
  value that might begin with `@`.
- **Use the `www` host.** The bare apex `scailabs.ai` 301-redirects to `www`,
  and most HTTP clients drop the multipart body when following a 301 on
  POST. Set `DOCS_HOST=https://www.scailabs.ai` and you're fine. (The
  SDKs default to the right host.)
- **`X-Site-ID` is required** — the management API is global, the
  `download` content type lives on ScaiLabs. Without the header you get a
  404 on the content-type lookup.

## See also

- [API keys](/docs/scaicms/concepts/api-keys) — how scopes get evaluated.
- [Assets](/docs/scaicms/concepts/assets) — where the uploaded files
  physically live (S3 + the `assets` table + the `/media/` proxy).
- The handover bundle's `api-key.md` — your team's specific key + the
  cURL smoke test. The bundle's `product-team-handover.md` §12 carries
  the team-facing how-to for publishing.
