API Reference

The EZKeel headless REST API at https://app.ezkeel.com. Create apps, trigger deploys, poll step-by-step status, manage env vars and custom domains — all over plain HTTPS with JSON.

Why a REST API

EZKeel is a deployment platform built for AI agents. Agents need deterministic, declarative deploys: a fixed request shape in, a machine-readable status out, no scraping a TUI, no holding a WebSocket, and no LLM in the loop. This API drives the exact same 4-step pipeline the dashboard chat uses — tier limits, deploy locks, and pipeline semantics cannot drift apart — but every call is a plain HTTP request an agent (or a curl script) can retry and reason about.

OpenAPI spec

The whole surface below is also published as a machine-readable OpenAPI 3.1 document at GET /api/openapi.json (public, no auth). Point an agent, codegen tool, or API client at app.ezkeel.com/api/openapi.json — the spec is embedded in the server binary, so it always matches the running API.

Authentication

Create an API key in the dashboard under Settings → API keys. The raw key (prefixed ezk_) is shown once at creation — store it securely. Send it as a Bearer token on every call:

terminal
$ curl -H "Authorization: Bearer ezk_..." https://app.ezkeel.com/api/apps

Apps

POST /api/apps

Register an app so a follow-up deploy can build and ship it. Set exactly one of repo_url (existing git repository) or template (curated template, copied into a fresh repo you own).

FieldTypeRequiredDescription
namestringyesLowercase RFC 1123 label (a-z, 0-9, interior hyphens, max 63 chars). Becomes the app's subdomain.
repo_urlstringone ofHTTPS only, no embedded credentials. Platform Forgejo repos clone with the platform token; external public HTTPS repos clone anonymously.
templatestringone ofTemplate slug from GET /api/templates. The template is copied into a new private repo you own and can push to, and the manifest sets framework/port/needs_database — combining them with template is a 400.
frameworkstringnoFramework hint (e.g. express). Auto-detected when omitted. Not allowed with template.
portintegernoContainer port (1–65535). Framework default when omitted. Not allowed with template.
needs_databasebooleannoProvision a PostgreSQL database on first deploy. Not allowed with template.
terminal
# One-click template, headless: fresh repo + deploy metadata from the manifest $ curl -X POST https://app.ezkeel.com/api/apps \ -H "Authorization: Bearer $EZKEEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"my-todo","template":"todo-list"}' {"app":{"name":"my-todo","framework":"express","port":3000,"database_name":"app_my_todo", "forgejo_repo":"https://git.ezkeel.com/ezkeel-admin/my-todo.git", ...}}
terminal
$ curl -X POST https://app.ezkeel.com/api/apps \ -H "Authorization: Bearer $EZKEEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"my-app","repo_url":"https://github.com/user/my-app","needs_database":true}' {"app":{"id":"...","name":"my-app","subdomain":"my-app","status":"deploying", "forgejo_repo":"https://github.com/user/my-app","database_name":"app_my_app", "port":3000,"url":"https://my-app.apps.ezkeel.com", ...}}
StatusMeaning
201Created. Body: {"app": {...}} including the computed url.
400Invalid name, repo URL, port, or template; or both/neither of repo_url/template.
403App limit reached for your tier.
409App name already taken. error=name_in_use_by_stopped_app means your own soft-stopped app holds the name — POST .../deploy revives it, DELETE ...?purge=1 frees the name.
502Template repo provisioning failed (the app row is rolled back — retry after fixing the cause).

GET /api/apps

List your apps. Returns {"apps": [...]} where each entry is the app row plus a computed url field.

PATCH /api/apps/{name}

Change an app's deploy settings after create. All fields optional, at least one required; takes effect on the next deploy.

FieldTypeNotes
repo_urlstringNew git repo to deploy from. Same validation as create (HTTPS only, no embedded credentials).
frameworkstringNew framework hint.
portintegerNew container port (1–65535).
terminal
$ curl -X PATCH https://app.ezkeel.com/api/apps/my-app \ -H "Authorization: Bearer $EZKEEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"repo_url":"https://github.com/user/new-repo"}' {"app":{"name":"my-app","forgejo_repo":"https://github.com/user/new-repo", ...}}
StatusMeaning
200Updated. Body: {"app": {...}} as persisted.
400Invalid repo URL or port, or an empty patch.
404No app with that name.

DELETE /api/apps/{name}

