Open the console

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.

Request
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

FieldTypeDescription
spec_yamlstringrequiredThe contents of elan.yaml.
refstringrequiredThe resolved 40-character commit sha to build.

Responses

StatusMeaning
202Accepted — NOT deployed. Carries deployment_id; poll it.
401No token, or one that did not verify.
403A credential scoped to another project.
409A deployment of this project is already running. Carries its deployment_id.
422Refused 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.
503This 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.

Request
curl "$API/v1/deployments/dep_b6a540abdb18482e" \
  -H "Authorization: Bearer $TOKEN"

Responses

StatusMeaning
200The record.
401No token, or one that did not verify.
403The deployment belongs to another tenant. Another project of your own tenant is served, whatever the credential's scope.
404No 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.

Request
curl "$API/v1/deployments?project=salmon&limit=20" \
  -H "Authorization: Bearer $TOKEN"

Parameters

FieldTypeDescription
projectquery stringoptionalNarrow to one project. Omitted, every project of this tenant — including for a project-scoped credential.
limitquery intoptionalPage size, default 50, capped at 200.
offsetquery intoptionalRows to skip, default 0.

Responses

StatusMeaning
200A page of summaries plus the total.
401No token, or one that did not verify.
422A project that could not be a project slug.
503No 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.

Request
curl "$API/v1/deployments/dep_b6a540abdb18482e/logs" \
  -H "Authorization: Bearer $TOKEN" | jq -r .log

Responses

StatusMeaning
200The log, or an empty one with a note.
401No token, or one that did not verify.
404No such deployment, or another tenant's. Another project of your own tenant is served, whatever the credential's scope.
502The log could not be read.
503This 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.

Request
curl -X POST "$API/v1/projects/salmon/rollback" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to_deployment_id": "dep_b6a540abdb18482e"}'

Body

FieldTypeDescription
to_deployment_idstringrequiredThe succeeded deployment of THIS project to restore.

Responses

StatusMeaning
202Accepted. Carries a NEW deployment_id plus rolled_back_to.
401No token, or one that did not verify.
403A credential scoped to another project. No admin role is needed.
404No such deployment, or another tenant's — 404 rather than 403, so an id does not leak.
409It 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.
503This deploy service has no pipeline configured.

Response body

{
  "deployment_id": "dep_7c1e03aa94b5f218",
  "state": "running",
  "rolled_back_to": "dep_b6a540abdb18482e"
}

Reading this as a model? llms.txt indexes every page, and each page has a “Copy for AI” control.