Hanzo

Platform

Package platform is Hanzo PaaS: deploy containers to your own tenant namespace — builds, releases, environments, logs, custom domains.

Package platform is Hanzo PaaS: deploy containers to your own tenant namespace — builds, releases, environments, logs, custom domains.

Base URLhttps://api.hanzo.ai
Operations47
AuthAuthorization: Bearer $HANZO_API_KEY

platform

GET /v1/platform/apps/{app}/cd

One app's reconciliation

The Hanzo CD Application for one declaration, on its own — the poll a deploy view makes while it waits, without re-reading the whole inventory. 404 while the declaration exists only on a branch, because the generator reads main.

ParameterInTypeRequiredDescription
apppathstringyes

GET /v1/platform/apps/{app}

One declaration

The values file for one app as git declares it: image repository and tag, hosts, replicas, and whether CD is automated on it. 404 when this organization declares no such app.

ParameterInTypeRequiredDescription
apppathstringyes

GET /v1/platform/apps

What this organization has declared, and what CD did with it

Returns the declarations in the caller's own org directory, each joined with the Hanzo CD Application reconciling it — sync verdict, health, the universe commit last applied. cd is null for a declaration the delivery plane has no Application for, which is the normal state of one that exists only on a branch.

If the delivery plane cannot be read, the declarations are still returned and cdUnavailable says why. An unreadable plane never renders as "nothing has been reconciled".

POST /v1/platform/apps

Deploy an app through cd.hanzo.ai

Builds a git repository into an image and writes the declaration that names it — a values file in hanzoai/universe under charts/app/values/<namespace>/<name>.yaml, which the fleet ApplicationSet renders as one Application. That file IS the deployment: nothing else has to be applied.

mode decides whether anything can go live. The default, branch, pushes to deploy/<namespace>/<name>/<tag> and returns a review URL; the generator reads main, so a branch declaration deploys NOTHING and merging the review is the deliberate act. commit writes main, and proves the image is pullable first — a declaration naming an image the registry cannot serve is an ImagePullBackOff with no rollback path.

Omit tag to build; give it to declare an image an earlier call already built, which is how a green build is released without rebuilding it.

An org is its name: the values DIRECTORY, the destination NAMESPACE and the AppProject FENCE are all <org>, and the image is <registry>/<org>/<app>. None of them is a request field — the directory decides what CD admits the sync under and the repository decides what the cluster pulls, so a caller who could name either could reach outside its own org.

org is an ACT-AS, not a placement field: it defaults to the caller's own, and naming another requires SuperAdmin. So does naming a RESERVED org — the platform's own namespace family (the brands and their environments, the control and delivery planes, admin) — even when it is the caller's own, because an IAM org named kube-system does not own Kubernetes. Both refuse rather than downgrade, so an escape attempt is never indistinguishable from a normal request.

A host outside the caller's org subtree is refused: claim and verify a custom domain first.

GET /v1/platform/cd

The delivery plane

Every Hanzo CD Application this caller may observe, with its sync verdict, health, the universe revision last applied, and whether automation and self-heal are on. A SuperAdmin sees the fleet; an org admin sees only Applications whose destination namespace IS its own organization, and never a reserved one.

A cluster with no CD installed answers an empty plane. A plane that cannot be READ answers 503 and says why — the two are opposite facts and never share a shape.

GET /v1/platform/ci

Continuous integration (not wired)

Answers 501. The forge's Actions runs need a Forgejo API client and this deployment has none; an empty run list would be indistinguishable from a forge with no runs.

POST /v1/platform/fleet/{app}/deploy

Rolls a platform service's pods, in a named environment.

It triggers a rolling restart of one platform service's Deployment by stamping a fresh restart annotation, and answers 202 with the app, the namespace, the environment and the timestamp. It restarts pods; it does NOT change the image — a version change is the release path, not this.

SuperAdmin ONLY, and deliberately narrower than the read gate beside it. The only namespaces this board touches are the platform's own tier, so a restart here recycles a SHARED service every tenant depends on. A brand-org admin is a customer-org admin, not a platform operator: observing the board is bounded and audited, and restarting production identity is not.

?env=main|test|dev is REQUIRED — a bare call does not default to production, which is what closes the fat-finger and confused-deputy hazard — and any other value is 400. A service with no Deployment to restart in that environment is 404.

ParameterInTypeRequiredDescription
apppathstringyesApp is the service's CR name, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the service's CR name, from the path.
envstringEnv is REQUIRED and must be main, test or dev.

