Skip to content

Control plane HTTP API

The control plane is operated through a JSON API over HTTPS. The ankka CLI is a thin client of this API: every CLI command is one call to one route listed on this page, so anything the CLI does, a script or another tool can do with the same token.

The API's address is the control plane's URL, https://api.<base domain> on an installation. A local platform prints it when it is deployed.

Authentication

Every route except GET /auth and the machine token routes (POST /oauth/token, /.well-known/jwks.json and /.well-known/openid-configuration) requires an OpenID Connect access token from the installation's identity provider, sent as a bearer token:

curl -H "Authorization: Bearer $TOKEN" https://api.127.0.0.1.sslip.io:8443/organizations

GET /auth answers without a token and says where the identity provider is, which is how ankka login starts. A person obtains a token with ankka login; a machine obtains one from the identity provider with the client-credentials grant and presents it as given. The control plane verifies the token itself, against the identity provider's published keys, and takes the token's sub claim as the caller's identity.

A registered machine is not a caller of these routes. It takes its own token from POST /oauth/token, which answers without a bearer token and authenticates the machine by its client id and secret, and presents that token to the services and the broker that grants name it.

Status Meaning
401 No token, or one that is invalid or expired. The response carries WWW-Authenticate: Bearer …. Log in again.
403 A valid token for an action its holder may not take, such as a member renaming an organization.
404 The organization, project or service does not exist, or the caller is not a member of the organization it belongs to. The two are indistinguishable on purpose.
503 The control plane cannot verify tokens right now, because it cannot read the identity provider's keys. The response carries Retry-After: 5.

Requests and responses

Request and response bodies are JSON (application/json). A route that succeeds with nothing to say answers 204 No Content with an empty body. Every error has a JSON body naming its status and the reason:

{"status":409,"error":"organization 'acme' still has 2 project(s)"}

Errors from a command use the platform's error codes: 400 for a malformed request or an invalid descriptor, 404 for something that does not exist, 409 for a request that conflicts with the current state, 503 and 504 when the control plane could not complete the call in time. See Error codes.

Every change is recorded with who made it and when, and a service's record is readable through GET /services/{projectId}/{name}/history.

From Scala

The request and response types on this page, the Role and service lifecycle vocabularies, and the service descriptor with its validation are published as one library, ankka-controlplane-api, in the package com.thinkmorestupidless.ankka.controlplane.api. It depends on ankka-core alone, so a client of the control plane carries no actor system, database driver or Kubernetes client. It is published for the JVM, under the Scala 3 artifact name; this is its sbt coordinate:

libraryDependencies += "com.thinkmorestupidless" %% "ankka-controlplane-api" % "0.16.1"

The CLI is built on the same library, so a client using it reads every answer the CLI can read and refuses an invalid descriptor with the platform's own message before sending it.

Roles

A caller's permissions come from their role in an organization, which the control plane records itself:

  • A member reads the organization, its projects and services, creates projects, and applies, pauses, resumes, restarts, exposes, unexposes and deletes services in them.
  • An owner can also rename and delete the organization and manage its members and invitations. The last owner cannot be removed or demoted.
  • A platform administrator holds the platform-admin role in the identity provider. They can read every organization, disable and enable one, set and clear its quota, and add a member to one directly. They can also do what an owner does in an organization they are not a member of, such as create a deploy token or accept a grant offered to it; each such act is recorded as administrative, so the organization's owners can see that the platform did it, not one of them.
  • A deploy token is a member of its organization, or, when it was created for one project, a member of that project alone. A project token can also make and end its project's own grants.

Anyone logged in may create an organization, and becomes its first owner.

Routes

The table is generated from the control plane's own route declarations.

Method Path Streaming
GET /organizations
GET /organizations/{organizationId}
POST /organizations/{organizationId}
PUT /organizations/{organizationId}/name
DELETE /organizations/{organizationId}
GET /organizations/{organizationId}/members
POST /organizations/{organizationId}/members
DELETE /organizations/{organizationId}/members/{subject}
PUT /organizations/{organizationId}/members/{subject}/role
DELETE /organizations/{organizationId}/invitations/{email}
POST /organizations/{organizationId}/members/{subject}/repair
GET /organizations/{organizationId}/grants
POST /organizations/{organizationId}/grants/{grantId}/accept
POST /organizations/{organizationId}/grants/{grantId}/decline
POST /organizations/{organizationId}/grants/{grantId}/relinquish
GET /organizations/{organizationId}/machines
POST /organizations/{organizationId}/machines
DELETE /organizations/{organizationId}/machines/{name}
PUT /organizations/{organizationId}/machines/{name}/byte-rates
GET /organizations/{organizationId}/tokens
POST /organizations/{organizationId}/tokens
DELETE /organizations/{organizationId}/tokens/{tokenId}
POST /organizations/{organizationId}/disable
POST /organizations/{organizationId}/enable
PUT /organizations/{organizationId}/quota
DELETE /organizations/{organizationId}/quota
POST /projects/{projectId}/erasures
GET /projects/{projectId}/erasures
GET /projects/{projectId}/erasures/{rid}
PUT /projects/{projectId}/erasures/{rid}
POST /projects/{projectId}/erasures/{rid}/withdraw
POST /projects/{projectId}/erasures/{rid}/override
GET /projects/{projectId}/erasures/{rid}/certificate
GET /projects
GET /projects/{projectId}
POST /projects/{projectId}
PUT /projects/{projectId}/name
DELETE /projects/{projectId}
PUT /projects/{projectId}/registry
DELETE /projects/{projectId}/registry
PUT /projects/{projectId}/secrets/{name}
DELETE /projects/{projectId}/secrets/{name}
PUT /projects/{projectId}/topics/{name}
GET /projects/{projectId}/topics/{name}/schema
DELETE /projects/{projectId}/topics/{name}
GET /projects/{projectId}/topics
GET /projects/{projectId}/status
POST /projects/{projectId}/rehearsals
POST /projects/{projectId}/backups/credential
GET /projects/{projectId}/rehearsals
POST /projects/{projectId}/restores
GET /projects/{projectId}/restores
GET /projects/{projectId}/restores/{name}
GET /projects/{projectId}/database
PUT /projects/{projectId}/database
GET /projects/{projectId}/history
PUT /projects/{projectId}/brokers/{name}
DELETE /projects/{projectId}/brokers/{name}
PUT /projects/{projectId}/location
DELETE /projects/{projectId}/location
GET /projects/{projectId}/brokers
GET /projects/{projectId}/secrets
GET /projects/{projectId}/secret-reads
POST /projects/{projectId}/grants
GET /projects/{projectId}/grants
GET /projects/{projectId}/grants/received
DELETE /projects/{projectId}/grants/{grantId}
POST /secret-reads
GET /erasures
GET /internal/erasure-log
GET /services/{projectId}
GET /services/{projectId}/{name}
PUT /services/{projectId}/{name}
POST /services/{projectId}/{name}/switch
POST /services/{projectId}/{name}/rollback
GET /services/{projectId}/{name}/descriptor
POST /services/{projectId}/{name}/pause
POST /services/{projectId}/{name}/resume
POST /services/{projectId}/{name}/restart
POST /services/{projectId}/{name}/storage-credential
POST /services/{projectId}/{name}/storage/settings
POST /services/{projectId}/{name}/storage/move
POST /services/{projectId}/{name}/expose
POST /services/{projectId}/{name}/unexpose
PUT /services/{projectId}/{name}/hostnames/{hostname}
DELETE /services/{projectId}/{name}/hostnames/{hostname}
GET /services/{projectId}/{name}/logs
GET /services/{projectId}/{name}/topology
GET /services/{projectId}/{name}/history
DELETE /services/{projectId}/{name}
GET /auth/whoami
GET /installation/restore
POST /installation/restore/release
GET /installation
POST /oauth/token
GET /.well-known/jwks.json
GET /.well-known/openid-configuration
POST /platform/machine-keys/rotate
GET /auth

