Skip to content

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:

Terminal window
export KUPE_TENANT="<tenant>"
export API="https://api.kupe.cloud/api/v1/tenants/$KUPE_TENANT"
POST /api/v1/tenants/{tenant}/databases
Terminal window
curl -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"
}

Poll GET /databases/{name} until status.phase is Running:

Terminal window
TIMEOUT_SECONDS=1800
ELAPSED=0
while [ "$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))
done
echo "Timed out waiting for the database" >&2
exit 1

The phases are Pending, Provisioning, Running, Upgrading, Degraded and Terminating, and Databases says what each one means. Once it runs:

Terminal window
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.observedGeneration has reached the generation in the PATCH response.
  • status.phase is Running.
  • The Ready condition in status.conditions is True.

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.

POST /api/v1/tenants/{tenant}/databases/{name}/bindings
Terminal window
curl -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:

Terminal window
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.

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:

Terminal window
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 412 means 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 namespaces is a 400, for example invalid 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 PATCH returns 409 with the code terminating.

PATCH takes JSON merge-patch: send only the fields you are changing. At least one of resources, highAvailability, deletionProtection or version is required.

Terminal window
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.

POST /api/v1/tenants/{tenant}/databases/{name}/rotate-credentials
Terminal window
curl -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
}
}
  • appliedAt is the token the database last applied. Once it equals your requestedAt, the database uses the new password and the old one no longer works.
  • delivered is true once 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.
  • pending is true while 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.

Terminal window
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.

Turn deletion protection off, then delete:

Terminal window
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.

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.