---
title: Storage API
description: REST API reference for ZevCloud Storage — plans, buckets, objects, custom domains.
---

Endpoints for managing ZevCloud Storage from your own code. Every request requires the `x-team-id` header identifying the team the request operates on. Authentication uses the same bearer token scheme as the rest of the [Services API](/api/services). See **[Authentication](/api/auth)** for token mechanics and scopes.

## Scopes

- `read` — list plans, list regions, list buckets, get bucket detail, browse objects, mint download URLs.
- `full` — create buckets, delete buckets, mint upload URLs, delete objects, rotate keys, switch plans, attach custom domains.

Owner and admin roles can call any endpoint within scope. Member role can list and read but cannot fetch raw credentials or write objects.

## Plans

### List plans

```
GET /v1/storage/plans
```

Returns the active storage plans available to the team. Includes per-plan storage cap (`storageBytesCap`), Naira price (`priceNgnMonthly`), whether the plan allows public-read buckets (`allowsPublicRead`), and the plan's slug for use in switch/upgrade calls.

```json
[
  {
    "id": "...",
    "slug": "free",
    "name": "Free",
    "storageBytesCap": "...",
    "priceNgnMonthly": 0,
    "isFree": true,
    "allowsPublicRead": false
  }
]
```

### List regions

```
GET /v1/storage/regions
```

Returns regions the team can pick from when creating a bucket. Each entry has a region code (`region`) and a human-readable label (`label`).

## Subscription

### Get current subscription

```
GET /v1/storage/subscription
```

Returns the team's current storage subscription with current usage, role-derived capabilities for the caller, and any delinquency state. Returns `null` if the team has no subscription yet — call **auto-provision** to create the Free-plan subscription.

```json
{
  "subscription": { "status": "active", "planId": "..." },
  "plan": { "slug": "starter", "allowsPublicRead": true },
  "usage": { "bytesUsed": "...", "bucketCount": 3 },
  "delinquency": { "state": "current" },
  "role": "owner",
  "capabilities": {
    "canCreateBuckets": true,
    "canViewCredentials": true
  }
}
```

### Auto-provision Free plan

```
POST /v1/storage/subscription/auto-provision
```

Idempotent. Materializes the Free plan subscription for the team if they don't already have one. Used by the dashboard's first-time empty state.

### Switch plan (Free-to-Free or free downgrade)

```
POST /v1/storage/subscription/switch
```

```json
{
  "planSlug": "free"
}
```

Owner-only. Completes synchronously. Use **upgrade** instead for paid plans, which goes through invoice settlement.

### Upgrade subscription

```
POST /v1/storage/subscription/upgrade
```

```json
{
  "planSlug": "starter",
  "applyCredit": true
}
```

Owner-only. Creates a draft invoice for the target plan, attaches a payment method, and returns `{ invoiceId, paymentUrl, paymentMethod, action }`. The subscription is not changed yet — the plan flip happens when the invoice settles.

For a Free-plan switch this endpoint completes synchronously with `action: "switched"` and no invoice.

### Check switch eligibility

```
GET /v1/storage/subscription/switch-eligibility?planSlug=starter
```

Returns whether a plan switch is currently allowed, with structured reasons if not (`reason`, `message`, `blockers`, `cooldownHoursRemaining`). Useful for disabling destinations in a UI before submit.

## Buckets

### Check name availability

```
GET /v1/storage/buckets/check-availability?suffix=uploads
```

Live-feedback helper for create forms. Returns `{ available, reason, fullName }`. `reason` is one of:

- `ok` — the clean name `{prefix}{suffix}` is free.
- `taken_globally_will_append_tail` — another team has the clean name; create will succeed but append a unique tail.
- `taken_in_team` — your team already has a bucket with that suffix.

### List buckets

```
GET /v1/storage/buckets
```

Returns every bucket on the team with status, region, size, and a `canViewCredentials` flag derived from the caller's role.

### Create bucket

```
POST /v1/storage/buckets
```

```json
{
  "nameSuffix": "uploads",
  "region": "...",
  "projectId": "...",
  "isPublicRead": false,
  "objectLockEnabled": false
}
```