Tear down an app: stops and removes the container and its route. By default this is a soft-stop — the app row survives (and keeps the name reserved) so a later deploy can revive it. Add ?purge=1 to also delete the app record and free the name. Returns 204 on success.

Deploys

POST /api/apps/{name}/deploy

Kick off the 4-step pipeline in the background. Returns 202 immediately with the deploy ID to poll:

202 Accepted
{"deploy_id": "f7a3c2e1-...", "status": "running"}

If another deploy holds the per-server lock, the response is {"status": "queued"} without a deploy_id — discover it via GET /api/apps/{name}/deploys once the run starts. Other statuses: 404 app not found, 409 deploy already in progress, 422 app has no repo configured.

GET /api/deploys/{id}

The deploy row plus its ordered steps — the machine-readable progress surface. The four steps are provision_db, clone_build, deploy_container, and caddy_route:

200 OK
{ "deploy": { "id": "f7a3c2e1-...", "app_id": "...", "user_id": "...", "status": "running", "started_at": "2026-06-11T10:00:00Z", "created_at": "2026-06-11T10:00:00Z" }, "steps": [ {"step_name": "provision_db", "step_order": 1, "status": "success", "output": "..."}, {"step_name": "clone_build", "step_order": 2, "status": "running"}, {"step_name": "deploy_container", "step_order": 3, "status": "pending"}, {"step_name": "caddy_route", "step_order": 4, "status": "pending"} ] }

Deploy status is one of pending, running, success, failed, canceled. Step status adds skipped. Failed deploys and steps carry an error field; steps carry output (build log tail on failure).

GET /api/apps/{name}/deploys?limit=N

Recent deploys for an app, newest first. Returns {"deploys": [...]}. limit is 1–100, default 20.

Polling pattern

terminal
# Poll every 5s until the deploy leaves the running state $ DEPLOY_ID=$(curl -s -X POST https://app.ezkeel.com/api/apps/my-app/deploy \ -H "Authorization: Bearer $EZKEEL_API_KEY" | jq -r .deploy_id) $ while :; do STATUS=$(curl -s https://app.ezkeel.com/api/deploys/$DEPLOY_ID \ -H "Authorization: Bearer $EZKEEL_API_KEY" | jq -r .deploy.status) echo "deploy: $STATUS" [ "$STATUS" = "running" ] || [ "$STATUS" = "pending" ] || break sleep 5 done deploy: running deploy: running deploy: success

Rollback

POST /api/apps/{name}/rollback

Flip the app's container back to its previous image (the agent tags the running image before every deploy).

StatusMeaning
200{"status": "rolled_back"}
409No previous successful deploy to roll back to.
404App not found.

Environment variables

App env vars are encrypted at rest with AES-256-GCM and injected into the container at deploy time.

GET /api/apps/{name}/env

Returns the decrypted env map: {"env": {"KEY": "value", ...}}.

PUT /api/apps/{name}/env

Merge semantics: listed keys are created or replaced; unlisted keys are untouched. Values apply on the next deploy — the container is not restarted. Chain PUT env → POST deploy.

terminal
$ curl -X PUT https://app.ezkeel.com/api/apps/my-app/env \ -H "Authorization: Bearer $EZKEEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"env":{"NODE_ENV":"production","LOG_LEVEL":"info"}}' {"updated":2,"note":"values apply on the next deploy"}

DELETE /api/apps/{name}/env/{KEY}

Removes one key. Returns 204 on success, 404 if the key doesn't exist.

Custom domains

POST /api/apps/{name}/domains

Attach a custom domain. Two DNS records are involved:

422 Unprocessable Entity — first call, before the TXT record exists
{"error":"domain_not_verified", "detail":"publish the TXT record below, wait for DNS propagation, then retry", "txt_record":"_ezkeel-verify.example.com", "txt_value":"ezkeel-verify=3f9a..."}
terminal
$ curl -X POST https://app.ezkeel.com/api/apps/my-app/domains \ -H "Authorization: Bearer $EZKEEL_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain":"example.com"}' {"domain":"example.com","status":"routed"}
StatusMeaning
201Domain stored and routed.
400Invalid domain (lowercase hostname, ≥2 labels), reserved platform domain, or per-app limit (10) reached.
409Domain already in use — domains are globally unique across the platform.
422Ownership not verified yet. Body carries txt_record and txt_value to publish.

GET /api/apps/{name}/domains

Returns {"domains": ["example.com", ...]}.

GET /api/apps/{name}/domains/{domain}

