Skip to main content

Gateway API

The Gateway API is the management surface for the platform's internet edge: which of your workloads is reachable from the internet, on which address, and what a caller has to present. An endpoint pairs a published address with a target workload; a domain is a custom hostname you verified; a key is the credential an apikey-auth endpoint checks.

Everything here is served by the gateway-api service (the gateway command group talks to it). Read the API overview first for the base URL, authentication, error envelope, paging, and rate-limit rules that apply to every route below.

Conventions

  • Member vs admin. Every list/get accepts a project member. Every state change — create, update, delete, publish, unpublish, key issue/rotate/revoke, domain claim/release — requires the project admin role.
  • The 404-instead-of-403 rule. A request against a project you hold no grant on returns 404, never 403. You see 403 only when you already hold a grant but lack the role for that action (a member trying to publish).
  • Error envelope. Every non-2xx response is {"error": "<message>", "request_id": "<id>"}. Status codes per route below.
  • Rate limiting. Every route is behind a per-principal token bucket and can answer 429 with a Retry-After header.
  • Never a secret on a list. GET responses carry key metadata only. The one response that carries a key's secret is the response that created it (POST .../endpoints/{name}/keys and POST .../keys/{keyID}/rotate-credential).

The endpoint object

Returned by every route that answers an endpoint, in whole (get, create, update, publish, unpublish) or as a list entry (list).