Owner or admin. `projectId` optionally links the bucket to one of the team's projects for organisational purposes. `isPublicRead` requires a plan with `allowsPublicRead: true`. `objectLockEnabled` is permanent on the bucket once set.

The response includes the new bucket plus an `accessKeyId` and a one-time `secretAccessKey`. **The secret is only returned in this response.** Subsequent reads do not include it.

### Get bucket detail

```
GET /v1/storage/buckets/:bucketId
```

Returns the bucket row, the access key ID (owners and admins only), the bucket endpoint, and effective serving state:

```json
{
  "id": "...",
  "fullName": "...",
  "region": "...",
  "status": "active",
  "isPublicRead": true,
  "endpoint": "...",
  "accessKeyId": "...",
  "canViewCredentials": true,
  "effectivePublicRead": true,
  "publicUrl": "...",
  "planAllowsPublicRead": true
}
```

The secret access key is **not** included on this read. Use **rotate** to get a fresh one.

`effectivePublicRead` combines the bucket's own flag with the team's plan capability — a public-read bucket on a downgraded plan returns `effectivePublicRead: false` and `publicUrl: null` until the team is back on a plan that allows it.

### Refresh usage

```
POST /v1/storage/buckets/:bucketId/refresh-usage
```

Forces an immediate recalculation of the bucket's `bytesUsed`. Useful after large external changes to surface the new size in the dashboard right away rather than waiting for the periodic refresh.

### Set bucket visibility

```
PATCH /v1/storage/buckets/:bucketId/visibility
```

Owner or admin. Flips an existing bucket between public-read and private. The response is the full bucket-detail shape so the caller can drop it into local state without an extra GET.

```json
{
  "isPublicRead": true
}
```

Enabling public-read (`isPublicRead: true`) requires a plan with `allowsPublicRead`. On Free plans this returns a 400 with an upgrade hint and the bucket is unchanged.

The endpoint wires both sides of the gate: the bucket row's `isPublicRead` column (what the dashboard renders) and the underlying gateway's website-access flag (what the public URL actually serves). Disabling is best-effort on the gateway side — the DB flag flips even if the gateway call hiccups, with a log warning, so the customer's intent is honored. Enabling fails atomically (DB stays as-is) if the gateway can't be flipped, so we never advertise a URL that won't serve.

Public URLs become available immediately at `<publicUrl>` from the response when `effectivePublicRead` is true.

### Delete bucket

```
DELETE /v1/storage/buckets/:bucketId
```

Owner or admin. Drains all objects synchronously then removes the bucket. Long-running on large buckets — set generous client timeouts.

### Rotate key

```
POST /v1/storage/buckets/:bucketId/keys/rotate
```

Owner or admin. Returns the new credential pair. **The secret is shown once.** The previous key remains valid for a 24-hour grace period before automatic revocation.

```json
{
  "accessKeyId": "...",
  "secretAccessKey": "...",
  "previousKeyExpiresAt": "..."
}
```

### Retry provisioning

```
POST /v1/storage/buckets/:bucketId/retry
```

Only valid on buckets in `error` status. Re-runs provisioning against the same row, preserving the original name. On success the bucket flips to `active` and the response includes a fresh secret access key.

### List connected services

```
GET /v1/storage/buckets/:bucketId/connected-services
```

Returns the services on the team that have this bucket wired in as managed environment variables.

## Objects

### List objects

```
GET /v1/storage/buckets/:bucketId/objects?prefix=uploads/&delimiter=/
```

Lists objects in a bucket using S3 prefix + delimiter semantics. Pass `delimiter=/` to get back `folders` (common prefixes) alongside `objects`. Page through results with `continuationToken`.

Query parameters:

| Parameter | Required | Default | Notes |
|-----------|----------|---------|-------|
| `prefix` | No | empty | Filter to keys starting with this prefix |
| `delimiter` | No | `/` | Folder simulation. Pass `delimiter=` (empty) for a flat listing |
| `continuationToken` | No | — | From the previous page's response |
| `maxKeys` | No | 1000 | Max objects per page |

