API
Deployments
You bring a git repository and one elan.yaml; these routes are how you ship it without the CLI. Deploying is ASYNCHRONOUS — every one of them answers in milliseconds and the work takes minutes.
202 means accepted, not deployed
This is the one thing to get right. A deploy walks ten phases — validate, build, scan, crosscheck, sign, provision, render, commit, sync, verify — and reports its outcome only through the read route. A caller that treats the 202 as success has shipped nothing and knows nothing about whether it worked.
Poll until state is terminal: succeeded, failed or refused
running is the only non-terminal value. A deploy is typically a few minutes and can legitimately be much longer — the build alone is allowed 30 minutes, and each rollout wave is given its own budget — so give your poll loop a generous ceiling and no fixed expectation.
A deploy of an unchanged target does nothing to it
The platform computes a blast radius from the diff: a target nobody touched keeps the digest it is already running, and the whole project moves only for a change to something they share. So deploying on every commit is cheap and safe.
One deploy per project, and a second is refused rather than queued
409 with the id of the one in flight. Queueing behind a ten-minute build is rarely what anybody wants, and the later deploy usually supersedes the earlier — so the choice is yours: poll it, or wait and resubmit.
A project scope guards the writes, not the reads
Deploying and rolling back carry the project in their path, so a credential issued for one project is refused with 403 for any other. The three read routes — the list, one deployment and its build log — check only the tenant: a scoped credential can read every project's deployments and logs, and project on the list narrows the answer rather than authorizing it.
Deploying needs no admin role
Any credential in the tenant can deploy and roll back, including a service user without Can write. Issue deploy credentials only to the systems that should be able to ship.
The samples use $API and $TOKEN, which the API overview sets.
Deploy a project
POST/v1/projects/{project}/deploymentsdeploy-service
Submits an elan.yaml and a resolved commit. Answers 202 with a deployment id, then builds, scans, signs, provisions and rolls out in the background.
The FILE travels, not a path to it
spec_yaml is the contents of elan.yaml. The platform never clones to read your config — it clones to build, by which point the config has already been validated.
ref must be a resolved 40-character sha
A branch name would make the deployment unreproducible. elan deploy resolves the branch and rewrites it into elan.yaml before submitting, so the file alone describes what ran. Do the same from your own CI.
Unknown fields are refused
The body is strict. A typo'd key is a 422 rather than a value silently ignored — which on a deploy would mean shipping something other than what you asked for.
One deploy per project at a time
A second deploy of the same project is REFUSED with 409, never queued, and the body names the one already running so you can poll it instead. Two concurrent deploys would race the config repo and each other's digests. Different projects deploy concurrently.
curl -X POST "$API/v1/projects/salmon/deployments" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile spec elan.yaml \
--arg ref "$(git rev-parse HEAD)" \
'{spec_yaml: $spec, ref: $ref}')"Body
| Field | Type | Description | |
|---|---|---|---|
| spec_yaml | string | required | The contents of elan.yaml. |
| ref | string | required | The resolved 40-character commit sha to build. |
Responses
| Status | Meaning |
|---|---|
| 202 | Accepted — NOT deployed. Carries deployment_id; poll it. |
| 401 | No token, or one that did not verify. |
| 403 | A credential scoped to another project. |
| 409 | A deployment of this project is already running. Carries its deployment_id. |
| 422 | Refused before anything was built — {state: "refused", problems}, each with where and message, including an unpaid balance. Or a body with a missing or unknown field, named in problems. |
| 503 | This deploy service has no pipeline configured. |
Response body
{
"deployment_id": "dep_b6a540abdb18482e",
"state": "running"
}Read a deployment
GET/v1/deployments/{deployment_id}deploy-service
The whole record: which phase it is in, what every target is running afterwards, what the vulnerability scan saw, and why it failed.
THIS is where the outcome lives
The submit route's 202 says only that the work was accepted. Poll here until state is one of succeeded, failed or refused — running is the only non-terminal value.
refused and failed are different, and the difference is whose fault it is
refused means something you can fix — a crosscheck problem, an unbumped version, an unpaid balance — and nothing was provisioned, so it carries problems[]. failed means the platform broke partway, and it carries error plus needs_teardown.
needs_teardown is the one field to act on
True when a FAILED deploy had already created tenant resources — a database, a topic, a commit in the config repo. It is always false for a refusal, because a refusal happens before any of that.
rollout lists every target, including the ones that did not move
It is a map of slug to the digest that target runs AFTER this deploy. An untouched agent appears with the digest it already had, because "what is my agent running" is the question people actually ask.
curl "$API/v1/deployments/dep_b6a540abdb18482e" \ -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 200 | The record. |
| 401 | No token, or one that did not verify. |
| 403 | The deployment belongs to another tenant. Another project of your own tenant is served, whatever the credential's scope. |
| 404 | No such deployment. |
Response body
{
"deployment_id": "dep_b6a540abdb18482e",
"state": "succeeded",
"phase": "verify",
"completed": ["validate", "build", "scan", "crosscheck", "sign",
"provision", "render", "commit", "sync", "verify"],
"digest": "...@sha256:e794e7...",
"rollout": {"hotel-concierge": "...@sha256:e794e7...",
"event-planner": "...@sha256:a1b2c3..."},
"waves": [["hotel-concierge"], ["event-planner"]],
"scan": {"completed": true, "findings": []},
"problems": [],
"error": "",
"needs_teardown": false
}List deployments
GET/v1/deploymentsdeploy-service
This tenant's deployment history, newest first, as a summary per row rather than the whole record.
Which tenant is not a parameter
project narrows WITHIN your own tenant and limit bounds the page; neither can widen the scope, because the scope comes from the token's claim rather than from the request.
total travels with the page
So a pager can say "of 9" and disable a button, rather than discovering the end by turning to an empty page.
A summary, not the record
moved is how many targets took this deploy's digest and targets is how many there are; the map itself is on the read route. Sending every rollout map for fifty deployments to draw a list is a page that gets slower as you succeed.
curl "$API/v1/deployments?project=salmon&limit=20" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| project | query string | optional | Narrow to one project. Omitted, every project of this tenant — including for a project-scoped credential. |
| limit | query int | optional | Page size, default 50, capped at 200. |
| offset | query int | optional | Rows to skip, default 0. |
Responses
| Status | Meaning |
|---|---|
| 200 | A page of summaries plus the total. |
| 401 | No token, or one that did not verify. |
| 422 | A project that could not be a project slug. |
| 503 | No store configured. |
Response body
{
"total": 9,
"limit": 50,
"offset": 0,
"deployments": [{
"deployment_id": "dep_b6a540abdb18482e",
"project": "salmon",
"ref": "6f1c0a9e00000000000000000000000000000000",
"state": "succeeded",
"phase": "verify",
"digest": "...@sha256:e794e7...",
"moved": 1,
"targets": 3,
"started_at": "2026-10-23T13:06:05.254220+00:00",
"error": ""
}]
}Read the build log
GET/v1/deployments/{deployment_id}/logsdeploy-service
The build's own output, served to the tenant who owns the deployment.
Why this route exists at all
A failed build reports a log URL pointing at the build console in the PLATFORM's cloud project, which you cannot open. Without this, the one actionable part of a build failure was a link that answered "permission denied".
An empty log is not an error
A deploy refused at validate never started a build, and one from before the platform recorded build ids has none kept. Both answer 200 with an empty log and a note saying so, because reporting them as a failure to fetch would send you looking for the wrong thing.
curl "$API/v1/deployments/dep_b6a540abdb18482e/logs" \ -H "Authorization: Bearer $TOKEN" | jq -r .log
Responses
| Status | Meaning |
|---|---|
| 200 | The log, or an empty one with a note. |
| 401 | No token, or one that did not verify. |
| 404 | No such deployment, or another tenant's. Another project of your own tenant is served, whatever the credential's scope. |
| 502 | The log could not be read. |
| 503 | This deploy service has no logging credential. |
Response body
{
"deployment_id": "dep_b6a540abdb18482e",
"build_id": "b684b74c-c6b2-4e87-8b56-f52b57f64694",
"log": "Step #0: ...\nStep #1: ..."
}Roll a project back
POST/v1/projects/{project}/rollbackdeploy-service
Puts a project back the way a previous SUCCEEDED deploy left it. No build, no scan, no re-signing — which is what makes it fast enough to reach for during an incident.
You name a DEPLOYMENT, not a digest and not a ref
A digest names one image and a project has several. A ref names a commit in your repository, which may have been force-pushed or deleted. A deployment id names a state this project was actually in.
It restores configuration as well as code
The deploy unit is (digest, slug), so moving digests alone would restore the CODE and leave whatever the bad deploy changed about egress, secrets or scaling in place — the half nobody expects, discovered when the rollback does not fix the problem. The old elan.yaml is replayed too.
It takes the project's lease like any deploy
So it is refused with 409 while another deploy of that project is in flight, and it is itself a deployment you poll on the read route.
curl -X POST "$API/v1/projects/salmon/rollback" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to_deployment_id": "dep_b6a540abdb18482e"}'Body
| Field | Type | Description | |
|---|---|---|---|
| to_deployment_id | string | required | The succeeded deployment of THIS project to restore. |
Responses
| Status | Meaning |
|---|---|
| 202 | Accepted. Carries a NEW deployment_id plus rolled_back_to. |
| 401 | No token, or one that did not verify. |
| 403 | A credential scoped to another project. No admin role is needed. |
| 404 | No such deployment, or another tenant's — 404 rather than 403, so an id does not leak. |
| 409 | It belongs to another project, did not succeed, recorded no targets, predates the stored spec, or names an image that no longer exists (listed in missing_images) — or a deploy is already running. |
| 503 | This deploy service has no pipeline configured. |
Response body
{
"deployment_id": "dep_7c1e03aa94b5f218",
"state": "running",
"rolled_back_to": "dep_b6a540abdb18482e"
}