---
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.

For anything that is not a person — a CI job, an orchestrator, a provisioning
tool — use a **service token**.

### Service tokens

A service token is a bearer credential that holds Storey roles of its own:

```bash
curl -H "Authorization: Bearer $STOREY_TOKEN" \
  https://storey.scailabs.ai/api/v1/buckets
```

They are **not S3 access keys**, and the distinction is worth keeping straight:
an access key signs requests against object data, a service token calls this API
and can create or delete a bucket. A service token gives no access to object
bytes at all.

What to expect:

| | |
|---|---|
| Shape | `stk_<prefix>_<secret>` |
| Shown | Once, in the create response. Storey stores only a hash and has no endpoint that returns it again |
| Scope | One or more roles, each at its own scope — a single tenant, a partner, or global |
| Expiry | Optional for a tenant- or partner-scoped token; a global token always has one (90 days by default, one year at most) |
| Revocation | Immediate — the next request with it fails, rather than waiting for expiry |
| Audit | Calls are attributed to the token's label and prefix, never to a person |

The `prefix` is not secret. It appears in listings and in audit entries, which is
what lets you identify a token after the secret is gone — and what makes the
label worth choosing carefully.

**Tenant context.** If a token can reach more than one tenant, say which one a
request is about:

```
X-Storey-Tenant: <tenant slug>
```

Without it you get `422`. A token that can reach exactly one tenant needs no
header. Naming a tenant the token cannot reach returns `404`, not `403` — the
same rule as every other identifier.

**If a token leaks**, revoke it and mint a replacement. There is no rotation in
place for a service token, because there is nothing to preserve: the token *is*
the credential, and a new one with the same grants is a two-minute change in one
config file.

Service tokens are managed by ScaiLabs platform operators. Ask for one, saying
what it is for, which tenant it needs, and the narrowest role that does the job.

## 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 |
| `/v1/tokens` | Service tokens (operator-managed) |

## 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.