Response:

```json
{
  "objects": [
    {
      "key": "uploads/avatar.png",
      "size": 12345,
      "lastModified": "...",
      "etag": "..."
    }
  ],
  "folders": ["uploads/2026/"],
  "continuationToken": null,
  "isTruncated": false
}
```

### Mint upload URL

```
POST /v1/storage/buckets/:bucketId/objects/upload-url
```

```json
{
  "key": "uploads/avatar.png",
  "contentType": "image/png"
}
```

Owner or admin. Returns a presigned PUT URL the browser can upload to directly, plus the expiration timestamp.

```json
{
  "url": "...",
  "method": "PUT",
  "headers": { "Content-Type": "image/png" },
  "expiresAt": "..."
}
```

Default URL lifetime is 1 hour. Use the returned headers exactly as given — adding extra signed headers on the client will break the signature.

For larger upload volumes or full S3 protocol features (multipart uploads, advanced ACLs), use the bucket's access key directly with an S3 SDK instead.

### Mint download URL

```
GET /v1/storage/buckets/:bucketId/objects/download-url?key=uploads/avatar.png
```

Any team member. Returns a presigned GET URL for a single object. Default lifetime 1 hour; max 24 hours.

```json
{
  "url": "...",
  "method": "GET",
  "expiresAt": "..."
}
```

### Delete object

```
DELETE /v1/storage/buckets/:bucketId/objects?key=uploads/avatar.png
```

Owner or admin. Idempotent — does not error on a missing key.

## Custom domains

### List eligible managed domains

```
GET /v1/storage/domains/managed-eligible
```

Returns the ZevCloud-registered domains on the team that can be attached as a subdomain to any bucket. Team-scoped; not specific to a particular bucket. Use this to populate a domain picker before attaching.

### List bucket domains

```
GET /v1/storage/buckets/:bucketId/domains
```

Returns the custom domains attached to a bucket along with the platform's `cnameTarget` value:

```json
{
  "domains": [
    {
      "id": "...",
      "hostname": "assets.example.com",
      "status": "active",
      "verifiedAt": "...",
      "lastCheckedAt": "..."
    }
  ],
  "cnameTarget": "..."
}
```

Status is one of `pending_verification`, `active`, or `error`.

### Attach a domain

```
POST /v1/storage/buckets/:bucketId/domains
```

Two body shapes:

**External domain** (you manage DNS):

```json
{
  "hostname": "assets.example.com"
}
```

The row starts in `pending_verification` status. The response includes the CNAME target to point the hostname at. Trigger **verify** after pointing the CNAME.

**Managed domain** (registered through ZevCloud):

```json
{
  "managedRegistrationId": "...",
  "subdomain": "assets"
}
```

The CNAME is written to your DNS automatically and the row is `active` immediately.

Either path: the bucket must be public-read AND on a plan that allows public-read. Owner or admin only.

### Verify a pending domain

```
POST /v1/storage/buckets/:bucketId/domains/:domainId/verify
```

Triggers a verification check on a `pending_verification` domain. The platform fetches a verification token at `https://{hostname}/.well-known/zevcloud-verify/{token}`; the domain flips to `active` if it round-trips. Rate-limited.

### Detach a domain

```
DELETE /v1/storage/buckets/:bucketId/domains/:domainId
```

Owner or admin. Removes the domain from the bucket. Traffic on the hostname stops resolving to the bucket immediately. You can leave the CNAME pointing at the platform target afterwards — nothing breaks — or remove it on your DNS side.

## Connecting a bucket to a service (env-vars API)

The bucket-to-service connect flow lives under the env-vars API rather than under `/storage`. Documented in full at **[Environment Variables](/api/env-vars)**; the two relevant endpoints are:

- `POST /v1/services/:serviceId/env/connect-bucket` — body `{ bucketId }`. Injects the five managed S3 env vars onto the service.
- `DELETE /v1/services/:serviceId/env/connect-bucket/:bucketId` — removes them.

The managed env vars stay synchronized with bucket key rotations automatically — no extra call needed.
