Manage databases
Every database route sits under /api/v1/tenants/{tenant}/databases. The API
never returns a password: credentials only reach your clusters, through
bindings.
| Method | Path | What it does | Tenant role |
|---|---|---|---|
GET |
/databases |
List databases | readonly |
POST |
/databases |
Create a database | admin |
GET |
/databases/{name} |
Get a database | readonly |
PATCH |
/databases/{name} |
Resize, toggle HA, toggle deletion protection | admin |
DELETE |
/databases/{name} |
Delete a database and its bindings | admin |
POST |
/databases/{name}/rotate-credentials |
Rotate one role’s password | admin |
GET |
/databases/{name}/bindings |
List a database’s bindings | readonly |
POST |
/databases/{name}/bindings |
Bind into namespaces of a cluster | admin |
GET |
/databases/{name}/bindings/{binding} |
Get a binding | readonly |
PATCH |
/databases/{name}/bindings/{binding} |
Change a binding’s namespaces | admin |
DELETE |
/databases/{name}/bindings/{binding} |
Remove a binding | admin |
A key or member with the tenant readonly role gets 403 on the admin
routes. The
API reference is generated from the API’s OpenAPI spec and
lists these routes under databases once the published spec includes them.
The tables on this page cover every field they take.
The examples use:
export KUPE_TENANT="<tenant>"export API="https://api.kupe.cloud/api/v1/tenants/$KUPE_TENANT"Create a database
Section titled “Create a database”POST /api/v1/tenants/{tenant}/databasescurl -X POST \ -H "Authorization: Bearer $KUPE_API_KEY" \ -H "Content-Type: application/json" \ "$API/databases" \ -d '{ "name": "orders-db", "resources": { "cpu": "500m", "memory": "1Gi", "storage": "10Gi" } }'| Field | Required | Mutable | Description |
|---|---|---|---|
name |
Yes | No | A DNS label that starts with a letter and ends with a letter or digit, without --, at most 56 characters minus the length of your tenant name. |
resources.cpu |
Yes | Yes | Per instance, without a unit: "500m", "2" or "0.5". |
resources.memory |
Yes | Yes | Per instance, with a unit: "512Mi", "1Gi". |
resources.storage |
Yes | Grows only | Per instance, with a unit: "10Gi". |
engine |
No | No | "postgres", the only engine and the default. |
version |
No | No | Major version such as "18". Omit it to get the platform default. |
highAvailability |
No | Yes | true runs two instances with a synchronous standby. Default false. |
deletionProtection |
No | Yes | Default true. Must be false before the database can be deleted. |
The platform checks the size against its minimum and maximum, your plan’s database limit and your plan’s resource pool when the request is admitted. See Limits.
201 Created returns the database and an ETag header. Provisioning is
asynchronous, so status is still empty:
{ "name": "orders-db", "engine": "postgres", "version": "18", "resources": { "cpu": "500m", "memory": "1Gi", "storage": "10Gi" }, "highAvailability": false, "deletionProtection": true, "status": { "observedGeneration": 0, "phase": "", "currentVersion": "", "previousVersion": "", "instancesReady": 0, "instancesDesired": 0, "rotations": { "owner": { "requestedAt": "", "appliedAt": "", "pending": false, "delivered": true }, "readwrite": { "requestedAt": "", "appliedAt": "", "pending": false, "delivered": true }, "readonly": { "requestedAt": "", "appliedAt": "", "pending": false, "delivered": true } }, "conditions": [] }, "generation": 1, "resourceVersion": "582019", "createdAt": "2026-10-09T10:00:00Z"}Wait for it to run
Section titled “Wait for it to run”Poll GET /databases/{name} until status.phase is Running:
TIMEOUT_SECONDS=1800ELAPSED=0while [ "$ELAPSED" -lt "$TIMEOUT_SECONDS" ]; do PHASE=$(curl -s -H "Authorization: Bearer $KUPE_API_KEY" \ "$API/databases/orders-db" | jq -r '.status.phase') echo "Phase: $PHASE (elapsed ${ELAPSED}s)" [ "$PHASE" = "Running" ] && exit 0 sleep 10 ELAPSED=$((ELAPSED + 10))doneecho "Timed out waiting for the database" >&2exit 1The phases are Pending, Provisioning, Running, Upgrading, Degraded
and Terminating, and Databases says what each
one means. Once it runs:
curl -s -H "Authorization: Bearer $KUPE_API_KEY" "$API/databases/orders-db" \ | jq '{phase: .status.phase, currentVersion: .status.currentVersion, ready: .status.instancesReady, desired: .status.instancesDesired}'{ "phase": "Running", "currentVersion": "18.6", "ready": 1, "desired": 1}version is the major you asked for, and status.currentVersion is the release
running.
A PATCH is in effect once all three of these hold:
status.observedGenerationhas reached thegenerationin thePATCHresponse.status.phaseisRunning.- The
Readycondition instatus.conditionsisTrue.
The platform records a change before every instance runs it, and the phase
stays Running while the instances roll onto it, so observedGeneration on its
own answers too early. A database with one instance is Degraded while that
instance restarts for a CPU or memory change. Count Degraded as a failure only
once observedGeneration has caught up and the phase has stayed there for a few
minutes, because a read taken before the platform saw the change still shows the
state from before it.
Bind it into cluster namespaces
Section titled “Bind it into cluster namespaces”POST /api/v1/tenants/{tenant}/databases/{name}/bindingscurl -X POST \ -H "Authorization: Bearer $KUPE_API_KEY" \ -H "Content-Type: application/json" \ "$API/databases/orders-db/bindings" \ -d '{ "name": "orders-db-prod-readwrite", "cluster": "prod", "namespaces": ["orders-api", "orders-worker"], "role": "readwrite" }'| Field | Required | Description |
|---|---|---|
name |
Yes | Binding name: 1 to 63 lowercase letters, numbers and -, starting and ending with a letter or number. |
cluster |
Yes | The cluster to bind into. It may not exist yet; the binding waits for it. |
namespaces |
Yes | Namespaces that receive the Service and Secret: 1 to 10, each listed once, none kube-* or argocd. |
role |
Yes | owner (changes the schema), readwrite (reads and writes data) or readonly (reads data). |
secretName |
No | Name of the credentials Secret in every listed namespace. Defaults to {database}-{role}. |
The CLI, the console and Terraform name a binding {database}-{cluster}-{role}
when you do not give a name, but the API needs it in the request. Only
namespaces can change once the binding exists; see
Change a binding’s namespaces.
201 Created returns the binding and an ETag header. Like a new database, its
status fills in once the platform has processed it:
{ "name": "orders-db-prod-readwrite", "database": "orders-db", "cluster": "prod", "namespaces": ["orders-api", "orders-worker"], "role": "readwrite", "secretName": "", "status": { "phase": "", "secretName": "", "message": "", "namespaces": [], "conditions": [] }, "resourceVersion": "582020", "createdAt": "2026-10-09T10:05:00Z"}secretName echoes what you sent, so it is empty when you took the default.
status.secretName is the name actually used. Poll the binding until
status.phase is Ready:
curl -s -H "Authorization: Bearer $KUPE_API_KEY" \ "$API/databases/orders-db/bindings/orders-db-prod-readwrite" \ | jq '.status | {phase, secretName, message, namespaces}'{ "phase": "Ready", "secretName": "orders-db-readwrite", "message": "the database is available in every listed namespace", "namespaces": [ { "name": "orders-api", "host": "orders-db.orders-api.svc.cluster.local", "phase": "Ready", "deliveredRotation": "", "message": "" }, { "name": "orders-worker", "host": "orders-db.orders-worker.svc.cluster.local", "phase": "Ready", "deliveredRotation": "", "message": "" } ]}status.phase is Ready only when every listed namespace is. status.namespaces
has one entry per namespace with its own host, phase and message. It is empty
until the platform first processes the binding, and can still list a namespace
you removed until that namespace is withdrawn.
A binding stays Pending while its database or cluster is missing or not
running, or a namespace is not delivered yet, and becomes Ready on its own.
Failed means a listed namespace already holds a Service or Endpoints object
named after the database, or a Secret of the binding’s Secret name, that the
platform did not create; that namespace’s message names it, and the other
namespaces are still delivered. See
Binding phases.
GET /databases/{name}/bindings returns every binding of the database as
{"items": [...]}.
Within one database, a namespace of a cluster can be listed by one binding per
role, a Secret name can be used once per namespace, and the bindings list at
most 20 namespaces between them. A request that breaks one of these gets a
plain 400 whose message says which, for example
database "orders-db" would have 21 bound namespaces, the most one database can have is 20.
Change a binding’s namespaces
Section titled “Change a binding’s namespaces”PATCH /api/v1/tenants/{tenant}/databases/{name}/bindings/{binding}The body holds one field, namespaces, and it is the complete new list. To add
or remove one namespace, read the binding, edit its list and send it back with
If-Match set to the ETag you read. This takes orders-worker out of the
binding created above:
ETAG=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $KUPE_API_KEY" \ "$API/databases/orders-db/bindings/orders-db-prod-readwrite" \ | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
curl -X PATCH \ -H "Authorization: Bearer $KUPE_API_KEY" \ -H "Content-Type: application/json" \ -H "If-Match: $ETAG" \ "$API/databases/orders-db/bindings/orders-db-prod-readwrite" \ -d '{"namespaces": ["orders-api"]}'200 OK returns the binding with a new ETag. A namespace added gets the
Service and the Secret, a namespace left out loses its Secret, and the
namespaces in both lists keep working. A namespace left out also loses the
Service and the network path, unless another binding of the same database lists
it. Poll the binding as after a create: the change is done when status.phase
is Ready and status.namespaces lists exactly the new namespaces.
- A
412means the binding changed since you read it, which the platform’s own status updates can also do. Read it again, apply your edit to the new list and send it again. - Any field other than
namespacesis a400, for exampleinvalid request body: json: unknown field "role". The cluster, role and Secret name are fixed. - A list that adds a namespace is checked like a create. A list that only removes namespaces is never refused by those checks.
- While the binding or its database is being deleted, the
PATCHreturns409with the codeterminating.
Resize, toggle HA or deletion protection
Section titled “Resize, toggle HA or deletion protection”PATCH takes JSON merge-patch: send only the fields you are changing. At least
one of resources, highAvailability, deletionProtection or version is
required.
ETAG=$(curl -s -D - -o /dev/null -H "Authorization: Bearer $KUPE_API_KEY" \ "$API/databases/orders-db" | awk 'tolower($1)=="etag:" {print $2}' | tr -d '\r')
curl -X PATCH \ -H "Authorization: Bearer $KUPE_API_KEY" \ -H "Content-Type: application/json" \ -H "If-Match: $ETAG" \ "$API/databases/orders-db" \ -d '{ "resources": { "memory": "2Gi", "storage": "20Gi" }, "highAvailability": true }'If-Match is optional. The ETag is the database’s resource version, which the
platform’s own status updates also move, so a 412 does not always mean someone
else changed the database. Get it again and retry. Changes apply asynchronously: CPU and memory changes restart the
instances one at a time, storage grows online. Resources you leave out keep
their value.
version can only be the current major, which is a no-op. A higher major gets
400 major_upgrade_unavailable, and a lower one a plain 400,
version cannot be lowered: the database is on major version 18.
Rotate credentials
Section titled “Rotate credentials”POST /api/v1/tenants/{tenant}/databases/{name}/rotate-credentialscurl -X POST \ -H "Authorization: Bearer $KUPE_API_KEY" \ -H "Content-Type: application/json" \ "$API/databases/orders-db/rotate-credentials" \ -d '{"role": "readwrite"}'202 Accepted:
{ "database": "orders-db", "role": "readwrite", "requestedAt": "2026-10-09T12:00:00.123456789Z"}requestedAt is an opaque request token. It looks like a timestamp, but only
tells one request from another. The platform then rotates the password: the old
one stops working, every bound Secret for the role is updated, and applications
that hold the password in memory must restart. No password is returned. A
database that is being deleted returns 409 with the code terminating.
Follow the rotation in status.rotations on GET /databases/{name}. It has
one entry per role, owner, readwrite and readonly:
{ "readwrite": { "requestedAt": "2026-10-09T12:00:00.123456789Z", "appliedAt": "2026-10-09T12:00:00.123456789Z", "pending": false, "delivered": true }}appliedAtis the token the database last applied. Once it equals yourrequestedAt, the database uses the new password and the old one no longer works.deliveredistrueonce every binding of the role has written the new password to its credentials Secret in every namespace it lists. The platform then copies each Secret into its cluster namespace.pendingistruewhile a requested rotation is not yet applied.
The rotation is complete when appliedAt equals your requestedAt and
delivered is true. Compare the tokens for equality only. If requestedAt
changes to another token, someone rotated the same role again, and that newer
rotation replaces yours. Each binding reports the token it delivered into each
namespace as status.namespaces[].deliveredRotation.
Remove a binding
Section titled “Remove a binding”curl -X DELETE \ -H "Authorization: Bearer $KUPE_API_KEY" \ "$API/databases/orders-db/bindings/orders-db-prod-readwrite"204 No Content. The Secret leaves every namespace the binding lists, and so do
the Service and the network path, except in a namespace another binding of the
database still lists. The role’s password does not change. Rotate it if those
namespaces should lose access for good.
Delete a database
Section titled “Delete a database”Turn deletion protection off, then delete:
curl -X PATCH \ -H "Authorization: Bearer $KUPE_API_KEY" \ -H "Content-Type: application/json" \ "$API/databases/orders-db" \ -d '{"deletionProtection": false}'
curl -X DELETE \ -H "Authorization: Bearer $KUPE_API_KEY" \ "$API/databases/orders-db"204 No Content. The database goes to Terminating and its bindings are removed
from every cluster. Poll GET /databases/{name} until it returns 404. While
deletion protection is on, DELETE returns 409:
{ "error": "database \"orders-db\" has deletionProtection enabled: set it to false before deleting", "code": "deletion_protected", "message": "database \"orders-db\" has deletionProtection enabled: set it to false before deleting", "severity": "error"}Deleting a database removes it immediately. Its storage is kept for 7 days for support-assisted recovery, then permanently deleted. There is no self-service restore.
Error codes
Section titled “Error codes”Besides the statuses every route can return (see Errors, retries, and rate limits), the database routes use these codes:
| Status | code |
When |
|---|---|---|
400 |
creation_paused |
The platform has paused new databases. Existing ones are unaffected; retry later. |
400 |
plan_limit_reached |
Creating the database would exceed the plan’s database limit. |
400 |
storage_shrink_unsupported |
A PATCH asked for less storage than the database has. |
400 |
major_upgrade_unavailable |
A PATCH asked for a higher major version. |
404 |
databases_unavailable |
Databases are not enabled on the platform. Retrying does not help. |
409 |
deletion_protected |
DELETE while deletionProtection is true. |
409 |
terminating |
The database is being deleted: a PATCH, a rotation or a new binding. Also a binding PATCH while that binding is being deleted. |
409 |
tenant_terminating |
The tenant is being deleted: a create, or a PATCH that grows the database. |
409 |
tenant_suspended |
The tenant is suspended: a create, or a PATCH that grows the database. |
409 |
The name is taken, or the database kept changing during an update or a delete. | |
412 |
The If-Match ETag no longer matches. |
|
503 |
A new tenant’s database limit is still being set up, in its first seconds. Retry in seconds. |
Other 400s carry a message saying what was wrong, such as a size below the
platform minimum, a lower major version, or a major version that is not offered
(unsupported postgres version "16" (supported: ...) lists the majors on
offer).
While databases are switched off on the platform, the clean-up routes keep
working so you can always remove what exists: list, get, delete, a PATCH that
only sets deletionProtection, and listing, getting and deleting bindings.
Next steps
Section titled “Next steps”- Databases: the model behind these routes
- Manage databases with Terraform
- API reference