Serverless API
This page documents every endpoint on serverless-api, the service behind scale-to-zero container services. It is a separate API service from agent-engine-api: set $CAI_SERVERLESS_API (not $CAI_API) — see the API overview for the port-forward recipe. In-cluster address: serverless-api.cai-system.svc.cluster.local:8080.
Conventions
- Errors use the standard envelope
{"error": "<message>", "request_id": "<id>"}. Unrouted paths render JSON 404/405 in the same envelope. - Auth: all project-scoped routes require
Authorization: Bearer <credential>(session token or API key). The{projectID}path segment must be a UUID — anything else 404s. "Not your project" and "does not exist" are both 404 by design; 403 appears in exactly one place (DELETE, admin-only). - Body cap: request bodies are limited to 256 KiB.
- Rate limit: 100 requests/sec, burst 200, per principal, per API replica.
/healthzis exempt. (rps: 0disables it.) - No database, no service: if the platform database is unreachable, every project-scoped route answers 503 —
the platform database is not configured, so project access cannot be resolved. - 202 semantics: create and
:set-trafficreturn 202 — accepted, not serving. Pollstatus.phaseandstatus.traffic.
Route table
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /healthz | none | Health (always 200; degradation is in the body) |
| GET | /v1/projects/{projectID}/services | project member | List services (paged) |
| POST | /v1/projects/{projectID}/services | project member | Create a service |
| GET | /v1/projects/{projectID}/services/{name} | project member | Get one service |
| PATCH | /v1/projects/{projectID}/services/{name} | project member | Sparse update |
| DELETE | /v1/projects/{projectID}/services/{name} | project admin | Delete (204) |
| GET | /v1/projects/{projectID}/services/{name}/revisions | project member | List revisions |
| POST | /v1/projects/{projectID}/services/{name}:set-traffic | project member | Replace the traffic split |
| GET | /v1/projects/{projectID}/services/{name}/logs | project member | Tail logs (text/plain) |
| GET | /v1/projects/{projectID}/services/{name}/spec | project member | Sanitized live YAML |
| GET | /v1/projects/{projectID}/services/{name}/metrics | project member | Live instance/readiness readout |
| GET | /v1/projects/{projectID}/triggers | project member | List triggers |
| POST | /v1/projects/{projectID}/triggers | project member | Create a trigger |
| GET | /v1/projects/{projectID}/triggers/{name} | project member | Get a trigger |
| PATCH | /v1/projects/{projectID}/triggers/{name} | project member | Update a trigger |
| DELETE | /v1/projects/{projectID}/triggers/{name} | project member | Delete a trigger |
GET /healthz
Unauthenticated. Always returns 200, even when degraded — the degradation is in the body.
| Field | Values |
|---|---|
status | ok | degraded |
service | serverless-api |
database | ok | disabled | unreachable (+ database_detail) |
sessions | ok | unavailable (+ sessions_detail) |
kubernetes | ok | unreachable (+ kubernetes_detail) |
crd | ok | missing (+ crd_detail, naming serverlessservices.platform.crusoe.ai) |
GET /v1/projects/{projectID}/services
Lists one page of services.
Query: page_size (default 50, max 200 — over-max is rejected, not clamped) and page_token (opaque, scope-bound cursor).
Response: 200 — {"services": [Service, ...], "next_page_token": "..."}. next_page_token is top-level and, on the last page, is omitted from the response entirely — it is not sent as "". Test for the key's presence, not for an empty-string value. The triggers list at /v1/projects/{projectID}/triggers behaves the same way.
This differs from the Agent Engine list endpoints (agents, sessions), which always emit next_page_token and use "" to mean "last page". See Agents API.
Errors:
- 400 —
page_size must be between 1 and 200 - 400 —
page_token is invalid or was issued for a different list; start from the first page
POST /v1/projects/{projectID}/services
Creates a service. Only name and image are required — everything else has a server-side default that is echoed back.
{
"name": "checkout-api",
"image": "localhost:30500/checkout-api:c5c2f25d",
"command": ["..."], "args": ["..."],
"env": {"LOG_LEVEL": "info"},
"env_from": [{"secret_name": "api-credentials", "config_map_name": "", "optional": true}],
"ports": [{"name": "h2c", "container_port": 8080}],
"resources": {"requests": {"cpu": "250m", "memory": "512Mi"},
"limits": {"cpu": "1", "memory": "512Mi"}},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"publish": {"enabled": false},
"traffic": [{"revision_name": "", "latest_revision": true, "percent": 100, "tag": ""}],
"runtime_class_name": "gvisor",
"service_account_name": "",
"timeout_seconds": 300
}
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | — | DNS-1035, 1–52 chars: ^[a-z]([-a-z0-9]{0,50}[a-z0-9])?$. Cannot be renamed. (52, not 63: the platform appends -00001 for revision names.) |
image | string | yes | — | Use the pull prefix localhost:30500/<repo>:<tag>, a public registry, or CCR. The push endpoint registry.cai-system.svc.cluster.local:5000 is not pullable by nodes — using it fails minutes later with an image pull error. |
command, args | string[] | no | image defaults | Container entrypoint override. |
env | map | no | {} | Plain env vars (visible to anyone who can read the service; use env_from + Secrets for credentials). |
env_from | array | no | [] | {"secret_name", "config_map_name", "optional"} references. |
ports | array | no | port 8080 | Max one port; name must be http1 or h2c; container_port 1–65535. |
resources | object | no | requests 250m/512Mi, limits 1 CPU/512Mi | Deliberately small so a bare create doesn't inherit the namespace LimitRange's 2 CPU / 4Gi default. |
scaling.min_scale | int | no | 0 | Instance floor. |
scaling.max_scale | int | no | 10 | Instance ceiling. 0 currently behaves as 10 (see note below). |
scaling.container_concurrency | int | no | 80 | 0 means the serverless default (effectively unbounded per instance). |
publish.enabled | bool | no | false | true gives the service a public HTTPS URL: https://<name>-<project-short>.apps.codyhill.dev. publish.host is accepted and stored but ignored — there is no custom domain today. |
traffic | array | no | 100% to latest | The complete split; validated as below. |
runtime_class_name | string | no | "" | "gvisor" opts into the gVisor sandbox; the controller reports RuntimeClassApplied: False if the cluster stripped it. |
service_account_name | string | no | "" | If unset, no Kubernetes service-account token is mounted into the container. |
timeout_seconds | int | no | 300 | 1–3600. Always mirrored into the env var CRUSOE_REQUEST_TIMEOUT_SECONDS. |
Response: 202 Accepted (not 201 — the object exists but nothing is serving yet) with the full Service object (below).
Errors:
- 400 —
invalid JSON body: ... - 400 —
name must be 1-52 characters of lowercase letters, digits and dashes, start with a letter and end with a letter or digit - 400 —
image is required - a serverless service has nothing to run without one - Traffic validation: 400 —
traffic percentages sum to N, not 100;traffic percent N is out of range 0-100;each traffic target needs either revision_name or latest_revision: true;traffic target "X" sets both revision_name and latest_revision;traffic target "X" appears twice;traffic tag "X" appears twice;revision X does not exist for this service - Scaling validation: 400 —
min_scale N is negative...;max_scale N is negative...;container_concurrency N is negative; ... (0 means the serverless default);min_scale N is greater than max_scale M; ... - 409 —
a service with that name already exists in this project - 400 —
rejected by the cluster: ... - 500 —
create service: ...
The API accepts and round-trips max_scale: 0, but the platform operator currently coerces 0 (or a negative) to 10 when it configures autoscaling. Net effect today: max_scale: 0 behaves as max_scale: 10. If you need a higher ceiling, set it explicitly.
The Service object
Every read and write returns this shape:
{
"name": "...", "project": "projects/<short>/services/<name>",
"image": "...", "command": [], "args": [], "env": {}, "env_from": [], "ports": [],
"resources": {"requests": {}, "limits": {}},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"publish": {"enabled": false, "host": ""},
"traffic": [], "runtime_class_name": "", "service_account_name": "",
"timeout_seconds": 300, "created_at": "RFC3339",
"status": {
"url": "http://...",
"external_url": "https://<name>-<project-short>.apps.codyhill.dev",
"internal_url": "http://<name>.<namespace>.svc.cluster.local",
"published": false,
"latest_ready_revision": "name-00002", "latest_created_revision": "name-00002",
"traffic": [{"revision_name": "...", "percent": 100, "tag": "", "url": ""}],
"phase": "Pending",
"conditions": [{"type": "Ready", "status": "True", "reason": "...", "message": "...",
"last_transition_time": "..."}],
"observed_generation": 3, "generation": 3
}
}
Status fields worth knowing:
| Field | Meaning |
|---|---|
status.url | The cluster URL the serving layer advertises. |
status.external_url | The public HTTPS address — filled in only once it is actually reachable over valid TLS. |
status.internal_url | Always-present in-cluster address. |
status.phase | Pending | Ready | NotReady | Degraded | Invalid. Pending means the controller hasn't written status yet. |
status.conditions | Named checks behind the phase: Ready, ServiceReady, VisibilityEnforced, RuntimeClassApplied, TrafficAccepted, Exposed. |
generation vs observed_generation | Unequal means "your change was accepted but not yet applied". |
"Internal" removes the service from the internet; it does not authenticate callers. Any workload that can reach the shared gateway can reach a cluster-local service. Put your own auth in front of anything sensitive. See Public endpoints and domains.
GET /v1/projects/{projectID}/services/{name}
200 with the Service object. 404 for both "doesn't exist" and "not your project" (deliberately indistinguishable).
PATCH /v1/projects/{projectID}/services/{name}
Sparse update: every field of the create body is accepted; omitted fields are left alone, and explicit zero values set zero. Exception: a scaling block, if present, replaces the whole block — omitted sub-fields inside it revert to the platform defaults (0/10/80). Traffic in a PATCH is validated exactly as on create.
Response: 200 with the updated object.
Errors: 409 — the service was modified concurrently - re-read it and retry (optimistic concurrency), plus all create-time validation errors.
Revisions are immutable. Updating a Secret referenced via env_from does not affect running revisions — you need a new revision (for example, a PATCH) to pick up the new value.
DELETE /v1/projects/{projectID}/services/{name}
Requires the project admin role — a plain member gets 403 (this is the one place the API uses 403). Deletes the endpoint and releases the public hostname claim.
Response: 204 No Content.
GET /v1/projects/{projectID}/services/{name}/revisions
Response: 200 — {"revisions": [Revision, ...]}. Revisions are returned whole, newest first (ordered by configuration generation, not by name), so there is never a next page and next_page_token is never present in the response.
Revision fields: name, generation, image (the resolved digest when known), ready ("True" | "False" | "Unknown"), reason, message, replicas, created_at, traffic_percent, tag, url.
404 if the service isn't in this project.
POST /v1/projects/{projectID}/services/{name}:set-traffic
Replaces the entire traffic split — it never merges. Percentages must sum to 100; named revisions must exist; latest_revision: true targets are allowed.
{"traffic": [{"revision_name": "checkout-api-00001", "percent": 90},
{"revision_name": "checkout-api-00002", "percent": 10, "tag": "canary"}]}
Response: 202 with the updated Service. Watch status.traffic for the live split.
Errors:
- 400 —
traffic is required - send the complete split, e.g. [{"revision_name":"api-00002","percent":100}] - The same traffic-validation 400s as create.
- Any other custom verb → 404 —
unknown method ':<verb>' - the supported one is ':set-traffic'
GET /v1/projects/{projectID}/services/{name}/logs
Streams text/plain (never JSON) from the newest pod only.
Query: ?tail=N (accepted range 1–10000; default 500) and ?follow=true to keep the connection open.
A scaled-to-zero service returns 200 with this body:
no running pods: this service is scaled to zero. Send it a request and the logs will appear here.
Mid-stream failures append a final line: [log stream ended: <reason>].
There is no platformctl command for serverless logs — use this endpoint or the console's Logs tab.
GET /v1/projects/{projectID}/services/{name}/spec
The "YAML tab". Returns the live object as sanitized YAML: metadata.managedFields and the kubectl last-applied annotation are stripped, and any spec.env value whose key matches (?i)(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE) is replaced with ***REDACTED*** (the key is kept). The output never contains a secret value.
Response: 200 — {"service": "projects/<short>/services/<name>", "yaml": "...", "note": "..."}
GET /v1/projects/{projectID}/services/{name}/metrics
A live, point-in-time readout — there is no time-series database, so no historical charts, and the note field says so plainly.
Response: 200
{"service": "...",
"instances": {"current": 1, "desired": 1},
"revisions": [{"name": "...", "generation": 2, "replicas": 1, "ready": "True",
"traffic_percent": 100, "latest_ready": true, "latest_created": true}],
"phase": "Ready",
"readiness": {"ready": "True", "reason": "", "message": "", "last_transition_time": "..."},
"scaling": {"min_scale": 0, "max_scale": 10, "container_concurrency": 80},
"note": "..."}
desired equals current on this API.
Triggers
A trigger fires a service without an inbound HTTP call. Triggers target a service by name within the same project only — there is no cross-project field, so cross-tenant invocation is structurally impossible. The target may be a serverless service or a bare platform service (a function or agent).
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/projects/{projectID}/triggers | List triggers |
| POST | /v1/projects/{projectID}/triggers | Create (validates the target exists; returns 202) |
| GET | /v1/projects/{projectID}/triggers/{name} | Get one |
| PATCH | /v1/projects/{projectID}/triggers/{name} | Update |
| DELETE | /v1/projects/{projectID}/triggers/{name} | Delete |
Trigger sources:
| Source | What it does |
|---|---|
schedule | Cron schedule (runs as a platform CronJob). |
objectstore | Fires on object-storage activity (S3 polling). |
pubsub | Fires when a message is published to a Pub/Sub topic in the same project. The platform creates a push subscription for the trigger and delivers each message to the target as a CloudEvent. The trigger sits at Pending only until the topic and its subscription become ready, then reports Ready. |
Errors: 409 — a trigger with that name already exists in this project
Related pages
- Serverless overview · Deploy a service · Autoscaling and scale to zero · Troubleshooting
- Platform limits — including the services-quota interaction every serverless user should know: each revision permanently holds two Kubernetes Services against the project quota.