Skip to Content

Storage

All endpoints require Authorization: Bearer TOKEN.

Create Bucket

POST /api/v1/storage

Body:

{ "name": "my-assets", "project_id": "prj_abc123", "connect_site_ids": ["<site-uuid>"] }

project_id is required and permanent: a bucket belongs to that project for its whole life, and no endpoint moves it to another one.

connect_site_ids lists the sites to connect the new bucket to, at level read-write; omitted or empty, it is connected to no site. Each connected site receives STORAGE_ENDPOINT, STORAGE_ACCESS_KEY, STORAGE_SECRET_KEY, STORAGE_BUCKET and STORAGE_REGION, the access key being one minted for that connection. The ids are checked before anything is created, with the same 400 and 403 as Create Database.

Response: 201

{ "bucket": { "id": "…", "name": "my-assets" }, "connections": { "connected": ["<site-uuid>"], "pending": [], "failed": [] } }

connections reads as it does for a database.

List Buckets

GET /api/v1/storage

Get Bucket

GET /api/v1/storage/:id

Response: 200 — { "bucket": { … } }. Besides its name, garage_bucket (the S3 bucket name) and its usage, a bucket carries endpoint, the S3 endpoint it is reached on — https://s3.ghayma.tech. No key travels with it: an app’s key is in its STORAGE_* variables.

Delete Bucket

DELETE /api/v1/storage/:id

Get Credentials

GET /api/v1/storage/:id/credentials

An app reads its own key here; a person never does. Called with a site’s managed key (the GHAYMA_API_KEY a connected site receives), this route answers with that site’s own connection key — the one in its STORAGE_* variables:

Response: 200

{ "credentials": { "endpoint": "https://s3.ghayma.tech", "access_key": "GKxxxxxxxx", "secret_key": "xxxxxxxx", "bucket": "st-myassets-a1b2c3d4", "region": "espace-garage", "level": "read-write", "credential": "connection" } }

Feed these straight into any S3 client — accessKeyId: access_key, secretAccessKey: secret_key. region is the literal string espace-garage (the Garage cluster’s region name), and bucket is the underlying S3 bucket name, not the Ghayma bucket id. The key reaches that bucket and nothing else, at the connection’s level (a read key cannot write), and credential is always "connection" — see Each app gets its own storage key. Its refusals:

StatuscodeWhen
403not_connectedThe key’s site is not connected to this bucket
403site_key_requiredA project-wide key — one you created, tied to no site — has no connection of its own
409credentials_not_readyThe bucket is still being provisioned and has no key yet
409no_own_credentialThe site’s connection has no key of its own yet. The platform gives it one as soon as the bucket can (it retries every ten minutes); retry later, or reconnect the site

Called by a person — with a dashboard session or an account token — the route answers 410. A bucket’s own key is never shown, and each connected app already holds a key of its own:

{ "code": "shared_credentials_retired", "error": "A bucket's own key is no longer shown to anyone: every app connected to my-assets has its own key in its STORAGE_* variables. From your laptop: ghayma env pull (your site's STORAGE_* variables; the S3 endpoint is public). For a system outside Ghayma: ghayma access add bucket my-assets --name <principal>. Endpoint, bucket and the variable names, without any key: ghayma storage credentials my-assets (CLI 0.14.0 or later)." }

Rotate Credentials

POST /api/v1/storage/:id/rotate

A bucket’s own key is never handed out, so it is not rotated from here: the route answers 410.

{ "code": "shared_rotate_retired", "error": "A bucket's own key is no longer rotated from here: it is never handed out, and only an operator rotates it. To give one app a new key: ghayma connections rotate bucket my-assets --site <site>, or Rotate on the app's row in the console's Access list." }

To give one app a new key, rotate its connection: POST /projects/:id/sites/:siteId/connections/bucket/:bucketId/rotate, or ghayma connections rotate.

Only an operator rotates a bucket’s own key. Presigned URLs signed before that rotation keep working until they expire: the replaced key retires after 7 days. An incident rotation (immediate) stops them at once.

Expose / Unexpose

Make bucket publicly accessible or restrict access.

POST /api/v1/storage/:id/expose POST /api/v1/storage/:id/unexpose

Presigned Upload URL

Generate a time-limited URL for client-side uploads.

POST /api/v1/storage/:id/presign/upload

Body:

{ "key": "uploads/photo.jpg", "content_type": "image/jpeg", "expiry": 3600 }

Response: 200

{ "upload_url": "https://s3.ghayma.tech/bucket/key?X-Amz-Signature=...", "expires_in": 3600 }

Presigned Download URL

Generate a time-limited URL for accessing private files.

POST /api/v1/storage/:id/presign/download

Body:

{ "key": "images/photo.jpg", "expiry": 3600 }

Response: 200

{ "download_url": "https://s3.ghayma.tech/bucket/key?X-Amz-Signature=...", "expires_in": 3600 }

Presigned URLs can be used directly in browsers (<img>, <video>, fetch) — no authentication headers needed.