Storage
All endpoints require Authorization: Bearer TOKEN.
Create Bucket
POST /api/v1/storageBody:
{
"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/storageGet Bucket
GET /api/v1/storage/:idResponse: 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/:idGet Credentials
GET /api/v1/storage/:id/credentialsAn 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:
| Status | code | When |
|---|---|---|
403 | not_connected | The key’s site is not connected to this bucket |
403 | site_key_required | A project-wide key — one you created, tied to no site — has no connection of its own |
409 | credentials_not_ready | The bucket is still being provisioned and has no key yet |
409 | no_own_credential | The 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/rotateA 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/unexposePresigned Upload URL
Generate a time-limited URL for client-side uploads.
POST /api/v1/storage/:id/presign/uploadBody:
{
"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/downloadBody:
{
"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.