GET /v1/platform/fleet/{app}

Returns one platform service, resolved to production by default.

It returns a single platform service by its CR name, with the same declared-versus-running and drift facts the board carries. The name must be a DNS-1123 label; anything else is 400.

Namespaces are scanned in lifecycle order — main, then test, then dev — and the first match wins, so a bare name resolves to PRODUCTION. The scan covers only the namespaces the caller is authorized for, so an org admin can never read a service outside their own org, and a name found in none of them is 404 rather than a leak.

ParameterInTypeRequiredDescription
apppathstringyesApp is the service's CR name, from the path.
envquerystringEnv narrows the scan to one lifecycle env: main, test or dev.

GET /v1/platform/fleet

Returns the platform's own service tier, and where it has drifted.

It returns the board for the services the PLATFORM itself runs — iam, kms, gateway and the rest — as {apps, summary}: per service its environment, health, phase, the image tag its CR DECLARES, the tag actually running, and the drift between them, plus a summary counting the board green, yellow and red.

This is not a customer surface. /v1/platform/projects/:project/apps is a tenant's apps; this is the tier those tenants run ON, which is why the two are named differently rather than sharing a prefix.

Admission is scoped at the SCAN, before any CR is read: a platform SuperAdmin observes the whole fleet, an org admin observes only their own org's namespaces, and an org that owns none gets an empty board — a non-super caller never even lists another org's services. Narrow further with env, health, org, or drift=1 for only what has drifted.

It degrades honestly rather than failing whole: a namespace that does not exist is skipped, and a running-state read the caller cannot make leaves the running tag empty — an unknown, never a guess — while the declared, health and phase columns still render.

ParameterInTypeRequiredDescription
envquerystringEnv narrows to one lifecycle env: main, test or dev.
healthquerystringHealth narrows to one health colour: green, yellow or red.
orgquerystringOrg narrows to one image namespace.
driftquerystringDrift is 1 or true to show only rows that have actually drifted.

GET /v1/platform/health

Reports whether this control plane can actually deploy anything.

A real probe, not a status page. It answers 200 only when the metadata store is open AND the cluster is genuinely reachable — proved by LISTING the operator App CRD, which settles reachability and CRD presence in one bounded call, and which is the exact question every deploy depends on. Anything else is 503 carrying the real reason and whether the CRD was found.

A constructed cluster client proves nothing — it is built from a kubeconfig, not from a reachable apiserver — so this deliberately spends a round trip rather than reporting ok while every deploy fails. Not admin-gated: liveness has to be probe-able without a credential.

POST /v1/platform/projects/{project}/apps/{app}/deploy

Deploys the app — building it first if it comes from git.

It starts a new, monotonically versioned deployment of the app and answers 202 with the deployment record. A 202 is an ACCEPTED deployment, not a live one.