TLS convergence status for an attached domain. The add returns before certificate issuance finishes (Caddy retries ACME in the background) — poll this instead of curling the domain yourself. The platform performs a verified TLS handshake against the domain and reports:

200 OK
{"domain":"example.com","status":"active", "cert":{"issuer":"R11","not_after":"2026-09-01T00:00:00Z","dns_names":["example.com"]}} // or, while DNS/ACME hasn't converged yet: {"domain":"example.com","status":"pending", "detail":"x509: certificate signed by unknown authority"}

status is active (valid cert is being served) or pending (with the handshake error verbatim, so you can tell "DNS not pointed here" from "issuance in flight"). 404 when the domain isn't attached to this app.

DELETE /api/apps/{name}/domains/{domain}

Removes the route and the domain. Returns 204 on success, 404 if the domain isn't attached to this app.

Deploy webhooks

Instead of polling GET /api/deploys/{id}, register a webhook and EZKeel will push a signed event when a deploy reaches a terminal state. Useful for CI bots, chat-ops relays, or any agent that wants to react on the event rather than spin a poll loop.

POST /api/apps/{name}/webhooks

Register an endpoint. The body is {"url": "https://..."}. The URL must be https, carry no embedded credentials, and resolve to a public address — internal/loopback/metadata targets are rejected (SSRF guard, re-checked at delivery time so DNS rebinding can't bypass it). Max 5 webhooks per app.

The 201 response includes a secret shown only once. Store it — every delivery is signed with it so you can verify the payload really came from EZKeel.

GET /api/apps/{name}/webhooks

Lists the app's webhooks (id, url, created_at) — secrets are never returned again.

DELETE /api/apps/{name}/webhooks/{id}

Removes a webhook. 204 on success, 404 if the id isn't one of your app's webhooks.

Delivery format

On deploy.succeeded, deploy.failed, or deploy.rolled_back, EZKeel sends a POST with these headers:

HeaderValue
X-EZKeel-EventThe event name (e.g. deploy.succeeded).
X-EZKeel-DeliveryThe webhook id.
X-EZKeel-Signaturesha256=<hex> where <hex> is HMAC-SHA256(secret, rawBody). Recompute it over the exact bytes received and compare to authenticate.

The JSON body:

{ "event": "deploy.succeeded", "app": "my-app", "deploy_id": "3f2a...e91", "status": "succeeded", "url": "https://my-app.apps.ezkeel.com", "timestamp": "2026-06-13T09:41:00Z" }

Delivery is best-effort: a non-2xx or network error is retried once, then logged. A 4xx from your endpoint is treated as permanent and not retried. Redirects are not followed.

Deploy a repo with curl

The complete agent loop, start to finish. First, get a key: log in to the dashboard at app.ezkeel.com, open Settings → API keys, create a key, and copy the ezk_... value (shown once). Then:

terminal
# 0. Your API key from Settings → API keys $ export EZKEEL_API_KEY=ezk_... $ API=https://app.ezkeel.com # 1. Create the app from a public repo $ curl -s -X POST $API/api/apps \ -H "Authorization: Bearer $EZKEEL_API_KEY" -H "Content-Type: application/json" \ -d '{"name":"my-app","repo_url":"https://github.com/user/my-app","needs_database":true}' {"app":{"name":"my-app","url":"https://my-app.apps.ezkeel.com", ...}} # 2. Set env vars (applied on the next deploy) $ curl -s -X PUT $API/api/apps/my-app/env \ -H "Authorization: Bearer $EZKEEL_API_KEY" -H "Content-Type: application/json" \ -d '{"env":{"NODE_ENV":"production"}}' {"updated":1,"note":"values apply on the next deploy"} # 3. Deploy $ DEPLOY_ID=$(curl -s -X POST $API/api/apps/my-app/deploy \ -H "Authorization: Bearer $EZKEEL_API_KEY" | jq -r .deploy_id) # 4. Poll until it finishes $ while :; do STATUS=$(curl -s $API/api/deploys/$DEPLOY_ID \ -H "Authorization: Bearer $EZKEEL_API_KEY" | jq -r .deploy.status) [ "$STATUS" = "running" ] || [ "$STATUS" = "pending" ] || break; sleep 5 done; echo "deploy: $STATUS" deploy: success # 5. Hit the live app $ curl -s https://my-app.apps.ezkeel.com/ Hello from my-app

Tip: deploying from a terminal instead of a script? The CLI wraps this same flow — and ezkeel up --json emits NDJSON progress events agents can parse line by line.