Path parameters are shown in braces. Identifiers for organizations and projects are lowercase letters, digits and -, starting with a letter; a project id must also fit in a Kubernetes namespace name, so it is at most 57 characters. The project id platform is reserved for the platform's own workloads, and creating a project with it answers 400.

Identity

POST /secret-reads

Where a service writes the record of each read, keep and removal of one of its secrets, before it uses the value. A service calls it, never a person: it is admitted by the certificate the platform issued the service, and refused to the internet. A record must name the calling service's own project and name, or it is refused with 403; one with an operation or outcome the platform does not write is 400. Answers 204 once the record is kept. A service that is answered anything else, or nothing in time, refuses the read it was recording, so no value is used without its record.

GET /auth

Where to log in. No token needed. Response: {"issuer": "...", "clientId": "...", "audience": "..."}, the identity provider's issuer URL, the public client id the CLI logs in as, and the audience the control plane expects on a token.

GET /auth/whoami

The caller as the control plane sees them. Response:

Field Type Meaning
subject string The token's sub: the caller's stable identity.
name string, optional Display name from the token.
email string, optional Email address from the token.
emailVerified boolean Whether the identity provider verified the email address.
platformAdmin boolean Whether the caller holds the platform-admin role.
organizations array { "id", "name", "role" } for every organization the caller belongs to.

Calling it also claims any pending invitation addressed to the caller's verified email.

GET /installation

What the installation is: its version, its cloud when it names a cloud provider, and where its backups go and how safely. Any caller with a token may read it. Response:

Field Type Meaning
platformVersion string The version of the platform the control plane runs.
cloud object, optional Absent when the installation names no cloud provider.
cloud.provider string The installation's cloud provider, such as gcp.
cloud.account string The one cloud account the installation's cloud resources are made in.
cloud.location string Where they are made unless a project says otherwise.
cloud.kmsKey string, optional The key the installation wraps with; shown only to an owner of an organization or a platform administrator.
secrets object, optional Where the installation keeps its secrets; absent from an older control plane.
secrets.backend string postgres (each service's own database and the cluster's Secrets) or secret-manager.
secrets.recordRetention string How long the record of secret reads is kept, such as 365d.
secrets.auditLog string Whether the cloud's own access log for its secrets is on: on, off, or unknown until the cloud provider reports it.
broker object, optional The installation's broker; absent from an older control plane.
broker.nodes number, optional How many nodes the broker has; absent when the control plane was not told, which means not stated.
broker.defaultCopies number The copies a topic is given when its declaration states none.
broker.minInSync number The copies that must hold a message before it is acknowledged, when a declaration states none.
retention object, optional The longest retention a topic may be given; absent from an older control plane.
retention.longest string, optional The bound as a duration, such as 30d; absent when there is none.
retention.unbounded boolean, optional true when a topic may keep its messages for ever; absent when there is a bound.
erasure object, optional Whether erasure is on (enabled, from ANKKA_ERASURE) and how many keyring instances hold its keys (keyringInstances); absent from an older control plane. See Erasure on the platform.

| backups | object | Where the installation's backups go, and how safely. |

A fact the control plane cannot read is absent rather than guessed, so a requirement that names one reads it as not stated. For example "broker": { "nodes": 3, "defaultCopies": 3, "minInSync": 2 }, "retention": { "unbounded": true }.

backups is, for example, { "backupTarget": "object-store", "retentionDays": 30, "copyRequired": false, "sharesFailureDomain": true, "encryption": "none: …" }.

backupTarget is none or object-store, the installation's own object store; with none, notBackedUp says that nothing is backed up. retentionDays is the least any project keeps its backups: a project may keep them longer, never for less time. copyRequired says whether a project is backed up only once its latest base backup has a copy outside the cluster. sharesFailureDomain is true when the backups are in the cluster they back up, so that losing the cluster loses them too. encryption says how a backup is encrypted at rest, or none and why. controlPlane is the control plane's own database's backups, shaped as a project's line, when the control plane can read them. secondaryStore is the installation's copy of every bucket outside the cluster, where it has one: { "lastCompleted": "…", "lastFailed": "…", "failure": "…", "buckets": 12, "deletedObjects": 1 }. Once a copy has completed, sharesFailureDomain is false.

Organizations

An organization summary is:

{
  "id": "acme", "name": "Acme Corp", "projects": 2, "disabled": false, "role": "owner",
  "quota": { "projects": 2, "services": 5, "instances": 8 },
  "usage": { "projects": 2, "services": 3, "instances": 5 }
}

role is the caller's role, owner or member, and is absent for a platform administrator looking at an organization they do not belong to. quota is the organization's quota and is absent when none is set; a limit absent from it is unlimited. usage is what the organization records as holding, counting every service's minInstances as its instances, and is absent when every count is zero. projects is the listing's count of the organization's projects; usage.projects is the organization's own record, and the two differ only for an organization created before the installation had quotas and not yet given one.

GET /organizations

The organizations the caller belongs to, as a list of summaries. A platform administrator sees every organization.

GET /organizations/{organizationId}

One organization, as a summary. Members only.

POST /organizations/{organizationId}

Creates an organization. Body: { "name": "Acme Corp" }. Any authenticated caller; the caller becomes its first owner. An id that was ever used, including by a deleted organization, is refused with 409. Answers 204.

A platform administrator may name the first owner instead:

{
  "name": "Acme Corp",
  "owner": { "subject": "3f2a9c1e-…", "email": "alice@example.com", "display": "Alice Example" }
}

owner.subject is required and email and display are optional. The organization's only member is then that subject, as owner, and the administrator is recorded as the one who created it. From a caller without the platform-admin role, a body with owner is refused with 403, platform administrator role required to name an owner.

When the installation's creation policy is platform-admin, set by ANKKA_ORGANIZATION_CREATION, a caller without the role is refused with 403: organizations in this installation are created by the platform administrator, followed by ; sign up at <url> when ANKKA_SIGNUP_URL is set. Neither refusal creates anything.

PUT /organizations/{organizationId}/name

Renames an organization. Body: { "name": "Acme Corporation" }. Owners only. Answers 204.

DELETE /organizations/{organizationId}

Deletes an organization. Owners only. Refused with 409 while it still has projects. The id is never reusable. Answers 204.

GET /organizations/{organizationId}/members

Members and pending invitations. Members only. Response:

{
  "members": [ { "subject": "3f1c…", "role": "owner", "email": "ada@example.com", "display": "Ada", "since": "2026-09-01T10:00:00Z", "addedBy": "Ada" } ],
  "invitations": [ { "email": "bob@example.com", "role": "member", "invitedAt": "2026-09-02T09:00:00Z", "invitedBy": "Ada" } ]
}

POST /organizations/{organizationId}/members

Invites an email address. Body: { "email": "bob@example.com", "role": "member" }, where role defaults to member. Owners only. The invitation becomes a membership the first time a token carrying that email address, verified by the identity provider, reaches the control plane. Answers 204.

DELETE /organizations/{organizationId}/members/{subject}

Removes a member by subject. Owners only. The removal takes effect on the member's next request. Answers 204.

