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-Afterheader. - Never a secret on a list.
GETresponses carry key metadata only. The one response that carries a key's secret is the response that created it (POST .../endpoints/{name}/keysandPOST .../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"
}
| Field | What it is |
|---|---|
name | The endpoint's name — also the first label of its shared address. |
resource_path | Canonical tenant-scoped key, built from the project's immutable short (never the mutable slug), so the path survives a rename. |
url / hostname | The address a caller reaches, with and without the scheme. hostname is the value for your DNS record or certificate check. |
domain_kind | shared (the platform's own gateway.codyhill.dev zone) or custom (a hostname you verified with POST /domains). |
published | Whether the address is actually served. An exist-but-unpublished endpoint keeps its address reserved and answers nothing. |
target | Where 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.mode | Who may call it: none, apikey, or jwt. With jwt, auth.jwt carries {issuer, jwks_uri, audiences[]}. |
ip_allowlist | CIDRs accepted; empty means any address. |
rate_limit | All 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.
| Status | When |
|---|---|
| 400 | Bad limit or page_token |
| 401 / 404 | No credential / no grant on the project |
| 429 | Rate 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.
| Status | When |
|---|---|
| 400 | Missing name, an unknown target.kind, a custom hostname not claimed, JWT without an audience, or a rate-limit with fewer than all three fields |
| 409 | The name is already an endpoint in this project |
| 401 / 403 / 404 / 429 | Standard |
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:
| Status | Envelope | When |
|---|---|---|
| 400 | {"error", "request_id"} | The request object failed validation — each route names which field above. |
| 401 | same | No credential, or one the service cannot use. |
| 403 | same | You hold a grant on the project but lack the admin role for this action. |
| 404 | same | The project (you hold no grant), the resource, or a referenced dependency — read the message. |
| 409 | same | The name is already an endpoint, or the hostname is already claimed. |
| 429 | same + Retry-After | The per-principal token bucket is exhausted. |
| 500 | same | The server itself erred; request_id is the handle to report. |