Downloads API
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 aDownloads.publish()method that handles MIME inference, multipart form-data, retry, and typed errors — see 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
for current artifacts.
Python#
1 | |
1 2 3 4 5 6 7 8 9 10 11 12 | |
CLI shortcut (pip install scaicms[cli]):
1 2 | |
TypeScript / JavaScript#
1 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
.NET#
1 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Side effects:
- A new asset row is created with
folder=/sdks/<product>/. - If
is_latest=true, all prior(product, language)rows are flipped tois_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.
1 2 3 4 5 | |
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:
1 2 3 4 5 6 7 | |
?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):
1 2 3 | |
?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:
1 2 3 | |
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:
1 2 3 4 5 6 | |
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):
1 2 3 4 5 6 7 8 | |
Three things teams trip on:
- cURL's
@is special in form fields. Atitleorrelease_notesthat 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-Ffor any value that might begin with@. - Use the
wwwhost. The bare apexscailabs.ai301-redirects towww, and most HTTP clients drop the multipart body when following a 301 on POST. SetDOCS_HOST=https://www.scailabs.aiand you're fine. (The SDKs default to the right host.) X-Site-IDis required — the management API is global, thedownloadcontent type lives on ScaiLabs. Without the header you get a 404 on the content-type lookup.
See also#
- API keys — how scopes get evaluated.
- Assets — where the uploaded files
physically live (S3 + the
assetstable + the/media/proxy). - The handover bundle's
api-key.md— your team's specific key + the cURL smoke test. The bundle'sproduct-team-handover.md§12 carries the team-facing how-to for publishing.