PUT /organizations/{organizationId}/members/{subject}/role

Changes a member's role. Body: { "role": "owner" }. Owners only. Answers 204.

DELETE /organizations/{organizationId}/invitations/{email}

Withdraws an invitation that has not been claimed. Owners only. Answers 204.

POST /organizations/{organizationId}/members/{subject}/repair

Adds a member directly by subject, for an organization whose owners have all left. Body: { "role": "owner" }, where role defaults to owner. Platform administrators only. Answers 204.

GET /organizations/{organizationId}/tokens

Lists the organization's deploy tokens, newest first. Owners, or a platform administrator. Each entry carries id, label, subject, createdBy, createdAt, expiresAt (absent when the token never expires), lastUsed (a date, absent when it has never been used), project (the project the token is scoped to, absent for a token of the whole organization) and administrative (true when a platform administrator, not an owner, created it; absent otherwise). No entry carries the secret, which exists only in the response that created it. A token whose project was deleted is listed with that project until it is revoked.

POST /organizations/{organizationId}/tokens

Creates a deploy token: a credential a machine can hold, which authenticates as a member of this organization, or of one of its projects. Body: { "label": "github-deploy", "expiresIn": 7776000, "project": "storefront" }, where label is for people and expiresIn is seconds — absent means 90 days, and 0 means a token that never expires. The maximum is 365 days. project is optional: absent, the token is a member of the whole organization; present, it must name a project of this organization, or the request is refused 404 no such project, what a stranger is told. Owners, or a platform administrator who need not be a member, whose act is recorded as administrative.

Answers 200 with { "id", "label", "secret", "subject", "expiresAt", "project" }. secret is shown here and nowhere else: it is stored only as a one-way digest, so a lost token is replaced rather than recovered. Present it as Authorization: Bearer <secret>, or give it to the CLI through ANKKA_TOKEN.

The token's subject is token:<id>, an ordinary member of the organization — so it is authorized, attributed and made invisible outside its organization by exactly the rules that apply to a person. It can do everything a member can, and nothing an owner can: it cannot invite members, rename or delete the organization, or manage deploy tokens, including its own.

A token created for one project is a member of that project and of nothing else:

Route Organization token Project token, its project Project token, another project
a project's services, topics, secrets, exposure, status and history yes yes 404 no such project
GET /organizations/{organizationId}/projects every project its project only —
POST /projects/{projectId}/grants, DELETE /projects/{projectId}/grants/{grantId} 403 yes 404 no such project
GET /projects/{projectId}/grants, GET /projects/{projectId}/grants/received yes yes 404 no such project
accepting, declining or relinquishing a grant 403 403 403
members, tokens and the organization's own settings 403 403 403

A project token makes its project's grants because an agent that deploys into one project has to open what it deploys. A grant it makes to another organization is pending, as any such grant is, and it cannot accept one. Every change it makes is attributed to token:<id>. Its scope survives the project's renaming; once the project is deleted, the token is told there is no such project.

DELETE /organizations/{organizationId}/tokens/{tokenId}

Revokes a deploy token and removes its membership. Owners only. Answers 204, and 404 for a token that does not exist, was already revoked, or belongs to another organization — the three are one answer so that a guessed id discloses nothing.

The control plane node that handles the revocation refuses the token on the very next request. Other nodes refuse it within the platform's read-refresh interval, since each one learns from the token's journal; the id is never reused.

GET /organizations/{organizationId}/grants

What the organization's registered machines and the services of every project it owns hold, or are offered, from other projects. Members only. Each entry is a received grant: its granting project and organization, the grantee, the target, its state, every change made to it with who made it and when, and for a topic grant the topic's partitions and whether it is compacted.

POST /organizations/{organizationId}/grants/{grantId}/accept

Accepts a pending grant offered to the organization: one naming a registered machine of it, or a service of one of its projects. Owners, or a platform administrator who need not be a member; a member or a deploy token is refused with 403. The grant takes effect, on the granting project's listing and its history, with whoever accepted it, and an administrator's acceptance is marked administrative. A grant not offered to this organization is 404, whoever asks, so an id discloses nothing; a grant that is no longer pending is 409. Answers 204.

POST /organizations/{organizationId}/grants/{grantId}/decline

Declines a pending grant offered to the organization. It ends and never takes effect. Owners only, with the same answers as accepting.

POST /organizations/{organizationId}/grants/{grantId}/relinquish

Gives up an accepted grant the organization holds, without the grantor. What it opened closes as a revoked grant's does. Owners only; a grant that is not accepted is 409.

GET /organizations/{organizationId}/machines

Lists the organization's registered machines, by name. Members only. Each entry carries name, clientId (machine:<organization>/<name>), registeredBy, registeredAt and byteRates, absent while the installation's defaults apply. No entry carries the secret.

POST /organizations/{organizationId}/machines

Registers a machine outside the installation: a system another organization runs, which takes tokens and calls what a project grants it. Body: { "name": "affiliate-network" }, a name under the service name rule. Owners only; a deploy token is refused. A name registered and not deleted is 409.

Answers 200 with { "name", "clientId", "clientSecret", "tokenUrl", "brokerBootstrap" }. clientSecret is shown here and nowhere else: it is kept only as a one-way digest. tokenUrl is the token route below; brokerBootstrap is the broker's address outside the cluster, absent when the installation does not expose it. A machine holds no grant until a project grants it one. See Cross-project access.

DELETE /organizations/{organizationId}/machines/{name}

Deletes a machine. Owners only. Answers 204, and 404 for one that is not registered. Its secret is refused from then on, and the name may be registered again as a new machine, with a new secret and no grant.

PUT /organizations/{organizationId}/machines/{name}/byte-rates

Limits what a machine may move through the installation's broker. Body: { "produceBytesPerSecond": 1048576, "consumeBytesPerSecond": 4194304, "requestPercentage": 50 }. Each rate is more than nothing and at most the installation's ceiling, and requestPercentage is from 1 to 100; anything else is 400 naming every problem. Owners only. Answers the machine's entry with its new rates.

POST /organizations/{organizationId}/disable

Disables an organization: every service in its projects is suspended, and its members can read but change nothing. Platform administrators only. Answers 204.

POST /organizations/{organizationId}/enable

Re-enables a disabled organization. Each service returns to the state it was in; one its members had paused stays paused. Platform administrators only. Answers 204.

PUT /organizations/{organizationId}/quota

Sets the organization's quota, replacing any. Platform administrators only, allowed on a disabled organization. The body names up to three limits:

{ "projects": 2, "services": 5, "instances": 8 }

A limit left out or null is unlimited; 0 allows none. Refused 400 when a limit is negative or when no limit is named. Accepted whatever the organization already holds: nothing running is stopped, and a quota below usage refuses only what is asked for next. Setting a quota also brings the organization's usage record up to date with what exists, for an organization created before the installation had quotas. Answers 204.

From then on, POST /projects/{projectId} is refused 409 when the organization's project count is at its quota, and PUT /services/{projectId}/{name} is refused 409 when a new service would exceed the service quota or when the descriptor's minInstances would take the organization's instances past the instance quota; each refusal names the quota and the count in use. A re-apply with the same or fewer instances is never refused for quota.

DELETE /organizations/{organizationId}/quota

Clears the quota; the organization is unlimited again. Platform administrators only. Answers 204, also when there was no quota.

Projects

A project summary is:

{
  "id": "checkout",
  "name": "Checkout",
  "organizationId": "acme",
  "services": 3,
  "registry": {
    "server": "ghcr.io",
    "username": "octocat",
    "setAt": "2026-09-25T10:00:00Z",
    "setBy": "sam@example.com"
  }
}