An IMAGE app deploys the tag you name (falling back to the app's tag, then latest) by writing its operator Service CR; the operator reconciles it to running. A GIT app launches an in-cluster BuildKit Job at commit — or the app's branch — and comes back in building; the Service CR is applied later, by the reconciler, once the Job succeeds. The reconciler is restart-safe, so a build in flight survives a cloud restart.

Deploys are bounded per org: over the concurrent-deploy cap is 429 and NOTHING is recorded, so a rejected deploy leaves no phantom in the history. An unreachable cluster is 503 but still records an honest error deployment, because a deploy that was attempted and failed must not be indistinguishable from one never made. Every other failure is likewise recorded in its real terminal state.

This is metered work: a git build is billed to the org's ledger in wall-clock build minutes once the Job finishes, and the running deployment is billed for its compute per tick for as long as it stays live. Requires a validated principal; 403 without one, and everything is written into that org's own tenant-<org> namespace.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the application's slug, from the path.
commitstringCommit is the git commit or ref to build, for a git-source app.
projectstringProject is the project the application lives under, from the path.
tagstringTag is the image tag to deploy, for an image-source app.

GET /v1/platform/projects/{project}/apps/{app}/deployments/{id}/logs

Returns real logs for a deployment — the build's, then the app's.

It returns the deployment's recorded status timeline together with LIVE pod logs pulled from the cluster: the build pod's output while a git build is running, and the running app's output once it is deployed. The source field says which of the two the body is — build, app or none — so a console can label the pane honestly.

It never fabricates log content. When no pod exists yet, or the cluster is unreachable, it degrades to the recorded timeline and says so. Every cluster read is confined to the caller org's own namespaces and time-boxed. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.
idpathstringyesID is the deployment's id, from the path.

GET /v1/platform/projects/{project}/apps/{app}/deployments/{id}

Returns one deployment of one app.

It returns a single deployment by id, scoped to the named application of the caller's org — so an id belonging to another app or another tenant is 404, not a read. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.
idpathstringyesID is the deployment's id, from the path.

GET /v1/platform/projects/{project}/apps/{app}/deployments

Returns an app's deployment history.

It lists every deployment recorded for one of the caller org's applications, newest version first, each with its version, status, source, commit and image. Failed and superseded attempts are included — that is the point of a history. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

POST /v1/platform/projects/{project}/apps/{app}/domains/{host}/verify

Checks a custom domain's DNS and turns it on if it passes.

It runs the DNS challenge check for a pending custom host and, when it passes, marks the host verified and renders it into the app's ingress so it starts serving.

A check that RAN and did not pass is not an error: it answers 200 with the host still pending and the reason in detail, so a console can show the operator what DNS is actually returning. An already-verified host answers as-is without re-checking. A host not claimed by this app is 404. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.
hostpathstringyesHost is the hostname, from the path.

DELETE /v1/platform/projects/{project}/apps/{app}/domains/{host}

Detaches a hostname and releases the claim.

It drops the host from the app's ingress and releases any custom claim on it, so the name becomes claimable again — by this org or any other. Answers 204.

The default host is permanent and cannot be removed: that is 400, not 404. A host that is neither attached nor claimed here is 404. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.
hostpathstringyesHost is the hostname, from the path.

GET /v1/platform/projects/{project}/apps/{app}/domains

Returns every hostname this app answers on.

It lists the app's hosts: the permanent default host it was born with, any org-subtree hosts attached to it, and every custom host claimed for it with its verification state and, while pending, the DNS challenge records to publish. Live endpoint status for each host is observed from the cluster. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

POST /v1/platform/projects/{project}/apps/{app}/domains

Attaches a hostname — instantly if you already own it, otherwise with a DNS challenge.

It attaches host to the app, and which of two things happens depends on who owns the name. A host inside the caller org's own subtree is structurally owned, so it goes ACTIVE immediately and answers 201. A bring-your-own host is claimed as PENDING and answers the DNS challenge records to publish; it is NOT rendered into the app's ingress until /verify passes.

Claims are globally unique. A host already claimed by another organization is 409, and so is one claimed by a different app in your own; re-adding this app's OWN claim is idempotent and answers its current state at 200. The default host is always attached and re-adding it is 409. A host under the platform's shared apex that is not the caller's own subtree is 403 — it belongs to whoever owns that subtree and can never be grabbed through the custom path.

host must be a valid DNS hostname; anything else is 400. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the application's slug, from the path.
hoststringHost is the hostname to attach.
projectstringProject is the project the application lives under, from the path.

PUT /v1/platform/projects/{project}/apps/{app}/env

Replaces an app's environment variables.

It writes the app's whole environment set and answers the updated application. This is the one post-create write path for env, and it REPLACES rather than merges: a variable absent from the body is gone, and a secret dropped from the set leaves the app's Secret on its next deploy.

Keys must match ^[A-Za-z_][A-Za-z0-9_]*$. A value marked secret: true is sealed into KMS and blanked in the database, so plaintext is never persisted — and the write fails 503 if KMS is unavailable rather than storing one in the clear.

The rule worth knowing: this does not restart anything. Once the app has been deployed the secret sync is re-declared immediately so the operator re-materialises the Secret, but RUNNING pods keep the environment they started with until their next deploy or restart. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the application's slug, from the path.
envEnvVarJSON[]Env is the app's whole environment set, REPLACING what it had.
projectstringProject is the project the application lives under, from the path.

POST /v1/platform/projects/{project}/apps/{app}/preview

Puts a branch on its own URL.

It deploys an already-built image to a per-branch preview and answers its URL, the branch, the preview's slug and the deployment. The preview is a FIRST-CLASS application named <app>-<branch> in the same project and tenant namespace, with its own default host — so it is completely isolated from production while reusing the same deploy mechanic. Re-previewing a branch converges that same target in place rather than stacking another one.

It carries NO environment variables, deliberately: a preview never inherits production's secrets. It also does not build — image is required and must already exist, and branch defaults to the parent app's. A branch that does not resolve to a valid slug distinct from the parent's is 400. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the parent application lives under, from the path.
apppathstringyesApp is the parent application's slug, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the parent application's slug, from the path.
branchstringBranch is the branch to preview; defaults to the parent app's branch.
imagestringImage is the already-built image ref to deploy.
projectstringProject is the project the parent application lives under, from the path.

POST /v1/platform/projects/{project}/apps/{app}/promote

Promotes an already-built release to the app.

It redeploys an image that already exists — named either by deploymentId, which promotes that deployment's exact built image, or by tag, resolved the same way a deploy resolves one. One of the two is required; neither is 400.

Promotion never builds. A deployment that carries no built image cannot be promoted and is 400, and a deployment id outside this app is 404. It runs through the same deploy core as everything else, so it takes a NEW version number and is subject to the same per-org concurrency cap. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the application's slug, from the path.
deploymentIdstringDeploymentID promotes that deployment's exact built image.
projectstringProject is the project the application lives under, from the path.
tagstringTag promotes an image tag, resolved the same way a deploy resolves one.

POST /v1/platform/projects/{project}/apps/{app}/rollback

Goes back to the previous release.

It redeploys a prior image: the one named by deploymentId, or — with no body — the newest earlier deployment that carries a real built image and did not error, skipping the release currently live. An app with nothing earlier to return to is 400.

A rollback is a deploy of an old image, not a rewind: it takes a NEW version number and appends to the history rather than erasing what came after. Both lookups are scoped to this app and org, so another tenant's image can never be rolled in. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
appstringApp is the application's slug, from the path.
deploymentIdstringDeploymentID is the deployment to redeploy.
projectstringProject is the project the application lives under, from the path.

POST /v1/platform/projects/{project}/apps/{app}/start

Starts a stopped app back up.

It scales the app's Service back to its configured replica count and marks it live, answering the updated application. It does not redeploy: the image already on the Service CR is what comes back.

The billing watermark is reset to now as part of starting, so the org is charged for THIS live span and never for the gap the app spent stopped. An app with no Service CR is 404, an unreachable cluster is 503, and a cluster that refuses the scale is 502. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

POST /v1/platform/projects/{project}/apps/{app}/stop

Stops an app without deleting it.

It scales the app's Service to zero replicas and marks it stopped, answering the updated application. Nothing else is removed — the record, its env, its domains and its deployment history all survive, and /start brings it back at the same replica count.

An app that is not deployed has no Service CR to scale and is 404. An unreachable cluster is 503 and a cluster that refuses the scale is 502. Because the pods stop, so does the compute metering. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

GET /v1/platform/projects/{project}/apps/{app}

Returns one application, with its live phase, health and secret sync.

It returns a single application of the caller's org together with what the cluster currently reports for it: the operator Service CR's phase and health, and whether its sealed env has synced. An app this org and project do not have is 404. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

DELETE /v1/platform/projects/{project}/apps/{app}

Deletes an application and tears down what it runs.

It removes the application record and tears down what it owns in the org's tenant namespace — its operator Service CR and its KMSSecret — then answers 204. An app this org and project do not have is 404, never a silent success.

Teardown is best-effort by design: a cluster that refuses or is unreachable does not block the delete, so the record cannot be left orphaned behind a broken cluster; the failure is logged for operators and the orphan reaper reconciles it. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project the application lives under, from the path.
apppathstringyesApp is the application's slug, from the path.

GET /v1/platform/projects/{project}/apps

Returns the applications in one project, with what the cluster says about them.

It lists the caller org's applications under one project. Each row carries the stored record and, for an app that is live or deploying, the LIVE phase and health read from its operator Service CR; an app with sealed env also carries its secret-sync state. Those cluster reads are best-effort — an unreachable cluster leaves those fields empty and never blocks the listing.

The project must exist in IAM for this org, or the answer is 404; the default project is implicit and always accepted, because it is part of what an org IS. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project's name, from the path.

POST /v1/platform/projects/{project}/apps

Creates an application from a git repo or a container image.

It registers a new application under one of the caller org's projects and answers 201 with it. Creating does NOT deploy: the app lands in draft and nothing reaches the cluster until /deploy.

source is git — which requires repo.url — or image, which requires image.repository; anything else is 400. A git app builds with zero-config pack by default and may opt into dockerfile; an image app never builds. The repo URL and Dockerfile path are validated here against the SAME allowlist the privileged build enforces, so an unsafe source is refused before it is ever persisted.

The slug is the app's identity in the cluster: given or derived from name, it must match ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$, and a slug already used in this project is 409. replicas and storageGb are clamped to the deployment's limits rather than refused.

Env keys must match ^[A-Za-z_][A-Za-z0-9_]*$. A variable marked secret: true is SEALED into KMS and its plaintext is never written to the database — and if KMS is unavailable the create fails 503 rather than falling back to storing a secret in the clear.

The app is seeded with its canonical default host, so it has a working HTTPS URL the moment it deploys. A bare custom domain cannot be attached here — it has to go through add-domain and DNS verification first. Requires a validated principal; 403 without one, and every cluster object it will later create lands in that org's own tenant-<org> namespace.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project to create the application under, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
buildTypestringBuildType is pack — the zero-config default that detects any project — or dockerfile, the explicit escape hatch.
descriptionstringDescription is free text about what the application is.
dockerfilestringDockerfile is the path to build from, for buildType dockerfile.
domainsstring[]Domains are extra ingress hosts. The canonical default host is always attached; a bare custom host is refused here and…
envEnvVarJSON[]Env is the application's environment. Keys must match ^[A-Za-z_][A-Za-z0-9_]*$; a variable marked secret: true is…
environmentstringEnvironment is the deploy target this app names ("production" by default).
imageimageOriginImage is the container image to run, for source image.
namestringName is the application's display name.
portintegerPort is the container port the app listens on.
projectstringProject is the project to create the application under, from the path.
replicasintegerReplicas is how many copies to run; clamped to the deployment's limit rather than refused.
repogitOriginRepo is the git source to build from, for source git.
slugstringSlug is the app's identity in the cluster — its CR name and part of its host.
sourcestringSource is git, which requires repo.url, or image, which requires image.repository.
storageGbintegerStorageGB is the persistent volume size in GiB; absent means stateless.

GET /v1/platform/projects/{project}

Returns one project and its app count.

It returns a single project of the caller's org with the number of platform applications under it. A project this org does not have is 404, which is also what another tenant's project looks like from here. Requires a validated principal; 403 without one.

ParameterInTypeRequiredDescription
projectpathstringyesProject is the project's name, from the path.

GET /v1/platform/projects

Returns your org's projects, each with how many apps live under it.

It lists the caller org's projects with the number of platform applications in each. A project is IAM's resource — it is created and deleted at /v1/iam/projects, never here — so this is the ONE projection IAM cannot serve: the project plus what the platform has put under it.

Requires a validated principal; 403 without one, and the org comes from that validated identity rather than a request header. This is the console's first authenticated read, so a project store that is not yet initialised degrades to an EMPTY list rather than a 500 — a new org genuinely has zero projects — and the real cause is surfaced to operators instead of to the caller.

POST /v1/platform/sites/{slug}/deploy

Upload a built site — this is where a zip goes live

Takes a built site live at https://<slug>.hanzo.app. The content type decides the shape: a zip or tar.gz — raw in the body or as a multipart file part, which is what the platform's upload UI posts — is stored and served immediately, answering 200 with the finished deployment; a JSON body instead queues a build from the site's linked repo and answers 202 with a queued deployment plus, where one could be minted, a scoped upload grant for CI. The git path requires a linked repo (400 without one).

The hosting gate is fail-closed and runs first, before anything is parsed or uploaded: 402 for an unfunded org, 503 for unreachable commerce, nothing written. The debit lands only on success — a failed upload is never billed and never flips the live site — and a redeploy answers the SAME URL, because slug and apex are stable.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404. Object storage must be configured (503); an archive that does not walk is a 400 and one over the size cap is a 413.

ParameterInTypeRequiredDescription
slugpathstringyes

GET /v1/platform/sites/{slug}/deployments/{id}

Returns one deployment of a project by id.

It is how a console follows a build: the status (queued, uploading, live, error), the message a failure left, and the URL and prefix it went live at. Like the history, it never replays the upload grant.

Scope: a validated principal is required (403 without one). Both the project and the deployment are resolved within that principal's org, so a deployment of another project — or of another tenant — is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project the deployment belongs to, from the path.
idpathstringyesID is the deployment id, from the path.

GET /v1/platform/sites/{slug}/deployments

Returns a project's deploy history, newest version first.

Every deploy of the project is a row — uploads, generated sites, and git/CI builds alike — carrying its version, status, source, commit, live URL, file count and byte count. The short-lived upload grant a queued git deployment was handed is NOT replayed here: it exists only on the 202 that minted it, so a grant cannot outlive its build by being fetched again.

Scope: a validated principal is required (403 without one) and the project is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to act on, from the path.

POST /v1/platform/sites/{slug}/domains/{host}/verify

Checks the DNS challenge for a pending custom hostname and, when it passes, promotes the host so it begins routing at the edge.

It answers 200 either way, with the host's honest current state: verified once the TXT record is found, still pending — with the records to publish and the resolver's own explanation in detail — when it is not. A not-yet is not an error: the check ran, DNS simply has not propagated, and the customer retries. An already-verified host is returned unchanged without re-resolving. On a successful promotion the edge cache-tag is flushed, since the host routes as of that moment.

Scope: a validated principal is required (403 without one). Both the site and the claim are resolved within that principal's org, so a host claimed by another tenant is "not claimed by this site".

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project the host is attached to, from the path.
hostpathstringyesHost is the custom hostname, from the path.

DELETE /v1/platform/sites/{slug}/domains/{host}

Gives a custom hostname back, so the name is free to reuse.

A claim is FIRST-COME and global, so an add-only surface was not ownership but a leak: a customer who mistyped a domain, or claimed one they later moved elsewhere, could neither reuse it nor let anyone else. This is the third writer that closes it. The release is scoped to (host, org, slug), so it can only ever drop THIS tenant's own claim, and it is IDEMPOTENT: releasing a host we do not hold is a clean 204, never a 404 that would let a caller probe which hosts other tenants hold. The edge cache-tag is flushed, since the host stops routing here.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project the host is attached to, from the path.
hostpathstringyesHost is the custom hostname, from the path.

GET /v1/platform/sites/{slug}/domains

Returns every custom hostname this site holds: the live ones, plus any pending claim with the DNS records it still owes.

domains is the routing answer — the hosts that are verified right now — while claims is the full panel, one row per host, each saying whether it is live or pending and, if pending, exactly what to publish.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to act on, from the path.

POST /v1/platform/sites/{slug}/domains

Attaches one or more CUSTOM public hostnames to this org's site.

Binding a host you do not own would let you shadow it at the edge, so which outcome you get depends on whether ownership is already established: a SuperAdmin vouches (the operator manages the customer's DNS, so its bind IS the proof) and binds VERIFIED immediately; every other caller, INCLUDING an admin of the deployment's own brand org, has the host CLAIMED as pending and gets the DNS challenge back in bound[].records. A pending claim HOLDS the name so nobody else can take it, but it does not route until POST .../domains/{host}/verify proves control.

A hostname we operate is refused to a non-vouched caller (those are assigned by the platform, never claimed), a host another site already holds is a 409, and a name the platform holds is a 400 for EVERY caller — a vouch skips the ownership proof, never the host table's own invariant. Claims and binds are idempotent for the same (org, slug), and re-claiming returns the SAME token rather than invalidating a record the customer has already published. The edge cache-tag is flushed afterwards so a newly-verified host serves the current build immediately.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the site the hosts attach to, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
domainsstring[]Domains are the custom hostnames to attach, in order.
slugstringSlug is the site the hosts attach to, from the path.

POST /v1/platform/sites/{slug}/publish

Promotes a build output into a new release AND goes live with it — create+activate in one call, which is the 99% path.

It is exactly the two halves in sequence with no extra semantics, so the staged flow and the one-shot flow can never drift apart: source is promoted under the same org-relative rule and the same guards CreateRelease applies, then the site's pointer is flipped to it, the public host is claimed and the edge is purged. Idempotent on unchanged bytes — same manifest, same release id, no copy — and billed once, after the release exists.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the site to publish, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
slugstringSlug is the site to publish, from the path.
sourcestringSource is the build output to promote, as a path RELATIVE to your org's own storage space — never a URL and never a…

POST /v1/platform/sites/{slug}/purge

Flushes the site's edge cache without redeploying anything.

It invalidates the edge cache-tag site-<org>-<slug> and stamps lastPurgeAt (unix seconds), and it NEVER writes or deletes the S3 origin — the live build keeps serving; only stale copies held at the edge drop, so the next request re-fetches the current artifact from origin. Idempotent, and an edge that is unconfigured or failing is not fatal: lastPurgeAt is still stamped and the answer is still the updated project.

Scope: a validated principal is required (403 without one) and the project is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to act on, from the path.

POST /v1/platform/sites/{slug}/releases/{release}/activate

Points the site at an existing release — the go-live, and equally the ROLLBACK.

Aim it at an older release and the site serves that one again: releases are immutable and retained to the retention depth, so nothing is rebuilt or re-copied and the flip is one atomic statement. Before the flip, two conditions run in the order that gives each its own honest answer — the ROW says whether this release exists for this tenant at all (404, with no signal about a foreign id), and only then do the BYTES say whether it can still serve (410 GONE when retention has reclaimed them; that rollback target is not coming back, so publish again). Going live also claims the public host and purges the edge, so the release is reachable and no cached predecessor is served. NOT billed: no new content is produced, only a pointer moved.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the site the release belongs to, from the path.
releasepathstringyesRelease is the content-addressed release id ("rel_" + 32 hex chars), from the path.

GET /v1/platform/sites/{slug}/releases

Returns a site's releases newest-first, marking the active one — the rollback menu.

Each row carries the release id to activate, the source it was promoted from, its object and byte counts, and the URL if it is the one serving. Retention bounds the list, so it is the set that can actually still be rolled back to, not a full history.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to act on, from the path.

POST /v1/platform/sites/{slug}/releases

Promotes a build output into a new immutable release WITHOUT serving it — the staged half of publishing, for when you want to check a release before it goes live. Answers 201.

source is a path RELATIVE to your org's own storage space: the org segment is prepended server-side from the validated principal and the bucket is never in the request at all, so a server-side copy can only ever reach bytes your org already owns. The prefix is listed, content-addressed (SHA-256 over the sorted manifest of key/size/etag), and copied into an immutable <org>/.releases/<slug>/<id>/ prefix; the row is written LAST, so a partial copy is unreachable rather than merely unlikely. Re-publishing an unchanged source is idempotent BY CONSTRUCTION — same bytes, same id, no copy at all.

The source must contain index.html at its root and stay under the same file and byte caps an artifact deploy does (413 past them); a source that changes mid-copy is a 409 and the release is abandoned. Each publish also reclaims releases past the retention depth, so a site's release space stays bounded. This is the billable half — the hosting gate runs before any copy, and the debit lands once the release exists.

Scope: a validated principal is required (403 without one) and the site is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the site to publish, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
slugstringSlug is the site to publish, from the path.
sourcestringSource is the build output to promote, as a path RELATIVE to your org's own storage space — never a URL and never a…

GET /v1/platform/sites/{slug}

Returns one project of yours by slug — its settings, its live URL and the deployment currently serving it.

Scope: a validated principal is required (403 without one) and the lookup is keyed by (org, slug), so another tenant's slug is a 404 exactly like a nonexistent one.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to act on, from the path.

PATCH /v1/platform/sites/{slug}

Changes a project's settings, and only the settings you send.

Every field is optional and absent means "leave it": name may not be blanked, framework must stay a known build hint, and cacheControl is capped at 256 characters with no newlines (it becomes a response header). visibility flips public/private under the same rule as create — public is free, private needs a funded org. upstream and license are free-text credit for third-party work, and sending "" clears one. Changing anything reconciles the project's canonical git repo, so a visibility change reaches the source and not just the listing.

hidden/hiddenReason are platform MODERATION and are ignored unless the caller is a platform admin; they remove a project from the public catalogue without touching the publisher's own visibility choice, so un-hiding restores exactly what they asked for.

Scope: a validated principal is required (403 without one) and the project is resolved within that principal's org, so another tenant's slug is a 404.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to update, from the path.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
cacheControlstring
descriptionstring
frameworkstring
hiddenbooleanHidden is MODERATION, and the only admin-gated field on this body: it pulls a public project out of the catalogue from…
hiddenReasonstring
licensestring
namestring
repoobject
slugstringSlug is the project to update, from the path.
tagsobjectTags sets the site's browser tag config: platform slug → non-secret pixel id (e.g. {"ga4":"G-…","meta":"…"}).
upstreamstringUpstream/License credit the third-party work this app was published from — settable after the fact, because the demos…
visibilitystringVisibility flips an existing project between "public" and "private".

DELETE /v1/platform/sites/{slug}

Deletes a project and takes its site off the internet.

The metadata delete is authoritative and everything after it is best-effort, in this order: the public <slug> subdomain binding is released so the slug is free to reclaim, the release rows are dropped so a reclaimed slug never inherits the previous owner's rollback menu, the S3 origin is purged under BOTH <org>/<slug>/ and the site's sibling release space, and the edge cache-tag is flushed. A failure in any of those is logged and the delete still answers 204 — resurrecting a project because a purge missed would be worse than a leaked prefix.

Scope: a validated principal is required (403 without one) and the project is resolved within that principal's org, so another tenant's slug is a 404 and nothing of theirs is touched.

ParameterInTypeRequiredDescription
slugpathstringyesSlug is the project to act on, from the path.

GET /v1/platform/sites

Returns every project your org owns.

Each row carries the slug, name, framework, visibility, status and live URL — the same rows console and the builder render, because there is only one store behind both. It requires a validated principal (403 without one) and is keyed by that principal's org, so it never contains another tenant's project.

POST /v1/platform/sites

Creates a project — the handle a site is deployed and served under — and answers 201 with it in draft.

name is required; slug is derived from the name when omitted and is the identifier that matters — it becomes the S3 key segment, the public host <slug>.hanzo.app, and the handle every later call addresses, so it must match ^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$ and may not be a reserved label such as api or admin. framework is a build hint from a closed set, defaulting to static; it never gates a deploy, it only tells CI how to build a linked repo.

Two defaults are worth knowing: the analytics beacon is ON unless analytics is explicitly false, and visibility is public unless asked otherwise. Publishing publicly is free; PRIVATE is the paid feature, and an unfunded org asking for it is refused rather than quietly published as public. Creation also provisions the project's data space and a canonical git repo, both best-effort — neither can fail the create.

Scope: a validated principal is required (403 without one) and the project is created in THAT principal's org. The slug is unique per org, so a slug already used in the caller's own org is a 409 while the same slug in another org is irrelevant.

Request bodyapplication/json (required)

FieldTypeRequiredDescription
analyticsbooleanAnalytics is the opt-OUT for the wired-by-default analytics beacon: absent (nil) ⇒ ON (the default); explicit false ⇒…
descriptionstring
frameworkstring
licensestring
namestring
repoobject
slugstring
upstreamstringUpstream/License credit the third-party work this project was published from.
visibilitystringVisibility is "public" (the default when absent) or "private".

Platform guide · All Hanzo APIs · Interactive reference

How is this guide?

On this page

platformGET /v1/platform/apps/{app}/cdGET /v1/platform/apps/{app}GET /v1/platform/appsPOST /v1/platform/appsGET /v1/platform/cdGET /v1/platform/ciPOST /v1/platform/fleet/{app}/deployGET /v1/platform/fleet/{app}GET /v1/platform/fleetGET /v1/platform/healthPOST /v1/platform/projects/{project}/apps/{app}/deployGET /v1/platform/projects/{project}/apps/{app}/deployments/{id}/logsGET /v1/platform/projects/{project}/apps/{app}/deployments/{id}GET /v1/platform/projects/{project}/apps/{app}/deploymentsPOST /v1/platform/projects/{project}/apps/{app}/domains/{host}/verifyDELETE /v1/platform/projects/{project}/apps/{app}/domains/{host}GET /v1/platform/projects/{project}/apps/{app}/domainsPOST /v1/platform/projects/{project}/apps/{app}/domainsPUT /v1/platform/projects/{project}/apps/{app}/envPOST /v1/platform/projects/{project}/apps/{app}/previewPOST /v1/platform/projects/{project}/apps/{app}/promotePOST /v1/platform/projects/{project}/apps/{app}/rollbackPOST /v1/platform/projects/{project}/apps/{app}/startPOST /v1/platform/projects/{project}/apps/{app}/stopGET /v1/platform/projects/{project}/apps/{app}DELETE /v1/platform/projects/{project}/apps/{app}GET /v1/platform/projects/{project}/appsPOST /v1/platform/projects/{project}/appsGET /v1/platform/projects/{project}GET /v1/platform/projectsPOST /v1/platform/sites/{slug}/deployGET /v1/platform/sites/{slug}/deployments/{id}GET /v1/platform/sites/{slug}/deploymentsPOST /v1/platform/sites/{slug}/domains/{host}/verifyDELETE /v1/platform/sites/{slug}/domains/{host}GET /v1/platform/sites/{slug}/domainsPOST /v1/platform/sites/{slug}/domainsPOST /v1/platform/sites/{slug}/publishPOST /v1/platform/sites/{slug}/purgePOST /v1/platform/sites/{slug}/releases/{release}/activateGET /v1/platform/sites/{slug}/releasesPOST /v1/platform/sites/{slug}/releasesGET /v1/platform/sites/{slug}PATCH /v1/platform/sites/{slug}DELETE /v1/platform/sites/{slug}GET /v1/platform/sitesPOST /v1/platform/sites