{
"name": "research-buddy",
"resource_path": "projects/p7f2ab91/gateway/endpoints/research-buddy",
"project_id": "p7f2ab91",
"url": "https://research-buddy-p7f2ab91.gateway.codyhill.dev",
"hostname": "research-buddy-p7f2ab91.gateway.codyhill.dev",
"domain_kind": "shared",
"published": true,
"target": { "kind": "agent", "name": "research-buddy" },
"auth": { "mode": "apikey" },
"ip_allowlist": ["203.0.113.0/24"],
"rate_limit": { "requests": 100, "unit": "minute", "key": "client_ip" },
"created_at": "2026-08-06T12:00:00Z",
"updated_at": "2026-08-09T09:14:00Z"
}
FieldWhat it is
nameThe endpoint's name — also the first label of its shared address.
resource_pathCanonical tenant-scoped key, built from the project's immutable short (never the mutable slug), so the path survives a rename.
url / hostnameThe address a caller reaches, with and without the scheme. hostname is the value for your DNS record or certificate check.
domain_kindshared (the platform's own gateway.codyhill.dev zone) or custom (a hostname you verified with POST /domains).
publishedWhether the address is actually served. An exist-but-unpublished endpoint keeps its address reserved and answers nothing.
targetWhere traffic goes: {"kind", "name"} — kind is one of agent, function, serverless, container, vectordb, pubsub, memorystore. The two shared engines vectordb and pubsub take no name.
auth.modeWho may call it: none, apikey, or jwt. With jwt, auth.jwt carries {issuer, jwks_uri, audiences[]}.
ip_allowlistCIDRs accepted; empty means any address.
rate_limitAll three fields or none: requests per unit (second, minute, hour, day), counted per key of client_ip or api_key.

The get-one response (GET /endpoints/{name}) adds keys — a metadata-only list of the keys this endpoint checks — and domain, the claim behind a custom hostname.

Endpoints

List endpoints

GET /v1/projects/{projectID}/gateway/endpoints

Auth: member. Returns 200 with {"endpoints": [...], "next_page_token": "..."}. Accepts limit and page_token. An unpublished endpoint is still listed — this is the "what of mine could go live right now?" read.

StatusWhen
400Bad limit or page_token
401 / 404No credential / no grant on the project
429Rate limited

Get one endpoint

GET /v1/projects/{projectID}/gateway/endpoints/{name}

Auth: member. Returns 200 with the endpoint object plus keys (metadata) and, for a custom domain, domain. 404 if the name does not exist.

Create an endpoint

POST /v1/projects/{projectID}/gateway/endpoints

Auth: admin. Creates the endpoint and reserves its address without publishing it. This is the form platformctl gateway endpoint create uses when you want the address to exist before it is wired.

{
"name": "research-buddy",
"target": { "kind": "agent", "name": "research-buddy" },
"domain": { "kind": "shared" },
"auth": { "mode": "apikey" },
"ip_allowlist": ["203.0.113.0/24"],
"rate_limit": { "requests": 100, "unit": "minute", "key": "client_ip" }
}

A custom hostname is a domain field of {"kind":"custom","hostname":"agents.example.com"}, and the hostname must already be claimed via POST /domains first. On auth.mode: jwt, auth.jwt carries {issuer, jwks_uri, audiences[]} and at least one audience is required.

Returns 201 with the endpoint object.

StatusWhen
400Missing name, an unknown target.kind, a custom hostname not claimed, JWT without an audience, or a rate-limit with fewer than all three fields
409The name is already an endpoint in this project
401 / 403 / 404 / 429Standard

Update an endpoint

PATCH /v1/projects/{projectID}/gateway/endpoints/{name}

Auth: admin. A partial update — only the fields you send change. Omitted fields keep their values; this is the route platformctl gateway endpoint update uses.

{
"auth": { "mode": "jwt", "jwt": { "issuer": "https://issuer.example.com", "jwks_uri": "https://issuer.example.com/.well-known/jwks.json", "audiences": ["agents"] } },
"ip_allowlist": ["203.0.113.0/24"],
"rate_limit": { "requests": "100", "unit": "minute", "key": "api_key" },
"hostname": "agents.example.com",
"target": { "kind": "agent", "name": "research-buddy-2" }
}

Returns 200 with the updated endpoint. 400 on the same validation rules as create.

Delete an endpoint

DELETE /v1/projects/{projectID}/gateway/endpoints/{name}

Auth: admin. Releases the address and revokes every key the endpoint held — the same one-call lever for "this thing is off the internet, now." Returns 204. Deleting something absent is a real 404, not a silent success.

Publish / unpublish

POST /v1/projects/{projectID}/gateway/endpoints/{name}/publish
POST /v1/projects/{projectID}/gateway/endpoints/{name}/unpublish

Auth: admin. publish turns the address on; unpublish turns it off while keeping the address reserved. Both return the endpoint object with its new published value. An endpoint with auth.mode: apikey and no keys is refused at publish with a 400 naming the missing half, because an endpoint that checks keys and has none cannot serve.

Keys

The credential an apikey endpoint checks. Three verbs, all admin-only.

Issue a key

POST /v1/projects/{projectID}/gateway/endpoints/{name}/keys

Body: {"note": "for the mobile app", "expires_in_days": 90}. Both optional; an absent expiry means the key does not expire. Returns 201 with:

{
"key": { "key_id": "gk_...", "note": "for the mobile app", "state": "live", "created_at": "...", "expires_at": "..." },
"secret": "gk_secret_...",
"note": "store the secret now: it is never returned again"
}

secret is in this response only — only its hash is stored, so a lost secret is replaced, never recovered.

Rotate a key's credential

POST /v1/projects/{projectID}/gateway/endpoints/{name}/keys/{keyID}/rotate-credential

Returns 200 in the same shape as issue. The old secret is marked superseded and keeps working for a bounded overlap, so a deployed caller rotates on its own schedule; the new secret is in this response only.

Revoke a key

DELETE /v1/projects/{projectID}/gateway/endpoints/{name}/keys/{keyID}

Hard cutover, effective immediately — the response that created the key named this the right lever when a secret may be in someone's hands. Returns 204.

The state on a key in a list reads live, expired, or revoked, derived rather than stored — "expired" is a fact about the clock, so the server derives it on read rather than maintaining a stored copy that would be wrong between sweeps. superseded: true marks the outgoing half of a rotation.

Domains

A custom hostname you own and verified, that endpoints may publish on instead of the shared gateway.codyhill.dev address.

Claim a domain

POST /v1/projects/{projectID}/gateway/domains

Body: {"hostname": "agents.example.com", "note": "for the marketing site"}. Returns 201 with the domain object including the DNS record you create at your provider:

{
"hostname": "agents.example.com",
"verified": false,
"record": { "type": "CNAME", "name": "agents.example.com", "value": "<project-short>.gateway.codyhill.dev" },
"note": "for the marketing site"
}

The record is both the proof of ownership and the routing: you cannot point an address you do not control, so the one record does both jobs. Set it, and the platform promotes verified on its next sweep.

List domains

GET /v1/projects/{projectID}/gateway/domains

Auth: member. Returns 200 with {"domains": [...], "next_page_token": "..."}.

Release a domain

DELETE /v1/projects/{projectID}/gateway/domains/{domain}

Auth: admin. Releases the claim. An endpoint still published on that hostname is refused — unpublish or re-target it first. Returns 204.

Error codes

All routes share these:

StatusEnvelopeWhen
400{"error", "request_id"}The request object failed validation — each route names which field above.
401sameNo credential, or one the service cannot use.
403sameYou hold a grant on the project but lack the admin role for this action.
404sameThe project (you hold no grant), the resource, or a referenced dependency — read the message.
409sameThe name is already an endpoint, or the hostname is already claimed.
429same + Retry-AfterThe per-principal token bucket is exhausted.
500sameThe server itself erred; request_id is the handle to report.