registry is absent unless the project has a registry credential, and never carries the password.

GET /projects

The projects in the caller's organizations, ordered by name. The optional query parameter organization narrows the list to one organization; naming one the caller does not belong to yields an empty list.

GET /projects/{projectId}

One project, as a summary. Members of its organization only.

POST /projects/{projectId}

Creates a project. Body: { "name": "Checkout", "organizationId": "acme" }. Members of that organization only. An invalid id is refused with 400; an id that was ever used is refused with 409. Answers 204.

PUT /projects/{projectId}/name

Renames a project. Body: { "name": "Checkout team" }. Members of its organization. Answers 204.

DELETE /projects/{projectId}

Deletes a project. Refused with 409 while it still has services. The id is never reusable. Answers 204.

PUT /projects/{projectId}/registry

Registers the credential the cluster pulls this project's private images with. Body: { "server": "ghcr.io", "username": "octocat", "password": "…" }. Members of its organization, including deploy tokens. Answers 204.

The credential is written to the cluster before anything is recorded, and a cluster that could not be written answers 503 with the reason and records nothing — so the platform never claims a credential the cluster does not hold. The password appears in no reply, no listing and no history; a server given as a URL rather than a host is refused with 400.

DELETE /projects/{projectId}/registry

Stops claiming the project's registry credential. Answers 204, or 404 if none is set.

The Secret itself is left in the cluster — the control plane holds no permission to delete one — and the next deploy of each service in the project stops naming it.

PUT /projects/{projectId}/secrets/{name}

Sets entries of a project secret, which a descriptor's variable takes by secretKeyRef. Body: { "entries": { "STRIPE_KEY": "…" } }. Members of the project's organization, including deploy tokens. Answers 204.

The entries named are added or replaced and every other entry of the secret is kept. They are written to a Kubernetes Secret in the project's namespace before anything is recorded, and a cluster that could not be written answers 503 and records nothing. No value appears in any reply, listing or history: the control plane records the secret's name and its entries' names. A name the platform uses for its own Secrets (beginning ankka-, or ending -db, -cluster-tls, -service-tls, -database-tls, -secret-key or -telemetry), a malformed name or entry, an empty value or one over 64 KiB is refused with 400, every problem at once.

DELETE /projects/{projectId}/secrets/{name}

Removes the one entry named by the entry query parameter: DELETE /projects/shop/secrets/checkout?entry=STRIPE_KEY. Answers 204, or 404 when the project secret has no such entry, in which case nothing is written. The Secret itself is never deleted; one with no entry left is no longer listed.

GET /projects/{projectId}/secrets

The project's secrets, by name: [{ "name": "checkout", "entries": ["STRIPE_KEY"], "setAt": "…", "setBy": "…" }]. From the control plane's own record, so a secret just set is listed at once. Never a value — the control plane cannot read a Secret back.

GET /projects/{projectId}/secret-reads

The record of the project's secret reads, newest first: which service read, kept or removed which service secret, and when. Owners only: a member who is not an owner, and a deploy token, are refused with 403; someone outside the organization is told the project does not exist (404).

Query parameters narrow it: service and name match exactly, from (inclusive) and to (exclusive) are RFC 3339 instants, and limit is how many records to answer, from 1 to 1000 (200 when omitted). Response: { "records": [ … ] }, each record:

Field Type Meaning
at instant When the service asked.
project, service string Whose read it was, as the service's certificate names it.
hosting string embedded, process or module.
name string The secret's name. A record never holds a value.
operation string get, put or delete.
outcome string read, none, refused, unavailable, written or removed.
backend string postgres or secret-manager.
traceId, spanId string, optional The trace, and the handler's span within it.
component, componentKind string, optional The component that asked, when the runtime ran it; never for a process or a module.
latestSkipped boolean On Secret Manager: a newer version had been disabled, and was not the one read.

Records older than the installation's retention are removed; GET /installation says how long that is.

PUT /projects/{projectId}/topics/{name}

Declares a topic on the project, which the platform makes on the installation's broker as <projectId>.<name>, or changes a declared topic: more partitions, its settings, its contract. Body: { "partitions": 12, "retention": "90d", "retentionSize": "50GiB", "cleanup": "delete", "tombstoneWindow": "1d", "minCompactionLag": "0s", "maxCompactionLag": "none", "copies": 3, "minInSync": 2, "compacted": false, "contract": { "name": "order.v1", "schema": { … } }, "removes": "messages older than 30d" }. Every field is optional but partitions on a topic's first declaration. A first declaration takes each setting it leaves out from the installation's defaults; a declaration of a topic already declared keeps each setting it leaves out, partitions included. Durations are a whole number and ms, s, m, h or d, and retention may be everything; sizes are a whole number and B, KiB, MiB, GiB or TiB, and retentionSize and maxCompactionLag may be none; cleanup is delete, compact or compact,delete, and compacted: true is its short form. Members of the project's organization, including deploy tokens. Answers 204.

A project holds one declaration per topic, and every service of the project uses the topic by its name. Declaring a topic again as it is records nothing. A value that is not one of the words above, compacted and cleanup that disagree, a setting past the installation's longest retention time, largest retention size or most copies, and a minimum in-sync copies outside 1 to the copies are refused with 400, naming the setting. A change to a topic's copies or minInSync, or either stated for a topic whose copies are the broker's, is refused with 409: they are fixed when a topic is declared, and nothing else in the declaration is applied.

A declaration that removes messages — a shorter retention time, a smaller retention size, or a compacted topic that starts deleting by age — is an owner's: a member who is not an owner is refused with 403, naming what it removes, and so is a deploy token. Its body must also say what it removes, in removes, exactly as the control plane words it; without it, or with other words, it is refused with 400, naming what the declaration would remove, so a client shows that to its user and sends the declaration again saying so. removes on a declaration that removes nothing is refused with 400 too. A name that is not lower-case letters, digits, - and . starting and ending with a letter or digit, or is over 100 characters, partitions outside 1 to 1000, a contract name outside [a-z0-9][a-z0-9._-]{0,98}[a-z0-9], and a schema that is not JSON or is over 64 KiB, are refused with 400, every problem at once. Fewer partitions than the project declares is refused with 409, naming both counts: a topic is never made smaller.

A contract's schema is written to the project's schema store in the cluster before the declaration is recorded, under its fingerprint — sha256: and the SHA-256 of the document under RFC 8785 — so the record never names a document the cluster does not hold; a cluster that could not be written answers 503 and records nothing.

GET /projects/{projectId}/history

The changes to the project's topics, newest first, as many as a service's history keeps: [{ "kind": "topic-changed", "topic": "transactions", "actor": { "subject": "…", "display": "Ada", "administrative": false }, "at": "2026-10-08T10:00:00Z", "changes": [{ "setting": "retention", "from": "90d", "to": "180d" }] }]. kind is topic-declared, topic-changed, topic-removed, or topic-filled for a topic declared before topics stated their settings, which the control plane filled from the installation's defaults when it started, with no actor. A declaration that changed nothing is not listed. Members only.

GET /projects/{projectId}/topics/{name}/schema

The schema document a topic's contract was declared with, as JSON, exactly as it was given. Members only. 404 when the project declares no contract on the topic.

DELETE /projects/{projectId}/topics/{name}

Stops declaring a topic. Answers 204, or 404 when the project declares no topic of that name. The topic and what was published to it stay on the broker; declaring it again finds them.

