Databases
All endpoints require Authorization: Bearer TOKEN.
Create Database
POST /api/v1/databasesBody:
{
"name": "my-postgres",
"type": "postgres",
"project_id": "prj_abc123",
"connect_site_ids": ["<site-uuid>"]
}| Field | Type | Options |
|---|---|---|
name | string | Alphanumeric, hyphens allowed |
type | string | postgres, mongodb, valkey |
version | string? | One of the engine’s versions from List Engine Versions. Omitted → the newest |
project_id | string | Required. Project that owns the database |
tier | string? | Compute tier, e.g. xs, s, m. Omitted → the catalog default for that engine (see below) |
disk_gb | int? | Disk to reserve, in GB. The volume is created at exactly this size, the size you are billed for. Omitted → the tier’s default disk |
size_mb | int? | Legacy volume size in MB, used only when disk_gb is omitted |
backup_tier_slug | string? | Backup schedule, e.g. daily, sixhourly. Omitted → the catalog’s default backup tier |
replica_set | bool? | MongoDB only, best omitted: MongoDB always runs as a single-node replica set. false is refused (400 standalone_mongodb_retired) |
mode | string? | Valkey only: cache or store. Omitted → cache. Ignored for other engines. See Cache or store |
connect_site_ids | string[]? | Site uuids of the project to connect the new database to, at level connect. Omitted or empty → connected to no site |
Every sizing field is optional; sending none of them creates the smallest database the engine supports, priced accordingly.
The default tier is engine-aware. With tier omitted, a database lands on
the smallest tier that allows its engine. PostgreSQL lands on the smallest
active tier (xs today), and MongoDB on the smallest MongoDB-capable tier —
m on the current catalog, because the two smallest tiers cannot run it.
Valkey lands on the smallest tier whose valkey_enabled is true in the catalog
(GET /api/v1/marketplace/catalog) — every tier, xs included, today. A
database is never placed on a tier that cannot run its engine: an explicit
tier that cannot is refused with 400 rather than quietly upgraded — for
Valkey, valkey is not offered on this tier: choose a tier other than "xs"
(the slug is the one you sent).
project_id is required and permanent: a database belongs to that project for
its whole life, and no endpoint moves it to another one. Its connection
variables are derived for every site connected to it (see
Site Access), so there is no attach call to make afterwards:
| Engine | Variables |
|---|---|
| PostgreSQL | DATABASE_URL |
| MongoDB | MONGODB_URI (alias MONGODB_URL) |
| Valkey | REDIS_URL and VALKEY_URL, both redis://c_<id8>:<password>@<host>:6379/0. Runtime only: a build never receives them |
The tier’s points cost is reserved at creation. A create that would push the
project past its plan budget answers 409, and one the platform has no capacity
for answers 503 — see Points Troubleshooting.
Response: 201
{
"database": { "id": "…", "name": "my-postgres", "status": "provisioning" },
"connections": {
"connected": [],
"pending": ["<site-uuid>"],
"failed": []
}
}connections says what became of each site in connect_site_ids:
| Field | Holds |
|---|---|
connected | Site ids connected now |
pending | Site ids waiting for a new PostgreSQL or Valkey database to accept connections — usually within a minute. They connect once it does; until then the database lists them in pending_connections |
failed | { "site_id", "error" } for each site that could not be connected. The database is created all the same, and error is a sentence you can show users |
Every site id is checked before anything is provisioned: a site of another
project answers 400, and a site you cannot write — a collaborator restricted to
other sites — answers 403. Either way nothing is created. Nothing connects a
database you did not choose a site for — see How a service gets
connected.
Engine refusals:
| Status | code | When |
|---|---|---|
400 | use_valkey | type is redis: “Redis is not offered. Create a Valkey database instead: it speaks the Redis protocol and every Redis client works with it.” |
400 | valkey_disabled | Valkey is switched off on this platform: “Valkey is not available yet” |
400 | invalid_mode | mode is not cache or store: mode must be "cache" or "store" |
400 | standalone_mongodb_retired | type is mongodb and replica_set is false: “MongoDB always runs as a single-node replica set now: a standalone instance cannot give each app its own database user. Leave replica_set out (or set it to true).” |
400 | — | version is not one the engine offers: unsupported version '9.0' for valkey, with supported_versions listing the ones it does |
400 | — | The tier does not run this engine (mongo_enabled or valkey_enabled is false on it) |
List Engine Versions
GET /api/v1/databases/versionsThe engine versions a create accepts in its optional version field, newest
first; a create without one gets the first.
Response: 200
{
"mongodb": ["8", "7", "6"],
"postgres": ["18-alpine", "17-alpine", "16-alpine", "15-alpine", "14-alpine"],
"valkey": ["9.1"]
}List Databases
GET /api/v1/databasesGet Database
GET /api/v1/databases/:idResponse: 200 — { "database": { … } }.
| Field | Type | Meaning |
|---|---|---|
type | string | postgres, mongodb or valkey |
status | string | provisioning, running, stopped, error or resizing |
status_message | string? | Why the database is in error — a Valkey that never became ready. Absent otherwise |
valkey_mode | string? | Valkey only: cache or store. Absent for other engines |
On a Valkey, version is 9.1 and port is 6379. A database body carries
no username: a database’s own login is never shown to anyone.
Two fields describe sites still waiting for a new database to accept connections. Both are absent when no site waits:
| Field | Type | Meaning |
|---|---|---|
pending_connections | string[]? | Ids of the sites that connect once the database accepts connections |
connect_error | string? | Why they still haven’t connected, once they have waited 15 minutes |
Delete Database
DELETE /api/v1/databases/:idGet Credentials
GET /api/v1/databases/:id/credentialsAn app reads its own credential 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 credential — the one in its
DATABASE_URL, MONGODB_URI or REDIS_URL:
Response: 200
{
"type": "postgres",
"host": "pg-mypostgres-a1b2c3d4-0.databases.svc.cluster.local",
"port": 5432,
"username": "c_3f1a2b3c",
"password": "<password>",
"database": "db_mypostgres",
"internal_url": "postgresql://c_3f1a2b3c:<password>@pg-mypostgres-a1b2c3d4-0.databases.svc.cluster.local:5432/db_mypostgres",
"level": "connect",
"credential": "connection"
}username is the connection’s own (c_ followed by eight characters), level
is the connection’s level, and credential is always "connection". Three
refusals:
| Status | code | When |
|---|---|---|
403 | not_connected | The key’s site is not connected to this database |
403 | site_key_required | A project-wide key — one you created, tied to no site — has no connection of its own |
409 | no_own_credential | The site’s connection has no credential of its own yet. The platform gives it one as soon as the database 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 database’s own login is never shown, and each connected
app already holds a credential of its own:
{
"code": "shared_credentials_retired",
"error": "A database's own login is no longer shown to anyone: every app connected to my-postgres has its own credential in its variables. From your laptop: ghayma connect --local. For a system outside Ghayma: ghayma access add database my-postgres --name <principal>. Host, port and the variable names, without any password: ghayma db credentials my-postgres (CLI 0.14.0 or later)."
}On a Valkey the sentence has no For a system outside Ghayma part: a Valkey has
no external principals yet.
The host and internal_url fields use internal Kubernetes URLs (.svc.cluster.local). Use these in apps deployed on Ghayma. To reach the database from outside the cluster, add a named principal — it is handed a URL on the database’s own <resource>.db.ghayma.tech host, with its own credential. That is the only external path. See the Networking Guide for details.
Site Access
Which of the owning project’s sites may reach this database over the network.
Changes converge within ~2 minutes. The same rows decide which sites receive the
database’s connection variables — a connected site gets them, a disconnected one
loses them, on its running app and on every later deploy. On a PostgreSQL
database, connecting a site also creates the role that site’s DATABASE_URL
carries, and disconnecting it drops that role alone. On a Valkey it creates the
user the site’s REDIS_URL carries, the same way.
These two routes are the per-database view of a connection at level
connect. The project-wide Connections API reads and writes
the same rows — for buckets and auth apps as well as databases — and is the only
way to reach those other two kinds. Both keep working; use whichever fits the
call you are making.
A new database is connected only to the sites in its create’s
connect_site_ids — none, even in a project with a single
site, when that list is empty — and stays deny-all until you call
Set Site Access or
Connect a Service.
List Site Access
GET /api/v1/databases/:id/sitesResponse: every site of the owning project, flagged:
{
"sites": [
{ "site_id": "…", "slug": "main", "name": "main", "has_access": true },
{ "site_id": "…", "slug": "admin", "name": "admin", "has_access": false, "pending": true }
]
}"pending": true marks a site waiting for the database to accept logins; it
connects once it does. A project with no sites yet returns { "sites": [] }.
Set Site Access
PUT /api/v1/databases/:id/sitesBody — the full desired set (an empty array revokes all access):
{
"site_ids": ["<site-uuid>", "<site-uuid>"]
}Returns the same shape as the list endpoint. 400 if any site_id belongs to
a different project, and 403 unless you may write every site whose access the
save changes. Saving also narrows the wait to the sites you saved: a waiting
site you leave out no longer connects.
A Valkey that is not running yet answers 409 with
code: "database_not_running": “the Valkey database is not running yet; connect
the site once it is running (database cache is provisioning)”. Sites chosen at
create connect on their own once it runs. Any other database that is stopped or
still being provisioned answers 409 with code: "not_running", and one that
cannot give an app a credential of its own answers 409 with
code: "credential_unsupported": a new site is connected only with a credential
of its own.
External Access (Named Principals)
A named principal is the only way to reach a database from outside. Each outside consumer gets its own credential, level, optional source
allowlist and optional expiry. Reads need the project read role, every
mutation write; :id is a project id or slug and :resourceId is the
database’s UUID. Buckets use the identical routes under /buckets/.
| Route | Does |
|---|---|
GET /projects/:id/databases/:resourceId/access | List the principals, revoked ones included. Never carries a secret |
POST /projects/:id/databases/:resourceId/access | Create one. 201 with the credential in the body — the only time it is readable |
DELETE /projects/:id/databases/:resourceId/access/:accessId | Revoke it (204). Revoking twice is not an error |
POST /projects/:id/databases/:resourceId/access/:accessId/rotate | New secret, same identity — returned once |
PUT /projects/:id/databases/:resourceId/access/:accessId/allowlist | Replace allow_cidrs with the whole list given; [] means any source |
A create takes name (required), level, allow_cidrs and expires_at; omit
any of the last three for the kind’s default level, any source, and no expiry. A
400 is a malformed name, level, CIDR or expiry; a 409 is a principal that is
already revoked; a 503 is an engine that could not mint, and nothing was
created. A project API key cannot call these routes at all — it is answered
403 before the handler.
A Valkey has no public access yet. A create on a Valkey answers 400, and
its list is always {"access": []}:
{
"code": "public_access_unavailable",
"error": "public access for this database engine is not available yet; use the ghayma tunnel"
}Reach it from your machine with
ghayma connect --local.
See the External Access guide for the model and the hostnames it hands out.
Rotate Credentials
POST /api/v1/databases/:id/rotateA database’s own login is never handed out, so it is not rotated from here:
the route answers 410 for every engine.
{
"code": "shared_rotate_retired",
"error": "A database's own login is no longer rotated from here: it is never handed out, and only an operator rotates it. To give one app a new credential: ghayma connections rotate database my-postgres --site <site>, or Rotate on the app's row in the console's Access list."
}To give one app a new credential, rotate its connection:
POST /projects/:id/sites/:siteId/connections/database/:dbId/rotate,
or ghayma connections rotate.
The other apps keep theirs.
Stop / Start
POST /api/v1/databases/:id/stop
POST /api/v1/databases/:id/startResize
PATCH /api/v1/databases/:id/tierChanges a database’s compute tier, disk size, backup schedule, or any mix of them. Send only what changes; at least one field is required.
Body:
{
"tier": "m",
"disk_gb": 10,
"backup_tier_slug": "daily"
}| Field | Type | Meaning |
|---|---|---|
tier | string? | New compute tier, e.g. s, m |
disk_gb | int? | New disk size in GB, at least 1. Larger grows the disk; smaller shrinks it |
backup_tier_slug | string? | New backup schedule: weekly, daily, sixhourly |
PATCH /api/v1/databases/:id/resize with {"storage_mb": 10240} changes the
disk alone, rounded up to whole GB, under the same rules.
A new tier on a Valkey restarts it with the new memory limit: its
maxmemory is 70 % of the tier’s memory limit. A Valkey disk grows online but
cannot shrink yet.
A disk change finishes after the call returns. The 200 response already
reads status: "resizing", and disk_gb keeps the old size until the disk has
the new one; poll Get Database until status is running
again. A grow happens online: the database stays up, and the new size applies
in about a minute.
{
"database": {
"id": "…",
"name": "my-postgres",
"status": "resizing",
"disk_gb": 20,
"disk_used_bytes": 1932735283,
"min_disk_gb": 3,
"resize": {
"direction": "shrink",
"target_disk_gb": 5,
"phase": "copy_out"
}
}
}Every database object carries these disk fields:
| Field | Type | Meaning |
|---|---|---|
disk_gb | int | The disk size it has now, in GB |
disk_used_bytes | int | What the volume holds on disk: the data plus the engine’s own files, such as PostgreSQL’s WAL or MongoDB’s journal, so it is larger than storage_used_bytes. Read about every 10 minutes |
min_disk_gb | int? | The smallest disk it can shrink to now. Absent until the first reading; when it is not below disk_gb, the disk cannot shrink yet. A Valkey disk cannot shrink whatever it reads |
resize | object? | The change in progress, or the last one that failed. Absent otherwise |
resize field | Meaning |
|---|---|
direction | grow or shrink |
target_disk_gb | The size it is moving to |
phase | The step it is on: expand for a grow; stop, copy_out, release, recreate, copy_back, start, cleanup for a shrink, in that order |
error | Why the change failed. Present only after a failure |
While status is resizing the database takes no other change: Stop,
Start, Delete, a Valkey mode switch and any other resize
answer 409 with code: "resize_in_progress", and Restore Backup is refused
until it finishes.
While an operator converts a MongoDB database to a replica set, Stop, Start,
Delete and both resize routes (PATCH …/tier and PATCH …/resize) answer 409
with code: "conversion_in_progress": “this database is being converted to a
replica set (since …); try again when it has finished”.
The new size is billed once the change is done. A grow is checked against your points budget when you ask for it; a shrink lowers the price from the moment it finishes. See Pricing by Service.
Errors:
| Status | Code | Meaning |
|---|---|---|
400 | — | disk_gb below 1 GB |
409 | disk_too_small | The data does not fit in 75 % of the new size. The body carries min_disk_gb, the smallest size that fits now, with disk_used_bytes and target_disk_gb |
409 | resize_in_progress | A resize is already running. Try again when it has finished |
409 | database_not_running | The disk changes only while the database runs. Start it first |
409 | shrink_unsupported | A smaller disk on a Valkey, on both endpoints: “this database’s disk can grow, but shrinking it is not available yet” |
503 | storage_capacity | The platform does not have the storage for this disk right now. Try a smaller size, or later |
A points rejection (409 over budget, 503 platform capacity) is the same as
for a create — see Points Troubleshooting.
Shrinking a disk
A PostgreSQL or MongoDB disk can shrink if what the volume holds fits in
75 % of the new size;
min_disk_gb is that rule applied to disk_used_bytes. The call measures the
volume again before it starts, so a shrink is checked against the current
figure, not the last reading.
A shrink stops the database for about a minute on a small database, longer on a large one, while its files are copied to the smaller disk. Apps cannot connect during that time. Everything else stays exactly as it was: credentials, users, connection strings and data.
If a shrink fails before the old disk is released, it is undone automatically:
the database comes back on its original disk with status: "running", and
resize.error says what went wrong. If it stops later, status is error and
the data is kept safe; support finishes the move.
Change Valkey Mode
PATCH /api/v1/databases/:id/valkey-modeSwitches a Valkey between cache and store — see Cache or
store. Needs the project write role.
Body:
{
"mode": "store"
}Response: 200 — { "database": { … } } with the new valkey_mode.
The database restarts into the new mode: a few seconds of downtime. Before
the response, a switch to store builds the append-only file, which takes
longer on a large dataset, and a switch to cache takes a fresh snapshot. Once
the database has started restarting, a cancelled request still records the new
mode. Asking for the mode it already has changes nothing.
Errors:
| Status | code | Meaning |
|---|---|---|
400 | — | The body has no mode (request validation) |
400 | not_valkey | ”this database is not a Valkey database” |
400 | invalid_mode | mode must be "cache" or "store" |
409 | database_not_running | Only a running Valkey switches. The sentence is the shared disk one: “the database must be running to change its disk” |
409 | resize_in_progress | A resize is running. Try again when it has finished |
500 | — | The append-only file or the snapshot could not be written. The mode stays as it was |
Database Logs
GET /api/v1/databases/:id/logs?tail=200&follow=1The engine’s own log, for every engine: PostgreSQL, MongoDB and Valkey.
| Parameter | Type | Default | Description |
|---|---|---|---|
tail | number | 200 | Lines to return from the end of the log, clamped to 1-1000 |
follow | string | — | 1 keeps the response open and streams new lines. The platform ends it after 10 minutes |
Response: 200 — text/plain; charset=utf-8, streamed. Each line starts
with its RFC3339Nano timestamp:
2026-10-08T09:12:22.108356960Z 1:M 08 Oct 2026 09:12:22.108 * Valkey is startingA follow also ends when the database restarts. Close the connection to stop it earlier. A follow may also carry empty lines that keep the connection open; skip them.
Reading the log needs the project write role. A project API key is refused
with 403: “project keys cannot read database logs”.
| Status | code | Meaning |
|---|---|---|
403 | — | A project API key, or a role below write |
409 | no_pod | ”the database has no running pod”: the pod does not exist — a stopped or new database — or its engine container has not started |
Backups
A backup is a dump of the database itself. Roles you create inside the database (for example a least-privilege application user) and the privileges granted to them are not part of it, and a restore does not recreate them. Keep the statements that create your roles and grants with your migrations so you can re-apply them after a restore.
Create Backup
POST /api/v1/databases/:id/backupsList Backups
GET /api/v1/databases/:id/backups?page=1&per_page=20Response: 200
{
"backups": [ … ],
"total": 9,
"page": 1,
"per_page": 20,
"has_more": false,
"health": {
"state": "healthy",
"consecutive_failures": 0,
"in_flight": false,
"last_completed_at": "2026-09-24T09:27:49Z",
"last_attempt_at": "2026-09-21T12:13:03Z",
"next_attempt_at": "2026-09-28T12:13:03Z",
"interval_hours": 168
}
}backups is one page, newest first. per_page is 20, 50 or 100; any
other value falls back to 20.
health is the state of the database’s backup schedule, worked out from its
most recent backups each time you ask:
| Field | Type | Meaning |
|---|---|---|
state | string | never (no backup yet), healthy, failing (scheduled backups are failing and the schedule is backing off) or paused (10 scheduled backups failed in a row and the schedule has stopped) — see Scheduled backups that keep failing |
consecutive_failures | int | Scheduled backups that failed since the last backup that completed. Failed manual backups are not counted |
in_flight | bool | A backup is running right now |
last_error | string? | Why the latest scheduled backup failed. Present only while the schedule is failing or paused |
last_completed_at | string? | When the latest successful backup, scheduled or manual, was taken |
last_attempt_at | string? | When the latest scheduled backup was attempted, whatever the outcome |
next_attempt_at | string? | When the next scheduled backup is due. null while the schedule is paused |
interval_hours | int | How often the schedule runs: 168 weekly, 24 daily, 6 every six hours |
Scheduled backups that keep failing
A failed scheduled backup is not retried every hour. Each failure in a row makes the next attempt wait longer:
| Scheduled backups failed in a row | Next scheduled attempt |
|---|---|
| 1 | 1 hour after the failure |
| 2 | 6 hours after |
| 3 to 9 | 24 hours after |
| 10 | None: the schedule is paused |
Schedules are checked once an hour, so an attempt can start up to an hour later than the table says.
Take a manual backup to resume. Once the cause is fixed, call Create
Backup. When that backup completes, consecutive_failures
drops to 0, a paused schedule starts again, and the next scheduled backup is
due one interval after the last successful scheduled one, or at the next hourly
check if that time has already passed. A manual backup that fails does not
count against the schedule. Manual backups count towards your plan’s limit
on stored manual backups: at the limit, Create Backup answers 400, so delete
an old manual backup first.
A failing schedule never costs you the backups you already have. Old scheduled backups are pruned only after a new scheduled backup succeeds, and manual backups are never pruned.
Restore Backup
POST /api/v1/databases/:id/backups/:backupId/restoreRestores a completed backup, scheduled or manual, into the database.
How a Valkey restore works. A Valkey backup is an encrypted snapshot. The restore stops the database, swaps the backup’s data in and starts it again: a few seconds of downtime. In store mode the old append-only file is dropped and a new one is built from the backup before the database accepts connections.
How a MongoDB restore works
A MongoDB restore never leaves a collection half-emptied:
- The backup is loaded next to your data first, into temporary collections
named
__ghayma_restore__<collection>in the same database. Your live collections are not touched while this runs. - When everything is loaded, each temporary collection replaces its live collection in a single atomic step, one collection at a time. The backup’s views are then recreated.
- If loading fails, your data is exactly as it was and the call returns the error.
- If the swap fails part-way, which is rare, the error lists the collections that were restored and the ones that were not. The leftover temporary collections stay in the database until your next restore, which clears them before it starts.
- Collections that are not in the backup are kept. A restore does not delete a collection just because the backup does not have it.
- Not supported: time-series collections, which cannot be swapped in (the
swap stops at the first one), and
system.*collections. A recreated view keeps its source collection and pipeline but loses any collation set on it, and a collection with the same name as a view in the backup is replaced by that view.
Delete Backup
DELETE /api/v1/databases/:id/backups/:backupId