Storage API

REST endpoints for the three-step upload flow, presigned download URLs, custom metadata, file listing and deletion, and the admin bucket CRUD surface.

Two API surfaces

Storage exposes two parallel surfaces with different intended callers and different authentication.

SDK surface — /v1/sdk/storage/*

Called from koolbase_flutter and @koolbase/react-native in end-user apps. Authenticated with the project's public API key (x-api-key). Scope-limited to the requesting project; subject to per-bucket limits and access rules.

Admin surface — /v1/projects/{id}/buckets/...

Called from the dashboard and management tooling. Authenticated with a session Bearer token. Used for bucket CRUD, browsing files across any bucket, and admin-side mutations the SDK doesn't expose.

SDK base URL & auth

Base URL: https://api.koolbase.com/v1/sdk

Required headers:
  x-api-key: pk_live_your_public_key_here

SDK endpoints

POST/sdk/storage/upload-urlGet a presigned R2 upload URL
POST/sdk/storage/confirmConfirm upload and record metadata
GET/sdk/storage/download-urlGet a download URL for a file
GET/sdk/storage/listList files in a bucket
DELETE/sdk/storage/objectDelete a file
PATCH/sdk/storage/objects/metadataAdd, change, or remove custom metadata

POST /sdk/storage/upload-url

// Request
{
  "bucket": "avatars",
  "path": "user_abc123.jpg",
  "content_type": "image/jpeg",
  "size": 204800,
  "overwrite": false
}

// Response 200
{
  "upload_url": "https://r2.example.com/presigned?...",
  "expires_in": 300
}

After receiving the URL, PUT the file bytes directly to upload_url. The URL expires in 300 seconds.

Safe-by-default uploads

overwrite defaults to false. A request to a bucket+path that already holds a file is rejected with path_conflict (409) rather than silently replacing data. Pass overwrite: true when you intend to replace.

Server-side bucket limits are checked at this step. If the bucket has a configured max_size_bytes, max_file_size_bytes, or allowed_mime_types, the upload-url request fails before R2 ever sees the bytes — quota_exceeded (409), file_too_large (413), or mime_not_allowed (415) respectively. size is required when any size limit is configured on the bucket.

POST /sdk/storage/confirm

// Request — call after successful PUT to R2
{
  "bucket": "avatars",
  "path": "user_abc123.jpg",
  "content_type": "image/jpeg",
  "size": 204800,
  "etag": "9e107d9d372bb6826bd81d3542a419d6",
  "metadata": {
    "user_id": "usr_a4f9b21c",
    "orig_filename": "IMG_0942.HEIC"
  }
}

// Response 201
{
  "id": "uuid",
  "bucket": "avatars",
  "path": "user_abc123.jpg",
  "content_type": "image/jpeg",
  "size": 204800,
  "metadata": {
    "user_id": "usr_a4f9b21c",
    "orig_filename": "IMG_0942.HEIC"
  },
  "created_at": "2026-01-01T00:00:00Z"
}

metadata is optional — omit it for no metadata. When present, it's subject to the same validation rules as Custom Metadata (≤50 keys, ≤8KB total, keys [a-z0-9_]+, values ≤1024 chars, no leading underscore). A validation failure returns metadata_invalid (400).

Confirm must be called within the upload grace window (~1 hour after the presigned URL was issued). After that the orphan reaper sweeps the R2 object on its next pass, and a late confirm returns upload_expired (410). The server also re-HEADs R2 to verify the ETag matches what was uploaded — any mismatch returns validation_error (400).

GET /sdk/storage/download-url

// Query params: ?bucket=avatars&path=user_abc123.jpg

// Response 200
{
  "url": "https://pub-xxx.r2.dev/user_abc123.jpg"
}

GET /sdk/storage/list

// Query params: ?bucket=avatars&prefix=user_

// Response 200
{
  "files": [
    {
      "path": "user_abc123.jpg",
      "size": 204800,
      "content_type": "image/jpeg",
      "metadata": { "user_id": "usr_a4f9b21c" },
      "created_at": "2026-01-01T00:00:00Z"
    }
  ]
}

DELETE /sdk/storage/object

// Request body
{ "bucket": "avatars", "path": "user_abc123.jpg" }

// Response 204 No Content

PATCH /sdk/storage/objects/metadata

// Request — merge semantics
//   string value → set or update the key
//   null         → delete the key
//   omitted      → leave the existing key untouched
{
  "bucket": "avatars",
  "path": "user_abc123.jpg",
  "metadata": {
    "alt_text": "New caption",
    "orig_filename": null
  }
}

// Response 200 — full updated object
{
  "id": "uuid",
  "bucket": "avatars",
  "path": "user_abc123.jpg",
  "metadata": {
    "user_id": "usr_a4f9b21c",
    "alt_text": "New caption"
  },
  "size": 204800,
  "content_type": "image/jpeg",
  "created_at": "2026-01-01T00:00:00Z",
  "updated_at": "2026-01-02T00:00:00Z"
}

See Custom Metadata for the full conceptual reference, validation rules, and SDK code examples. Validation failures return metadata_invalid (400) with a detail field naming the failing key and rule.

Every successful update fires a storage.object.updated event — bind a trigger to react to metadata changes.

Admin base URL & auth

Base URL: https://api.koolbase.com/v1

Required headers:
  Authorization: Bearer <session_token>

The session token is issued by the dashboard auth flow. Admin endpoints are scoped per-project — every path includes a {project_id} segment, and the caller must have access to that project.

Admin endpoints

GET/projects/{id}/bucketsList buckets in the project
POST/projects/{id}/bucketsCreate a bucket
DELETE/projects/{id}/buckets/{name}Delete a bucket and all its files
PATCH/projects/{id}/buckets/{name}/objects/metadataEdit metadata on a single object

GET /projects/{id}/buckets

// Response 200
[
  {
    "id": "buc_a4f9b21c",
    "project_id": "proj_d8c7a2",
    "name": "avatars",
    "public": false,
    "max_size_bytes": 1073741824,
    "max_file_size_bytes": 5242880,
    "allowed_mime_types": ["image/*"],
    "current_size_bytes": 204800,
    "created_at": "2026-01-01T00:00:00Z"
  }
]

current_size_bytes is the sum of all stored file sizes in the bucket, kept up to date by every confirm/delete. max_size_bytes, max_file_size_bytes, and allowed_mime_types are nullable / empty when no limit is set.

POST /projects/{id}/buckets

// Request — all limit fields optional
{
  "name": "avatars",
  "public": false,
  "max_size_bytes": 1073741824,
  "max_file_size_bytes": 5242880,
  "allowed_mime_types": ["image/*"]
}

// Response 201
{
  "id": "buc_a4f9b21c",
  "project_id": "proj_d8c7a2",
  "name": "avatars",
  "public": false,
  "max_size_bytes": 1073741824,
  "max_file_size_bytes": 5242880,
  "allowed_mime_types": ["image/*"],
  "current_size_bytes": 0,
  "created_at": "2026-01-01T00:00:00Z"
}

Naming rules: lowercase letters, digits, and hyphens; ≤ 63 characters; unique within the project; cannot start or end with a hyphen. See Buckets for the full reference.

Limits are immutable after creation

max_size_bytes, max_file_size_bytes, and allowed_mime_types cannot be changed once the bucket exists — there is no PATCH endpoint for them. To change a bucket's limits, create a new bucket with the desired configuration, migrate any files, and delete the old one.

allowed_mime_types supports family wildcards: image/* matches every image type. An empty or omitted list accepts any type.

DELETE /projects/{id}/buckets/{name}

// Response 204 No Content

Deletion is permanent

Deleting a bucket removes every file inside it from R2 and removes every storage_objects row from the database. There is no trash or recovery — once the call returns 204, the data is gone. The dashboard requires typing the bucket's name to confirm; direct API callers do not.

PATCH /projects/{id}/buckets/{name}/objects/metadata

// Request — same merge semantics as the SDK endpoint
{
  "path": "user_abc123.jpg",
  "metadata": {
    "alt_text": "New caption",
    "orig_filename": null
  }
}

// Response 200 — full updated object

This is the dashboard-facing parallel of /sdk/storage/objects/metadata. Same validation, same merge behaviour, same metadata_invalid error shape — only the auth model and base URL differ.

Errors emitted

Storage endpoints can return any of these storage-specific wire codes, documented in the Errors reference: path_conflict, quota_exceeded, file_too_large, mime_not_allowed, upload_expired, metadata_invalid, plus the generic not_found, permission_denied, and validation_error. Branch on the code field, not the HTTP status alone — multiple codes share each status (e.g. path_conflict and quota_exceeded both at 409).

metadata_invalid additionally carries a detail field naming the failing key and the rule it broke — see Custom Metadata → Errors for the SDK-side handling pattern.