GET /projects/{projectId}/topics

The project's declared topics, by name: [{ "name": "orders", "partitions": 3, "compacted": false, "settings": { "retention": "7d", "retentionSize": "none", "cleanup": "delete", "tombstoneWindow": "1d", "minCompactionLag": "0s", "maxCompactionLag": "none", "copies": 1, "minInSync": 1, "defaulted": ["retention", "retentionSize", "cleanup", "tombstoneWindow", "minCompactionLag", "maxCompactionLag", "copies", "minInSync"] }, "copiesHeld": 1, "brokerNodes": 1, "contract": { "name": "order.v1", "fingerprint": "sha256:…" }, "phase": "provisioned", "checks": [ { "topic": "orders", "service": "wallet", "component": "consumer:relay", "direction": "publishes", "stated": "order.v1", "state": "checked" } ] }]. From the project's own record, so a topic just declared is listed at once. phase says how far the platform has got with it — waiting for broker, provisioned, recovered or failed, with a detail — and is absent until the operator has reported on the topic, or when the cluster cannot be read. checks lists each side a running service takes on a topic with a contract, read from the services' instances: checked when the component states the declared contract, mismatch when it states another or none, unchecked for an instance started before the declaration; empty for a topic without a contract or when no instance could be read. settings names every setting in words and, in defaulted, those the installation supplied; copies and minInSync are absent for a topic whose copies are the broker's. copiesHeld is how many copies the broker holds of the topic and brokerNodes how many nodes the broker has, as the operator last read them; the detail says when a topic's copies are the broker's, below the installation's default, or a single copy.

PUT /projects/{projectId}/brokers/{name}

Declares a broker beside the installation's, which a component of any service in the project may name for one topic, or changes where it is. Body: { "bootstrap": "kafka.legacy:9094", "shape": "sasl", "secret": "legacy-credential" }. Members of the project's organization, including deploy tokens. Answers 204.

shape is certificate (the project secret holds ca.crt, tls.crt and tls.key) or sasl (it holds ca.crt, username and password, and optionally mechanism); every shape is over TLS. A name outside a topic's rule, a bootstrap that is not host:port[,host:port], another shape, or a secret name the platform reserves is refused with 400; a secret the project has not set, or one lacking an entry the shape needs, is refused with 400 naming the entry. Declaring a broker again as it is records nothing.

DELETE /projects/{projectId}/brokers/{name}

Stops declaring a broker. Answers 204, or 404 when the project declares no broker of that name. A service whose component names the broker is refused at its next start.

PUT /projects/{projectId}/location

Names where the project's new buckets in Google Cloud Storage are made, { "location": "europe-west6" }, in the installation's own words; its cloud provider says what they mean. A bucket's location is fixed when it is made, so this moves no bucket, and each service's status shows where its bucket is. Members with write access. Answers 204; 400 for an empty location; 409 where the installation keeps new buckets in Garage, which has no location to choose.

DELETE /projects/{projectId}/location

Lets the installation's default location apply to the project's new buckets again. Answers 204, or 409 where the installation keeps new buckets in Garage.

GET /projects/{projectId}/brokers

The project's declared brokers, by name: [{ "name": "legacy", "bootstrap": "kafka.legacy:9094", "shape": "sasl", "secret": "legacy-credential", "declaredAt": "…" }]. Never a credential.

GET /projects/{projectId}/status

The project's backups and database. Members of the project's organization. Answers { "id": "shop", "backedUp": true, "target": "object-store", "lines": [ … ], "database": { … } }.

backedUp is true when every line of history is archiving and has a base backup, and, where the installation requires a copy outside its failure domain, that copy exists. target is none when the installation backs nothing up, and detail then says so.

Each entry of lines is one line of history's backups: { "line": "ankka-db", "cluster": "ankka-db", "phase": "backing up", "lastBaseBackup": "…", "firstRestorable": "…", "lastRestorable": "…", "archiveLagSeconds": 12.5 }. phase is backing up, failing or not backed up, with the reason in failing, in the archiver's words where it has them. firstRestorable and lastRestorable bound the moments the project can be restored to; lastRestorable is now less archiveLagSeconds, since a write the archive has not reached cannot be restored. copiedAt is when the latest base backup was copied outside the failure domain, where that is required.

database is the project database: { "cluster": "ankka-db", "instances": 3, "readyInstances": 3, "primary": "ankka-db-1", "synchronous": true }, with writesWaitingOn when a synchronous write is waiting for a replica. Read live from what the operator last reported; a cluster that cannot be read answers as one that has not reported, and detail says so.

clusters lists the project's database clusters: { "name": "ankka-db", "line": "ankka-db", "phase": "live", "services": ["wallet"] }. phase is live, restore, or left once every service has moved off it. The platform removes none of them. restores is every restore of the project, as GET /projects/{projectId}/restores lists them. rehearsal is the latest rehearsal that ended; one that failed, or whose database was not removed, makes backedUp false and is named in detail, as a backup failure is.

POST /projects/{projectId}/restores

Restores the project's database to a moment. Owners of the project's organization only; a member who is not an owner is answered 403. Body: { "moment": "2026-10-08T09:20:00Z", "line": "ankka-db" }. The restore is a new database cluster beside the current one, made from the line's latest base backup before the moment and its archive up to it; nothing in the current database changes. Answers the restore, { "name": "ankka-db-r202610081012", "line": "ankka-db", "moment": "…", "phase": "Restoring" }.

line may be left out when every service of the project is on one cluster; with services on more than one, it is refused with 400 naming them. A moment before the earliest the line can be restored to, or after the latest, is refused with 400 naming both. A second restore while one has not ended is refused with 409 naming it. With no backup target, every restore is refused with 400.

GET /projects/{projectId}/restores

The project's restores, oldest first: [{ "name": "ankka-db-r202610081012", "line": "ankka-db", "moment": "…", "phase": "Verified", "requestedBy": "Ada", "requestedAt": "…", "reachedAt": "…", "services": [ … ] }]. phase is Restoring, Verified, Failed (with the reason in detail) or InUse once a service is switched to it. Members of the project's organization.

GET /projects/{projectId}/restores/{name}

One restore. Each entry of services says what one service's database holds in it: { "name": "wallet", "present": true, "journalRows": 1200, "stateRows": 3, "offsetRows": 42, "timerRows": 0, "highestSequence": 98765, "changedSecrets": ["stripe-key"] }. present is false for a service that had no database at the moment. changedSecrets names each service secret changed after the moment, never a value. reachedAt is the newest event any service's database holds. Answers 404 for a name the project has no restore of.

Once verified, the restore also says what it cannot take back: the broker. broker lists each topic the restore's services publish to or read with messages newer than the moment, { "topic": "transactions", "group": "ankka.shop.totals.view.sums", "after": 3, "read": 2, "services": ["totals"] }: after is the messages on the topic newer than the moment, and for a group, read is how many of them it has read. It is asked of the services' running instances as the restore is read, so it grows as they publish. notAsked names a service none of whose instances answered. note says that a message published again from a restored journal is read again by every view and consumer that reads its topic.

POST /projects/{projectId}/rehearsals

Rehearses a restore of the project. Members of the project's organization, since a rehearsal changes nothing of the project. Body: { "moment": "2026-10-08T09:20:00Z", "line": "ankka-db" }; both may be left out, for the latest moment the project database can be restored to. The rehearsal restores into a database cluster in the project's rehearsal namespace, ankka-<project>-rehearsal, checks it as a restore is checked, records how long it took and removes it. Answers the rehearsal, { "name": "ankka-db-x202610090000", "line": "ankka-db", "moment": "…", "outcome": "Running" }. A moment outside the line's window is refused with 400 naming both ends, a second rehearsal while one runs with 409, and every rehearsal with 400 when the installation has no backup target.

