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-adminrole 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;decryptonly withconsume;{ "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 9for 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.