---
title: Management API
path: management-api
status: published
---

# Management API

The management API handles everything that is not object data: buckets, access
keys, grants, usage, and audit. It is **separate from the S3 endpoint** and is
never co-hosted with it.

- Base: `https://storey.scailabs.ai/api/v1`
- JSON in, JSON out, `snake_case` fields, RFC 3339 UTC timestamps.
- Every response carries `X-Request-Id`. Quote it when reporting a problem.

## Authentication

The console authenticates with your ScaiLabs account through a server-side
session — **no access token is ever stored in your browser**. Requests from the
console carry a session cookie plus a CSRF header.

## Pagination

Listings are cursor-based: pass `?limit=` (default 50, max 200) and follow
`next_cursor` until it is null. There is no offset pagination — bucket and
object listings are unbounded, and offsets over an unbounded set produce
duplicates and gaps under concurrent writes.

## Errors

Every error uses one shape:

```json
{
  "error": {
    "code": "BucketNotEmpty",
    "message": "This bucket still contains objects. Delete them first, then delete the bucket.",
    "request_id": "3f2b…",
    "details": null
  }
}
```

The `code` is the contract — match on it, not on the message. Messages state
what to do next and may be reworded.

| Code | Status | Meaning |
|---|---|---|
| `Unauthenticated` | 401 | No valid session |
| `Forbidden` | 403 | Your role does not allow this |
| `NotFound` | 404 | No such resource **in your tenant** |
| `NameConflict` | 409 | That name is already used |
| `BucketNotEmpty` | 409 | Delete the objects first |
| `PlacementUnavailable` | 409 | No cluster serves that region and tier |
| `EntitlementExceeded` | 409 | You are at a limit on your plan |
| `KeyClusterMismatch` | 409 | The key belongs to a different region |
| `OperationInProgress` | 409 | Still provisioning; retry shortly |
| `ValidationError` | 422 | The request was malformed |
| `RateLimited` | 429 | Slow down |
| `ClusterUnavailable` | 503 | Temporary; retry |

A resource belonging to another tenant returns **`NotFound`, never
`Forbidden`** — a 403 would confirm the id exists somewhere.

## Idempotency

Mutating requests accept an `Idempotency-Key` header. Replaying a request with
the same key returns the original result instead of acting twice. Bucket and key
creation are long-running: the response carries an `operation_id` you can poll
at `GET /v1/operations/{id}`.

## Endpoint groups

| Group | Purpose |
|---|---|
| `/v1/regions`, `/tiers`, `/placements` | What you can order |
| `/v1/tenant`, `/tenant/entitlements`, `/tenant/usage` | Your account |
| `/v1/buckets` | Bucket lifecycle, stats, usage |
| `/v1/buckets/{id}/objects` | Listing, metadata, presigned URLs, delete, copy |
| `/v1/buckets/{id}/share` | Time-boxed public links |
| `/v1/keys`, `/v1/buckets/{id}/grants` | Access keys and their grants |
| `/v1/operations`, `/v1/audit`, `/v1/events` | Progress, history, live updates |

## Objects: bytes never pass through this API

Object listing and metadata come from the management API, but the bytes do not.
Uploads and downloads use **presigned URLs** that your client sends directly to
the S3 endpoint:

```bash
curl -X POST https://storey.scailabs.ai/api/v1/buckets/$BUCKET/objects/presign \
  -H "Content-Type: application/json" \
  -d '{"key": "reports/q2.pdf", "method": "GET", "ttl_seconds": 900}'
```

That keeps a large transfer off the control plane and means a presigned request
is metered exactly like any other S3 request — there is no cheaper path.

Presigned URLs are credentials. Their lifetime is capped by your role, every
one is recorded in the audit log, and a share link can be revoked.
