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:
- Email verification is required for every mutating endpoint (create app, deploy, rollback, env writes, domain writes). Unverified accounts get
403. - Rate limit: mutating endpoints are throttled per user at 10 writes/min with a burst of 5. Exceeding it returns
429with aRetry-Afterheader (whole seconds) — wait that long and retry. - Idempotency:
POST /api/appsandPOST /api/apps/{name}/deployaccept an optionalIdempotency-Keyheader (any opaque string up to 255 chars — a UUID works well). The first request runs and its response is stored against the key; a retry with the same key and same body returns that stored response verbatim, withIdempotent-Replayed: trueset, instead of creating a second app or kicking a second deploy. Reusing a key with a different body is422 idempotency_key_reuse; a retry that arrives while the original is still running is409 idempotency_key_in_progress. Server errors (5xx) are never cached, so a failed call stays retryable. Keys expire after 24 hours. - Errors are JSON:
{"error": "message"}.
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).
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Lowercase RFC 1123 label (a-z, 0-9, interior hyphens, max 63 chars). Becomes the app's subdomain. |
repo_url | string | one of | HTTPS only, no embedded credentials. Platform Forgejo repos clone with the platform token; external public HTTPS repos clone anonymously. |
template | string | one of | Template 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. |
framework | string | no | Framework hint (e.g. express). Auto-detected when omitted. Not allowed with template. |
port | integer | no | Container port (1–65535). Framework default when omitted. Not allowed with template. |
needs_database | boolean | no | Provision a PostgreSQL database on first deploy. Not allowed with template. |
| Status | Meaning |
|---|---|
201 | Created. Body: {"app": {...}} including the computed url. |
400 | Invalid name, repo URL, port, or template; or both/neither of repo_url/template. |
403 | App limit reached for your tier. |
409 | App 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. |
502 | Template 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.
| Field | Type | Notes |
|---|---|---|
repo_url | string | New git repo to deploy from. Same validation as create (HTTPS only, no embedded credentials). |
framework | string | New framework hint. |
port | integer | New container port (1–65535). |
| Status | Meaning |
|---|---|
200 | Updated. Body: {"app": {...}} as persisted. |
400 | Invalid repo URL or port, or an empty patch. |
404 | No 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:
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:
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
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).
| Status | Meaning |
|---|---|
200 | {"status": "rolled_back"} |
409 | No previous successful deploy to roll back to. |
404 | App 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.
- Key syntax:
^[A-Za-z_][A-Za-z0-9_]*$ - Max 64 vars per app, max 8 KB per value
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:
- Ownership (required before the add succeeds): the first call returns
422with atxt_record/txt_valuepair. Publish that TXT record, wait for propagation, and retry — the token is deterministic per (user, domain), so the same value always works. - Routing/TLS: point an A record at the platform. Caddy retries certificate issuance in the background, so the cert converges once the record resolves.
| Status | Meaning |
|---|---|
201 | Domain stored and routed. |
400 | Invalid domain (lowercase hostname, ≥2 labels), reserved platform domain, or per-app limit (10) reached. |
409 | Domain already in use — domains are globally unique across the platform. |
422 | Ownership 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:
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:
| Header | Value |
|---|---|
X-EZKeel-Event | The event name (e.g. deploy.succeeded). |
X-EZKeel-Delivery | The webhook id. |
X-EZKeel-Signature | sha256=<hex> where <hex> is HMAC-SHA256(secret, rawBody). Recompute it over the exact bytes received and compare to authenticate. |
The JSON body:
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:
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.