GET /projects/{projectId}/rehearsals

The project's rehearsals, oldest first: [{ "name": "ankka-db-x202610090000", "line": "ankka-db", "moment": "…", "outcome": "Completed", "requestedBy": "Ada", "requestedAt": "…", "elapsedSeconds": 118, "services": [ … ] }]. outcome is Running, Completed, Failed or NotRemoved, the last for a rehearsal whose database could not be removed when it ended, which is removed when its time to live passes. A rehearsal the project's schedule started has no requestedBy. The latest hundred are kept.

POST /projects/{projectId}/backups/credential

Issues the project's backup credential again. Owners of the project's organization only. The operator makes a new key for the project's backup bucket, writes it where the archiver reads it, and deletes the old one, so a key that leaked stops working; the archiver uses the new one from its next upload. Answers { "project": "shop", "generation": 2 }, the count of times it was issued again. Refused with 400 when the installation has no backup target.

GET /projects/{projectId}/database

What the project asks of its database: { "replicas": 2, "synchronous": true, "retentionDays": 45, "rehearse": "weekly" }, each at its default when the project has asked nothing. Members of the project's organization.

PUT /projects/{projectId}/database

Sets what the project asks of its database, whole: the body is GET's answer, and a field left out is its default. replicas is between 0 and 4, each a replica beside the primary that takes its place if it is lost. synchronous makes every write wait for a replica to hold it, and needs a replica. retentionDays is how long the project's backups are kept, never shorter than the installation's retention. rehearse is daily or weekly. Members of the project's organization; every problem is refused at once with 400. Answers the setting.

GET /projects/{projectId}/history

Who did what to the project's database, newest first: [{ "kind": "restore-requested", "by": "Ada", "at": "…", "detail": "ankka-db-r202610081012, of ankka-db at …" }]. kind is restore-requested, restore-completed, restore-failed, rehearsal-requested, rehearsal-completed, rehearsal-failed or database-set. Members of the project's organization.

POST /projects/{projectId}/grants

Grants one grantee one target of the project's own. Body: { "grantee": "service:payments/merchant", "target": { "kind": "route", "service": "wallet", "method": "POST", "path": "/v1/wallets/{player}/{currency}/deposits" } }. Owners of the project's organization, or a deploy token created for this project; a member or an organization's deploy token is refused with 403. Answers 200 with the grant.

A grantee is service:<project>/<service> or machine:<organization>/<name>. A target is one of:

  • { "kind": "route", "service", "method", "path" }: one HTTP route of one of the project's services, by its method and its path template;
  • { "kind": "method", "service", "method": "WalletService/Deposit" }: one gRPC method;
  • { "kind": "topic", "topic", "right": "consume" | "produce", "decrypt": false }: one of the project's declared topics, with one right; decrypt only with consume;
  • { "kind": "erasure" }: the right to ask for the erasure of the project's data subjects.

There is no wildcard: a grant names one grantee and one target. A grantee of the project's own organization is granted at once ("state": "accepted"); one of another organization is pending until an owner there accepts it. Granting what is already granted and live answers the existing grant and records nothing. A malformed grantee or target, a grant to one of the project's own services, and decrypt on a produce grant are refused with 400, every problem at once; a topic the project has not declared, a grantee project that was never created and an organization that does not exist are refused with 404.

GET /projects/{projectId}/grants

The project's grants, live and ended, oldest first: [{ "id": "3f9c…", "grantee": "service:payments/merchant", "target": { … }, "state": "accepted", "effect": "in effect", "granted": { "by": "Ada", "at": "…" }, "answered": { … }, "ended": { … } }]. Members of the project's organization. state is pending, accepted, declined, withdrawn, revoked, relinquished or lapsed; effect is in effect, or why the grant opens nothing: its state's word, route not seen, route not grantable, rollout needed or broker not exposed. Each act (granted, answered, ended) carries "administrative": true when a platform administrator did it under that role, and no such field otherwise.

GET /projects/{projectId}/grants/received

What other projects granted this project's services, with every change and who made it: [{ "id": "3f9c…", "grantingProject": "spinvibe", "grantingOrganization": "eitheror", "grantee": "service:payments/merchant", "target": { … }, "state": "accepted", "changes": [ { "change": "made", "by": "Ada", "at": "…" } ], "topic": { "partitions": 3, "compacted": false } }]. Members of this project's organization. A change carries "administrative": true when a platform administrator made it under that role. topic is the granted topic's settings, for a topic grant. A change appears here a moment after it is made on the granting project, which is the one record a change is written to.

DELETE /projects/{projectId}/grants/{grantId}

Ends a grant from the grantor's side: a pending grant is withdrawn and an accepted one revoked. Owners of the project's organization, or a deploy token created for this project. Answers 204; 404 for a grant the project does not hold, 409 for one that has already ended. An ended grant is never reopened; granting the same again makes a new one.

Erasure requests

A project's requests to erase a data subject's personal fields. See Erasure on the platform. While the installation's erasure is off, every write below is refused with 409, erasure is off on this installation (ANKKA_ERASURE).

POST /projects/{projectId}/erasures

Asks for an erasure. Body: { "subject": "player/8c1f", "notBefore": "2031-10-08T00:00:00Z", "reason": "aml-retention", "correlation": "closure-4411" }, every field but subject optional. A member of the project's organization, or a service, as itself, of any project holding an accepted erasure grant of this one (its own project included); a service without one is refused with 403, and the refusal is recorded in the project's history as erasure-refused. Answers 201 with the request: held with a notBefore still to come, otherwise applied at once, after the answer. A data subject outside the rule is refused with 400.

GET /projects/{projectId}/erasures

The project's requests, newest first: [{ "id", "subject", "state", "correlation", "notBefore", "askedBy", "askedAt", "appliedAt", "finalAt" }]. Narrowed by subject, state (held, applying, applied, withdrawn) and correlation; at most limit rows, 100 by default and at most 1000. Members.

GET /projects/{projectId}/erasures/{rid}

One request: its state, who asked and when, its hold (notBefore, reason) and any override, when its key was destroyed (destroyedAt), each service's completion (completions: service, completedAt, rowsRedacted, tokensDropped, sessionsEnded, and the erasure handler's objectsErased, finality and any error), when the objects are final (objectsFinalAt) and the erasure final (finalAt), and, while an attempt to apply it has failed, lastFailure (reason, at). Members.

PUT /projects/{projectId}/erasures/{rid}

Replaces a held request's notBefore and reason. Members; 409 once it is not held.

POST /projects/{projectId}/erasures/{rid}/withdraw

Withdraws a held request. Members; 409 for an applied request, which nobody can withdraw.

POST /projects/{projectId}/erasures/{rid}/override

Applies a held request at once. Body: { "reason": "regulator order 2028/41" }, recorded with the owner's name. Owners of the project's organization; a member is refused with 403.

GET /projects/{projectId}/erasures/{rid}/certificate

The erasure certificate of an applied request: request, project, subject, askedBy, askedAt, hold, holdOverride, keyDestroyedAt, each service's completion (services) and finalAt. It holds no personal field. Members; 409 before the request is applied.

GET /erasures

The requests carrying one correlation id (?correlation=closure-4411) in every project the caller is a member of, each naming its project. A project the caller may not read is left out.

GET /internal/erasure-log

The installation's erasure log after ?after=<seq>, oldest first, for the keyring to apply before it answers anything. The keyring's identity alone.

Installation

GET /installation/restore

Whether the control plane is held after its own database was restored. Any authenticated caller. Answers { "held": true, "restoredAt": "…", "targetTime": "…", "services": [ … ], "topics": ["shop"], "unknownProjects": ["lab"] }. Held, the control plane changes nothing in the cluster: services lists each service whose recorded generation or image is not what the cluster runs, { "project": "shop", "service": "cart", "recordedGeneration": 1, "clusterGeneration": 2, "recordedImage": "cart:1", "clusterImage": "cart:2" }, topics each project whose declared topics differ from the cluster's, and unknownProjects each project the cluster holds that the restored database does not know. releasedAt and releasedBy say when the hold was released, and by whom.

POST /installation/restore/release

Releases the hold: from its next sweep the control plane makes the cluster what its database records. A platform administrator's alone; anyone else is answered 403. Recorded on the restore marker with who released it. Answers as GET /installation/restore does.

Machine tokens

A registered machine takes a token from the control plane by OAuth 2.0 client credentials, and presents it as Authorization: Bearer <token> through the gateway. A service verifies it with the keys below and reads the caller as that machine. These routes take no token of their own.

POST /oauth/token

Answers a token for a registered machine. The body is a form, grant_type=client_credentials with client_id and client_secret, or the two as HTTP Basic. Answers 200 with { "access_token", "token_type": "Bearer", "expires_in": 900 }: the token lives fifteen minutes, and a machine takes a new one as it needs.

An unknown client id, a wrong secret and a deleted machine are all 401 with {"error": "invalid_client"}, so the route tells a guesser nothing. Another grant type is 400 unsupported_grant_type, and a request with none 400 invalid_request. More requests a minute for one client id than the installation allows (twelve unless it says otherwise, counted per control plane instance) are 429 with Retry-After, the seconds until the next is allowed.

GET /.well-known/jwks.json

The keys machine tokens are signed with, as a JSON Web Key Set: every key the control plane holds, so a token signed before a rotation verifies until it expires. Public. Inside the cluster the same keys are on the control plane's keys port, 7629, over TLS that asks for no client certificate.

GET /.well-known/openid-configuration

Where a verifier finds the rest: { "issuer", "jwks_uri", "token_endpoint" }. Public.

POST /platform/machine-keys/rotate

Adds a signing key, which signs every token from then on; the key it replaces stays in the key set. Platform administrators only. Answers the new key's id. The control plane also rotates every thirty days on its own, and drops a key thirty-one days old that is not the newest.

Services

Every service route answers with a service status, except where noted:

Field Type Meaning
name string The service's name.
projectId string Its project.
lifecycle string One of the lifecycle states.
generation integer Increments on every apply and restart.
image string The image of the current descriptor.
readyInstances integer Instances that are ready.
desiredInstances integer Instances that should be running.
detail string, optional Why the service is in its state, when there is something to say.
confirmed boolean false when the status restates what was last known rather than a current observation.
database string, optional What the platform did about the service's database, such as provisioned or supplied.
hostname string, optional The full https:// URL the service answers at, while exposed.
exposed boolean Whether the service has been exposed.
suspended boolean Whether its organization is disabled.
paused boolean Whether its members paused it.
hosting string embedded or process.
protocol string, optional The sidecar protocol a process-hosted service declared.
topicSources list, optional Each topic source of the service, from its running instances: kind, component, topic, group, start, version, recordedVersion, behind, broker, contract, lag (messages past the last one handled, summed over the instances, as of their last poll) and failing (the reason of the change being delivered again). Absent when no instance answered, and on a listing row.
topicChecks list, optional Each side the service's components take on a declared topic with a contract: topic, component, direction, stated, state (checked, mismatch or unchecked). Absent as topicSources is.
customHostnames list, optional Each custom hostname the service holds, in the order added: hostname; state, which is pending, serving or rejected; reason, the certificate authority's or the gateway's words when there is something to say; record, the DNS record that points the name at the installation (name, kind, value); and note, when no record can be given, such as an apex on an installation that has published no address. Every one is pending with the reason the service is not exposed while the service is not exposed. Absent when the service holds none.
proofRecord object, optional The record that proves the project controls a name it brings: { "name": "_ankka.<hostname>", "kind": "TXT", "value": "ankka-project=<project>" }, with <hostname> written literally, since it is the same beside every name. On every read of one service; absent on a listing row.

GET /services/{projectId}

Every service in a project, as a list of statuses ordered by name. Members only. A listing can lag a moment behind a change, because it is read from a projection; a single service's status is not.

GET /services/{projectId}/{name}

One service's status. Members only.

PUT /services/{projectId}/{name}

Applies a service descriptor, creating the service if it is new. The body is the descriptor, and its name must equal {name}. Every validation problem is reported at once, with 400. Each apply increments the generation, including one that changes nothing, which is how an image tag is pulled again. A paused service stays paused. Answers with the new status.

POST /services/{projectId}/{name}/pause

Stops every instance and keeps the descriptor, database and hostname. Pausing a paused service changes nothing. Answers with the status.

POST /services/{projectId}/{name}/resume

Starts a paused service again. Answers with the status.

POST /services/{projectId}/{name}/restart

Replaces every instance by a rolling update, and increments the generation. Refused with 409 while the service is paused. Answers with the status.

POST /services/{projectId}/{name}/storage-credential

Issues the service's storage credential again. A new credential is written where the service's instances read it, the instances are replaced by a rolling update once it is there, and the old credential goes on working until the rotation grace (an hour as shipped) has passed, after which it is refused. The service's generation is unchanged. Refused with 409 when the service's descriptor asks for no bucket, since the platform holds no credential of its own for it. Answers with the status, and the history records storage-credential-reissued.

POST /services/{projectId}/{name}/storage/settings

Applies the installation's current bucket settings — how long a deleted object can be recovered, and the key the bucket is encrypted with — to the service's bucket in Google Cloud Storage. A changed setting reaches a new bucket by itself and an existing one only this way, since shortening how long a deleted document can be recovered is a decision, not something the platform does on its own. Refused with 409 for a bucket that is not in Google Cloud Storage. Answers with the status; the history records storage-settings-reapplied.

POST /services/{projectId}/{name}/storage/move

Moves the service's bucket from Garage to Google Cloud Storage. The body may name how long the service's writes may be paused, { "writePauseBound": "30m" }, from one minute to 24 hours; {} is ten minutes. Every object is copied while the service goes on writing; then its writes are paused, what changed is copied, every object is checked on both sides, and the service is replaced onto its new bucket. A pause that reaches its bound, or an object that does not check, fails the move and gives the service its writes back, still on Garage. The bucket in Garage is kept. Refused with 409 where the installation keeps new buckets in Garage, for a service whose descriptor asks for no bucket, for a bucket already in Google Cloud Storage, and while a move is in progress; 400 for a bound that is not one. A move that failed may be asked for again. Answers with the status, whose storageMove says where the move is; the history records storage-moved.

POST /services/{projectId}/{name}/rollback

Rolls the service back: applies the descriptor it recorded at an earlier generation again, as a new generation. Nothing is rewound; the generation keeps counting and the history shows the rollback. The body names the generation, { "generation": 1 }; {} asks for the most recent generation whose descriptor differs from the one the service has, passing over restarts and applies of the same descriptor. Answers { "rolledBackTo": 1, "status": { … } }, the generation it rolled back to and the status it produced.

A rollback is checked as an apply is: the descriptor against the platform's rules as they are now (400 invalid descriptor at generation 1: …), the organization's quota, and whether the organization is disabled. Whether the service is paused or exposed, and its restart count, are unchanged. The service keeps the descriptors of its last fifty applies, and refuses with:

  • 404 service 'cart' has no generation 9 for a generation it never had;
  • 409 the descriptor of generation 3 is no longer kept; the oldest kept is generation 11;
  • 409 generation 3 was a restart and ran the descriptor of generation 2;
  • 409 service 'cart' already has the descriptor of generation 2, the generation it is at included;
  • 409 service 'cart' has no earlier generation with a different descriptor, with no generation named.

A refused rollback writes nothing.

POST /services/{projectId}/{name}/switch

Moves the service onto another of its project's database clusters: a verified restore that holds its database, or back to the project database, ankka-db. Owners of the project's organization only. Body: { "cluster": "ankka-db-r202610081012" }. The switch is a new generation, so the service rolls onto the cluster at once and no other service moves; its history records a switched entry naming both clusters, and its status names the cluster it is on in databaseCluster. Switching to the cluster the service is on changes nothing.

A cluster that is not one of the project's restores is refused with 400, a restore that is not verified with 409, and a restore that holds no database for the service with 400 naming the moment. The cluster the service leaves is kept, with every write it had.

GET /services/{projectId}/{name}/descriptor

The descriptor the service applied at the generation named by the required generation query parameter, exactly as PUT /services/{projectId}/{name} accepts one, so it can be compared with the current one or applied again. Members only. Answers for a deleted service, as its history does. It refuses as a rollback does for a generation never had, no longer kept, or that recorded none. A variable's literal value is in the reply as it was applied; one taken from a project secret is the reference to it, never the value.

POST /services/{projectId}/{name}/expose

Makes the service reachable from outside the cluster at https://<service>-<project>.<base domain>, and answers with the status, whose hostname is that URL. Refused with 409 when the service serves no HTTP, when the hostname's first label would be over 63 characters, or when another exposed service already derives the same hostname. A control plane with no base domain configured refuses every expose the same way.

POST /services/{projectId}/{name}/unexpose

Removes the external route and nothing else. Answers with the status. The service's custom hostnames stay recorded and stop answering; exposing it again serves them again.

PUT /services/{projectId}/{name}/hostnames/{hostname}

Adds a custom hostname to the service: a name under a domain the project's owner brings, served beside the hostname the platform derives. No body. The name is lowercased and loses a trailing dot. Answers with the status, whose customHostnames gives the record to create. Members with write access, and platform administrators. A name the service already holds is answered unchanged.

Refused with 409, checked in this order, so the first thing wrong is the one said:

Refusal Message
a scheme, a path, a port or a space a custom hostname is a name alone: …
a wildcard a custom hostname cannot be a wildcard
not a DNS name '<hostname>' is not a hostname: …, naming the label, or the 253-character limit
one label '<hostname>' is not a name on the internet: a custom hostname has at least two labels
the base domain or a name under it a custom hostname cannot be under the base domain '<base>': those names are the platform's
no issuer configured the installation names no authority for custom hostnames (ANKKA_HOSTNAME_ISSUER)
the service is not exposed service '<name>' is not exposed
five held already service '<name>' holds 5 custom hostnames, the most a service can hold
another service holds it '<hostname>' is held by service '<other>' in project '<project>'
no proof record '<hostname>' does not carry the proof record of project '<project>': create TXT _ankka.<hostname> with the value "ankka-project=<project>"
the resolver could not be asked could not look up _ankka.<hostname>: <detail>; the proof record was not checked

The proof record is read once, here, and never again; the control plane reads no other DNS.

DELETE /services/{projectId}/{name}/hostnames/{hostname}

Removes a custom hostname: nothing answers at it within seconds, its certificate is discarded, and any service may claim the name. Nothing else about the service changes. By a platform administrator the history records it as hostname taken away, with administrative: true; by a member, as hostname removed. A name the service does not hold is answered unchanged.

GET /services/{projectId}/{name}/logs

Recent output of the service's running instances, as the cluster holds it at the moment of asking. Members only. Query parameters:

Parameter Meaning
instance One instance, by pod name; otherwise every instance.
previous Present, or true: the container before the last restart.
platform Present, or true: the platform's container instead of the developer's. A process-hosted service's pod holds the sidecar beside the process, and a web-hosted one's the proxy beside it; without this the developer's is read. Refused with 400 for a service whose pod has one container: --platform applies to a service with process or web hosting.
tail Only the last N lines.
since Only the last N seconds.

Response: { "instances": [ { "instance": "cart-6d9…", "output": "…", "error": null } ] }. An instance that could not be read carries an error rather than failing the whole response. A service with no running instance, for example a paused one, answers 404.

GET /services/{projectId}/{name}/topology

What a deployed service is made of and what calls what, read from each running instance and merged. Members only; a non-member is answered 404, as for the service itself. The control plane reads every instance concurrently over the service's observe port, presenting its own certificate, and the member receives the merged document and no credential for the service, its instances or the cluster.

Response: ServiceTopology — service, running (instances found), contributing (instances that answered), partial, instances, window, nodes, declared, calls and differences. Each entry of instances carries pod and a status:

status Meaning
ok The instance answered, and its counts are in the merge.
unreachable It did not answer in time.
unsupported The connection was refused: its runtime serves no topology.
failed It answered with an error, or with something that is not a topology; problem says what.

partial is true whenever an instance is not ok; such an instance contributes nothing and is never guessed at. Nodes and declared connections are the union across the instances that answered, and a node not on every one of them is listed under differences with the pods that have it. Each node's handlers carry a name and a type (command, query, route, tool, ...); an MCP endpoint's tools are handlers of type tool on the endpoint's node, named alone (get_cart), beside its route, and an agent's tools are of type tool too. Observed calls are summed per pair of handlers over a recent window, with handled (ok, refused, failed) and unanswered (timedOut, undelivered) kept apart: one is counted where a handler ran, the other where a caller got no answer, and they are never added together. Durations are read from bucketed histograms, so p50, p99 and max are bucket edges and say bucketed: true. A service with no running instance, for example a paused one, answers 404.

GET /services/{projectId}/{name}/history

Who did what to the service, newest first. Members only. Each entry is { "kind": "applied", "generation": 3, "actor": { "subject": "…", "display": "Ada", "administrative": false }, "at": "2026-09-20T12:00:00Z" }. kind is one of applied, rolled-back, restarted, paused, resumed, exposed, unexposed, hostname added, hostname removed, hostname taken away, deleted, suspended, reinstated, storage-credential-reissued, storage-moved, storage-settings-reapplied or switched; a switched entry's detail names the database clusters it moved from and to, and a hostname's entry carries hostname, the name it added or removed. administrative is true when the platform administrator role is what allowed the action. Entries recorded before actors were tracked have no actor or at.

An applied or rolled-back entry also carries image, the image of the descriptor it recorded, and digest, 64 hexadecimal characters that two entries share exactly when their descriptors state the same things: reordered labels or annotations are the same descriptor, and reordered variables are not. A rolled-back entry carries rolledBackTo, the generation whose descriptor it applied again. An entry the control plane held from before it recorded images may have neither image nor digest.

DELETE /services/{projectId}/{name}

Deletes the service's workload. Its database is kept, so applying the same name again recovers its data. A service name, unlike an organization or project id, can be reused. Answers 204.