The Zeloxa Host API lets you do from a script, a CI pipeline or an AI agent what you do in Hosting in the dashboard: list and create sites, upload a build and put it live, roll back, read deployment logs, and connect custom domains. Automation apps such as Zapier and Make use its event hooks to receive form submissions, deploy and domain events.
- Base URL:
https://zeloxalabs.com/api/v1/host - Format: JSON in, JSON out (uploads are a raw zip body)
- Auth:
Authorization: Bearer <API token>
Prefer not to write HTTP calls yourself? The Zeloxa CLI
wraps this API (zeloxa deploy, zeloxa rollback, …), and AI agents can use the
same operations through the Zeloxa MCP server. To be told when a
deployment finishes, use webhooks rather than polling.
curl https://zeloxalabs.com/api/v1/host
# {"name":"Zeloxa Host API","version":"v1","docs":"https://zeloxalabs.com/docs/host-api"}Authentication
Every endpoint except GET /api/v1/host needs an API token.
- In the dashboard, open Hosting → API tokens and create a token.
- Choose its scopes and, optionally, an expiry date.
- Copy the token. It is shown once; Zeloxa stores only a hash of it.
Tokens start with
zx_and are 46 characters long.
Send it on every request:
Authorization: Bearer zx_…export ZELOXA_TOKEN="zx_…" # e.g. from a CI secret
curl https://zeloxalabs.com/api/v1/host/whoami \
-H "Authorization: Bearer $ZELOXA_TOKEN"Things to know:
- A bad token is always an error. A malformed, unknown, expired or revoked
token gets
401 INVALID_TOKEN; the API never falls back to another way of signing in. The message is the same for every reason, on purpose. A request with no credentials at all gets401 UNAUTHORIZED. - Suspended accounts. While an account is canceled or has an unpaid
invoice, its tokens stop working (
401 INVALID_TOKEN). The dashboard keeps working so the account can be brought back into good standing. - 401 responses carry
WWW-Authenticate: Bearer realm="zeloxa"(witherror="invalid_token"when a token was sent but rejected). - Revoking a token in Hosting → API tokens takes effect on the next request.
- Dashboard sessions. The dashboard's own sign-in cookie is also accepted,
so the dashboard can call this API. It is not meant for scripts: requests
that change something are refused with
403 FORBIDDENunless they come from the dashboard itself. Use a token. - Keep tokens server-side. The API does not send CORS headers, so browser JavaScript on another site cannot read its responses. Store tokens in your CI provider's secret store, never in a repository or front-end code.
Scopes
Each token has one or more scopes. Higher scopes include the lower ones.
| Scope | Allows | Includes |
|---|---|---|
host:read | List and read sites, deployments, logs, domains and site integrations; re-check a domain's verification; whoami | — |
host:deploy | Upload a deployment, promote a deployment, roll back | host:read |
host:hooks | Subscribe and unsubscribe event hooks; read hook samples (which include form submissions) | host:read |
host:admin | Create sites (each site is billed monthly), add and remove custom domains, connect and change site integrations and the consent banner | host:deploy, host:hooks |
host:deploy and host:hooks sit side by side: neither includes the other,
and host:admin includes both. In the dashboard, host:hooks is the
Automations access level.
Use the narrowest scope that works: host:deploy for CI, host:hooks for
Zapier and Make, host:read for dashboards and monitoring.
Some settings can only be changed in the dashboard, with no scope that
reaches them: API tokens, the integrations on the Integrations page (Slack,
Discord and signed webhooks), site passwords and security headers. A leaked
token therefore cannot create more tokens, redirect your existing
notifications or lock you out of a site. A host:hooks token can add and
remove its own hook subscriptions only, and everything it subscribed stops the
moment the token is revoked.
A request whose token lacks the scope gets 403 INSUFFICIENT_SCOPE:
{
"error": {
"code": "INSUFFICIENT_SCOPE",
"message": "This action needs the host:deploy scope.",
"details": { "required": "host:deploy", "granted": ["host:read"] }
}
}Conventions
- JSON. Responses are
application/jsonobjects (the one exception is hook samples, a bare array). Request bodies must be sent withContent-Type: application/json; any other content type is a400 VALIDATION_ERROR. Unknown fields in a request body are ignored. - Uploads are the zip file itself as the request body, with
Content-Type: application/zip(application/octet-streamandapplication/x-zip-compressedare also accepted). Form uploads (multipart/form-data, i.e.curl -F) are refused with a400that says so. - Field names are camelCase. Fields are always present; a value that does
not exist is
null, never omitted. The exceptions are a DNS record's optionalnoteand an error's optionaldetails. - Timestamps are ISO 8601 in UTC with milliseconds, e.g.
2026-10-02T09:41:07.512Z. - IDs are UUIDs. Sites, deployments and domains each have one.
- Deployment references. Wherever a path takes
{ref}, you can use the deployment's id or its per-site number —12or#12. In a URL path,#must be written%23(/deployments/%2312); plain12is simpler. - Ordering. Sites and domains are listed newest first, deployments by number (newest first), log lines oldest first.
- Caching. Every response is sent with
Cache-Control: no-store. - Methods.
HEADworks whereverGETdoes;OPTIONSreturns theAllowheader. Any other method gets405 METHOD_NOT_ALLOWEDwith anAllowheader. An unknown path under/api/v1gets a JSON404 NOT_FOUND. - Request bodies for JSON endpoints are limited to 64 KB.
Errors
Every error has the same shape. Branch on code, which is stable; message is
for people and may be reworded.
{
"error": {
"code": "SITE_NOT_FOUND",
"message": "Site not found",
"details": {}
}
}details is present only when there is something useful in it.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The request is malformed: invalid JSON, wrong content type, a missing or invalid field or query parameter. details.parameter names a bad query/body parameter; details.issues lists field problems. |
| 400 | INVALID_ARCHIVE | The upload is not a usable build: not a zip, a damaged zip whose contents do not match its recorded sizes, encrypted entries, compression other than deflate or stored, nothing publishable in it, no index.html (or _worker.js) at the top, too many files or archive entries, a file over the per-file limit, or over the size limit once unpacked. |
| 401 | UNAUTHORIZED | No credentials were sent. |
| 401 | INVALID_TOKEN | The token is malformed, unknown, expired or revoked, or the account is suspended. |
| 403 | INSUFFICIENT_SCOPE | The token is valid but lacks the scope. details.required, details.granted. |
| 403 | FORBIDDEN | The request is not allowed this way (a dashboard session used from outside the dashboard). |
| 403 | ORGANIZATION_REQUIRED | The signed-in user has no organization yet (dashboard sessions only). |
| 403 | SITE_LIMIT_REACHED | The account has reached its site limit. details.limit. Contact Zeloxa to raise it. |
| 404 | SITE_NOT_FOUND | No such site in this account. |
| 404 | DEPLOYMENT_NOT_FOUND | No such deployment on that site. |
| 404 | DOMAIN_NOT_FOUND | No such domain on that site. |
| 404 | NOT_FOUND | No such endpoint, or another missing resource. |
| 405 | METHOD_NOT_ALLOWED | The endpoint does not support that method. See the Allow header and details.allowed. |
| 409 | DEPLOYMENT_NOT_READY | The deployment cannot be made live (its status is not ready), or there is no earlier deployment to roll back to. details.status when known. |
| 409 | DOMAIN_TAKEN | That hostname is already connected to a site. |
| 409 | LIMIT_REACHED | An account limit other than the site limit is reached (for example API tokens or integrations). details.limit. Remove one to make room. |
| 409 | CONFLICT | The request clashes with something that already exists (for example the same URL twice). details.field when known. |
| 413 | PAYLOAD_TOO_LARGE | The request body is over the limit. details.limitBytes. |
| 429 | RATE_LIMITED | Too many requests. Wait and retry with backoff. |
| 500 | INTERNAL_ERROR | Something failed on Zeloxa's side. For uploads, read this before retrying. |
| 502 | UPSTREAM_ERROR | A provider Zeloxa depends on failed (storage, edge network, certificates). The operation did not happen; retrying is safe. |
| 503 | SERVICE_UNAVAILABLE | The feature is temporarily unavailable or not set up yet. Nothing was changed; try again later. |
Resources in other accounts are reported as not found, never as forbidden.
Retrying. GET requests are always safe to retry. For writes:
400,403,404,409,413— fix the request; retrying it unchanged gives the same answer.502 UPSTREAM_ERRORon an upload — the deployment was recorded as failed and cleaned up, and the live site is unchanged. Retrying uploads a new deployment.500 INTERNAL_ERRORon an upload withdetails.deploymentId— check before retrying, see below.
Limits
| Limit | Value |
|---|---|
| Upload size (the zip, as sent) | 100 MB |
| Total unpacked size of a build | 100 MB |
| Largest single file in a build | 25 MB |
| Files in a build | 5,000 |
| Entries in an archive, skipped ones included | 50,000 |
| Path of a file in a build | 1,024 characters; longer entries are skipped |
| Upload request duration | 300 seconds |
| JSON request body | 64 KB (160 KB for PUT …/integrations/{kind}) |
| Site name | 1–120 characters |
| Sites per account | 50 (contact Zeloxa for more) |
| Hook subscriptions per account | 100 |
limit on deployment lists | 1–100, default 20 |
limit on log pages | 1–2,000, default 500 |
A Content-Length over the upload limit is refused before anything is read;
a body that turns out to be larger while it streams is cut off at the limit.
Values of limit above the maximum are treated as the maximum.
Objects
Site
{
"id": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"name": "Marketing site",
"slug": "marketing-site",
"url": "https://marketing-site.zeloxa.app",
"published": true,
"runtime": "static",
"currentDeploymentId": "b7a1d2c3-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"deploySource": "local",
"githubRepo": null,
"githubBranch": null,
"lastDeployAt": "2026-10-02T09:41:07.512Z",
"createdAt": "2026-09-28T14:02:11.000Z"
}| Field | Type | Notes |
|---|---|---|
url | string | The site's address on Zeloxa Host: https://<slug>.zeloxalabs.com. |
published | boolean | true once a deployment has gone live. |
runtime | "static" | "worker" | What the live deployment is: static files, or a full-stack app. |
currentDeploymentId | string | null | The deployment being served; null until the first deploy. |
deploySource | "local" | "github" | Where builds come from. Uploads through this API set it to local. |
githubRepo, githubBranch | string | null | Set for sites connected to GitHub. |
lastDeployAt | string | null | When the live deployment last changed (deploy, promote or rollback). |
Deployment
{
"id": "b7a1d2c3-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"siteId": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"number": 12,
"status": "ready",
"runtime": "static",
"source": "cli",
"fileCount": 48,
"totalBytes": 1835210,
"gitRepo": null,
"gitBranch": null,
"gitCommit": null,
"gitMessage": null,
"errorMessage": null,
"createdAt": "2026-10-02T09:41:05.880Z",
"readyAt": "2026-10-02T09:41:07.401Z",
"current": true,
"environment": "production",
"previewAlias": null,
"pullRequest": null
}| Field | Type | Notes |
|---|---|---|
number | integer | Per-site, starting at 1. Use it anywhere a {ref} is accepted. |
status | string | pending, uploading, building, ready, error or canceled. Only ready deployments can be live. |
source | string | upload (this API or the dashboard), cli, github or redeploy. |
fileCount, totalBytes | integer | The unpacked build. |
git* | string | null | Set for deployments built from GitHub. |
errorMessage | string | null | Why a deployment failed. |
readyAt | string | null | When it finished; null until then. |
current | boolean | Whether this is the deployment the site serves right now. |
environment | string | production, or preview for branch and pull request builds. A preview can never be promoted or rolled back to. |
previewAlias | string | null | A preview's address label: it is served at <previewAlias>--<slug>.<your hosting domain>. |
pullRequest | integer | null | The pull request a preview was built for. |
Deployments are immutable: a deployment's files are written once and never change, which is why promoting or rolling back is instant.
Domain
{
"id": "0c9d8e7f-6a5b-4c3d-8e2f-1a0b9c8d7e6f",
"siteId": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"hostname": "www.example.com",
"status": "pending",
"verified": false,
"sslStatus": "pending_validation",
"redirectTo": null,
"redirectStatus": 308,
"createdAt": "2026-10-02T09:50:00.000Z"
}status is pending until DNS and the certificate check out, then active
(with verified: true). sslStatus is the certificate's state, e.g.
pending_validation or active.
redirectTo is null when the domain serves the site. Otherwise every request
to the domain is answered with redirectStatus (301, 302, 307 or 308) to
https://<redirectTo> with the same path and query — typically
www.example.com → example.com or the other way round. The redirect only
happens once the domain is verified, like serving does.
Once a site has a verified custom domain that serves it, its default
<slug> address redirects there (see
primaryDomain). Every client address answers
plain HTTP with a 308 to HTTPS.
DNS record
Returned when a domain is added or re-checked. Create each record at your DNS provider as given.
{ "type": "CNAME", "name": "www.example.com", "value": "cname.zeloxalabs.com" }type is CNAME or TXT. name is the full hostname (@ for an apex
domain such as example.com). If your DNS provider adds your domain to every
name itself, enter only the part before it (www here). note, when
present, explains the record — for example that an apex domain needs CNAME
flattening or an ALIAS record at most registrars.
Log line
{ "seq": 3, "stream": "system", "line": "Stored 48 files.", "at": "2026-10-02T09:41:07.120Z" }seq increases within a deployment and is the cursor for paging. stream is
stdout, stderr (build output) or system (written by Zeloxa Host: received,
unpacked, stored, released or failed).
Form
{
"id": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"siteId": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"name": "Contact",
"enabled": true,
"createdAt": "2026-09-30T08:12:44.000Z"
}Forms are created and configured in the dashboard (Forms on a project). The API lists them so automation apps can offer one as a filter.
Hook
An event hook subscription.
{
"id": "8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a",
"event": "form.submission",
"siteId": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"formId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"source": "zapier",
"targetHost": "hooks.zapier.com",
"targetUrlMasked": "https://hooks.zapier.com/…k3x/",
"active": true,
"createdAt": "2026-10-03T10:00:00.000Z",
"lastDeliveryAt": "2026-10-03T10:04:12.311Z",
"lastDeliveryStatus": 200
}| Field | Type | Notes |
|---|---|---|
event | string | form.submission, deployment.ready, deployment.error, domain.verified or domain.failed. |
siteId, formId | string | null | Filters; null means every site, or every form. |
source | string | zapier, make or api — what created it, as declared or recognised from the client. |
targetHost, targetUrlMasked | string | Where deliveries go. The full URL is never returned after it is saved. |
active | boolean | false once the token that created it is revoked or expired: nothing is sent to it. |
lastDeliveryStatus | integer | null | The HTTP status the target last answered, 0 for no answer, null before the first delivery. |
Endpoints
Examples assume:
API="https://zeloxalabs.com/api/v1/host"
AUTH="Authorization: Bearer $ZELOXA_TOKEN"
SITE_ID="6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d"
DOMAIN_ID="0c9d8e7f-6a5b-4c3d-8e2f-1a0b9c8d7e6f" # from GET /sites/{siteId}/domainsGET /
What this API is. No authentication — useful to check a base URL.
curl "$API"{ "name": "Zeloxa Host API", "version": "v1", "docs": "https://zeloxalabs.com/docs/host-api" }GET /whoami
Which account and identity the credential acts as, and what it may do. Needs no particular scope. Use it to check a token before saving it, or to make a CI job fail early if it holds the wrong account's token.
curl "$API/whoami" -H "$AUTH"{
"tenant": { "id": "1d2e3f4a-5b6c-4d7e-8f9a-0b1c2d3e4f5a", "name": "Acme Inc" },
"actor": { "type": "token", "label": "GitHub Actions (zx_Q2xhdWQ…)" },
"scopes": ["host:deploy"],
"via": "token"
}actor.type is token for API tokens, user for a dashboard session and
mcp for an AI agent connected through the Zeloxa MCP server. via is
token, session or platform correspondingly.
GET /sites
All sites in the account, newest first.
curl "$API/sites" -H "$AUTH"{ "sites": [ { "id": "6f1c2b8e-…", "name": "Marketing site", "slug": "marketing-site", "url": "https://marketing-site.zeloxa.app", "…": "…" } ] }POST /sites
Create a site. Scope host:admin. Each site is billed monthly from the
moment it is created.
| Body field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–120 characters, trimmed. |
The slug (and so the site's address) is derived from the name; if it is
taken, a short suffix is added.
curl -X POST "$API/sites" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"name": "Marketing site"}'201 Created:
{ "site": { "id": "6f1c2b8e-…", "name": "Marketing site", "slug": "marketing-site", "url": "https://marketing-site.zeloxa.app", "published": false, "currentDeploymentId": null, "…": "…" } }Errors: 400 VALIDATION_ERROR (missing or invalid name),
403 SITE_LIMIT_REACHED.
GET /sites/{siteId}
A site, its custom domains and its live deployment.
curl "$API/sites/$SITE_ID" -H "$AUTH"{
"site": { "id": "6f1c2b8e-…", "…": "…" },
"domains": [ { "id": "0c9d8e7f-…", "hostname": "www.example.com", "status": "active", "verified": true, "…": "…" } ],
"currentDeployment": { "id": "b7a1d2c3-…", "number": 12, "status": "ready", "current": true, "…": "…" }
}currentDeployment is null until the site's first deployment.
GET /sites/{siteId}/settings
The site's routing and SEO settings.
curl "$API/sites/$SITE_ID/settings" -H "$AUTH"{
"settings": {
"trailingSlash": "auto",
"spaFallback": "auto",
"discourageSearchEngines": false,
"primaryDomain": { "mode": "auto", "hostname": null, "effective": "example.com" },
"defaultHost": "marketing-site.zeloxa.app",
"liveDeploymentLooksLikeSpa": false
}
}| Field | Notes |
|---|---|
trailingSlash | auto (default): /about/ redirects to /about, and /about serves about.html or about/index.html. always: page URLs end in a slash — /about and /about.html redirect (308) to /about/. never: /about/ and /about.html redirect to /about. Under always and never, /index.html redirects to /, and a redirect only happens when the canonical URL serves the same file. Full-stack apps get only the slash rule, and never under /api/. |
spaFallback | What an unknown route (a path without a file extension that matches no file) gets. on: index.html with 200, for a single-page app's client-side router. off: the build's own 404.html with 404, or a plain 404 page. auto (default): on when the live deployment looks like a single-page app — no 404.html, an index.html that loads a script, and at most two other HTML files — otherwise off. Deployments made before this setting existed count as single-page apps. |
discourageSearchEngines | true adds x-robots-tag: noindex, nofollow to every response and serves a robots.txt that disallows everything (unless the build ships its own). |
primaryDomain.mode | auto (default): the oldest verified custom domain that serves the site. domain: primaryDomain.hostname, while it is verified and serving (otherwise as auto). none: no primary domain. |
primaryDomain.effective | The primary domain right now, or null. When set, every request to defaultHost is redirected (308, same path and query) to it. Preview addresses are never redirected. |
liveDeploymentLooksLikeSpa | What spaFallback: "auto" follows for the live deployment (true for deployments made before detection existed, which are served as single-page apps); null when nothing is deployed. |
A build without a robots.txt of its own gets User-agent: * / Allow: /
(plus a Sitemap: line when the build has a sitemap.xml), or Disallow: /
on preview addresses and when discourageSearchEngines is on.
PATCH /sites/{siteId}/settings
Change some of the settings above. Scope host:admin. Omitted fields are left
as they are; unknown fields are refused.
| Body field | Type | Notes |
|---|---|---|
trailingSlash | string | auto, always or never. |
spaFallback | string | auto, on or off. |
discourageSearchEngines | boolean | |
primaryDomain | string | auto, none, or one of the site's custom domains (one that does not redirect). |
curl -X PATCH "$API/sites/$SITE_ID/settings" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"trailingSlash": "never", "primaryDomain": "example.com"}'Returns { "settings": … } as for GET. Changes reach every edge location
within 30 seconds (usually at once).
Errors: 400 VALIDATION_ERROR, 404 SITE_NOT_FOUND.
GET /sites/{siteId}/deployments
Deployment history, newest first.
| Query | Notes |
|---|---|
limit | 1–100, default 20. |
curl "$API/sites/$SITE_ID/deployments?limit=5" -H "$AUTH"{
"deployments": [
{ "id": "b7a1d2c3-…", "number": 12, "status": "ready", "current": true, "…": "…" },
{ "id": "a1b2c3d4-…", "number": 11, "status": "ready", "current": false, "…": "…" }
],
"currentDeploymentId": "b7a1d2c3-…"
}POST /sites/{siteId}/deployments
Upload a build and put it live. Scope host:deploy.
The body is a zip of your build output — the folder your build produces
(dist, out, build, …) — sent as-is with Content-Type: application/zip.
- The zip must have
index.htmlat its top level (or_worker.jsfor a full-stack app). If everything sits inside one folder (you zippeddistitself rather than its contents), that folder is stripped automatically. .git,node_modules,.DS_Store,__MACOSXand._*entries are ignored, as is any path that tries to leave the archive (../).- Builds with a
_worker.jsat the top run as full-stack apps where that is enabled for your account; otherwise deploy a static build.
The response is sent after the deployment is live, so a 201 means your
site is serving the new build — no polling needed. If anything fails before
that, the previous deployment keeps serving and nothing changes for visitors.
(cd dist && zip -qr ../site.zip .)
curl -X POST "$API/sites/$SITE_ID/deployments" -H "$AUTH" \
-H "Content-Type: application/zip" \
--data-binary @site.zip201 Created:
{
"deployment": { "id": "b7a1d2c3-…", "number": 12, "status": "ready", "source": "upload", "fileCount": 48, "totalBytes": 1835210, "current": true, "…": "…" },
"url": "https://marketing-site.zeloxa.app"
}Use --data-binary, not -d (which strips newlines) or -F (a form upload,
which is refused). The Zeloxa CLI sends X-Zeloxa-Source: cli, which records
the deployment's source as cli.
Errors: 400 VALIDATION_ERROR (empty body, form upload, wrong content type),
400 INVALID_ARCHIVE, 413 PAYLOAD_TOO_LARGE, 502 UPSTREAM_ERROR (storage
failed; nothing changed, safe to retry), 500 INTERNAL_ERROR (see
below).
If two uploads to the same site overlap, each gets its own number and the one that finishes last is live.
GET /sites/{siteId}/deployments/{ref}
One deployment, by id or number.
curl "$API/sites/$SITE_ID/deployments/12" -H "$AUTH"{ "deployment": { "id": "b7a1d2c3-…", "number": 12, "status": "ready", "current": true, "…": "…" } }GET /sites/{siteId}/deployments/{ref}/logs
The deployment's log, oldest line first.
| Query | Notes |
|---|---|
after | Return lines with seq greater than this. Default: from the start. |
limit | 1–2,000, default 500. |
curl "$API/sites/$SITE_ID/deployments/12/logs" -H "$AUTH"{
"lines": [
{ "seq": 1, "stream": "system", "line": "Received 612.4 KB archive (upload).", "at": "2026-10-02T09:41:05.902Z" },
{ "seq": 2, "stream": "system", "line": "Unpacked 48 files (1.8 MB), runtime static.", "at": "2026-10-02T09:41:05.951Z" },
{ "seq": 3, "stream": "system", "line": "Stored 48 files.", "at": "2026-10-02T09:41:07.120Z" },
{ "seq": 4, "stream": "system", "line": "Released deployment #12 to https://marketing-site.zeloxa.app.", "at": "2026-10-02T09:41:07.455Z" }
],
"nextAfter": null
}nextAfter is set only when more lines follow this page: pass it as after
to get the next page. It is null on the last page. To follow a deployment that is still running, poll with
after=<the last seq you have> until its status is no longer building.
POST /sites/{siteId}/deployments/{ref}/promote
Make a ready deployment the live one — newer or older. Scope host:deploy.
Nothing is rebuilt; the switch is instant. No request body.
curl -X POST "$API/sites/$SITE_ID/deployments/11/promote" -H "$AUTH"{
"deployment": { "id": "a1b2c3d4-…", "number": 11, "status": "ready", "current": true, "…": "…" },
"previousDeploymentId": "b7a1d2c3-…"
}Errors: 404 DEPLOYMENT_NOT_FOUND, 409 DEPLOYMENT_NOT_READY (the deployment
failed or has not finished).
POST /sites/{siteId}/rollback
Go back to an earlier deployment. Scope host:deploy.
| Body field | Type | Required | Notes |
|---|---|---|---|
to | number | string | no | A deployment number (11, "11", "#11") or id. Omit it to go to the newest ready deployment older than the live one. |
The body is optional; with no body, send no Content-Type or
application/json.
# Undo the last release
curl -X POST "$API/sites/$SITE_ID/rollback" -H "$AUTH"
# Go to a specific deployment
curl -X POST "$API/sites/$SITE_ID/rollback" -H "$AUTH" \
-H "Content-Type: application/json" -d '{"to": 9}'{
"deployment": { "id": "a1b2c3d4-…", "number": 11, "status": "ready", "current": true, "…": "…" },
"previousDeploymentId": "b7a1d2c3-…"
}Errors: 400 VALIDATION_ERROR (to is not a number or id), 404 DEPLOYMENT_NOT_FOUND, 409 DEPLOYMENT_NOT_READY (nothing earlier to roll back
to, or the target is not ready). Rollbacks appear in Hosting → Activity.
GET /sites/{siteId}/domains
The site's custom domains.
curl "$API/sites/$SITE_ID/domains" -H "$AUTH"{
"domains": [ { "id": "0c9d8e7f-…", "hostname": "www.example.com", "status": "pending", "verified": false, "…": "…" } ],
"cnameTarget": "cname.zeloxalabs.com"
}cnameTarget is where custom domains point their CNAME.
POST /sites/{siteId}/domains
Connect a custom domain. Scope host:admin.
| Body field | Type | Required | Notes |
|---|---|---|---|
hostname | string | yes | e.g. www.example.com. No https://, path or port. Lowercased; a trailing dot is removed; international names are converted to their ASCII (punycode) form. |
redirectTo | string | null | no | Redirect this domain to another domain of the same site (or the site's own <slug> address) instead of serving it. See domain redirects for the rules. |
redirectStatus | number | no | 301, 302, 307 or 308 (default). Only used with redirectTo. |
withCompanion | boolean | no | Also add the www/apex partner (www.example.com for example.com, and the other way round) redirecting to this domain with 308. Only for a two-label apex or a www. name. If the partner cannot be added, the first domain is kept and the response says why in companion. |
curl -X POST "$API/sites/$SITE_ID/domains" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"hostname": "www.example.com"}'201 Created:
{
"domain": { "id": "0c9d8e7f-…", "hostname": "www.example.com", "status": "pending", "verified": false, "sslStatus": "pending_validation", "…": "…" },
"dnsRecords": [
{ "type": "CNAME", "name": "www.example.com", "value": "cname.zeloxalabs.com", "note": "Enter the full name, or only the part before your domain if your DNS provider adds the domain itself. …" },
{ "type": "TXT", "name": "_acme-challenge.www.example.com", "value": "…", "note": "Proves you control the domain so its TLS certificate can be issued." }
],
"cnameTarget": "cname.zeloxalabs.com"
}Create the dnsRecords at your DNS provider, then call
verify until verified is true.
With withCompanion, the response also has companion: either
{ "hostname": "…", "added": true, "domain": …, "dnsRecords": …, "cnameTarget": … }
(the partner needs its own DNS records) or
{ "hostname": "…", "added": false, "error": { "code": "…", "message": "…" } }.
Errors: 400 VALIDATION_ERROR (not a valid domain name, an IP address, or a
zeloxalabs.com address — those are provided by Zeloxa Host already),
409 DOMAIN_TAKEN, 502 UPSTREAM_ERROR.
GET /sites/{siteId}/domains/{domainId}
One custom domain.
curl "$API/sites/$SITE_ID/domains/$DOMAIN_ID" -H "$AUTH"{ "domain": { "id": "0c9d8e7f-…", "hostname": "www.example.com", "status": "active", "verified": true, "…": "…" } }POST /sites/{siteId}/domains/{domainId}/verify
Re-check a domain's DNS and certificate and save the result. Scope
host:read, so a read-only token can poll it. No request body.
curl -X POST "$API/sites/$SITE_ID/domains/$DOMAIN_ID/verify" -H "$AUTH"Returns the same shape as adding a domain (domain, dnsRecords,
cnameTarget), with 200 OK. DNS changes can take from minutes to
a few hours to propagate; poll every minute or so rather than in a tight loop.
When a domain becomes verified, Zeloxa sends a domain.verified event to your
webhooks.
Errors: 404 DOMAIN_NOT_FOUND, 502 UPSTREAM_ERROR (the check
could not be made; try again shortly).
PATCH /sites/{siteId}/domains/{domainId}
Switch a domain between serving the site and redirecting. Scope host:admin.
| Body field | Type | Required | Notes |
|---|---|---|---|
redirectTo | string | null | yes | null to serve the site; otherwise the hostname to redirect to. |
redirectStatus | number | no | 301, 302, 307 or 308 (default). |
The target must be another domain of the same site or the site's own
<slug> address — never the domain itself. Redirects never chain: the target
must not itself redirect, and a domain that other domains redirect to must keep
serving (change those first). Removing a domain sets every domain that
redirected to it back to serving the site.
curl -X PATCH "$API/sites/$SITE_ID/domains/$DOMAIN_ID" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"redirectTo": "example.com", "redirectStatus": 308}'{ "domain": { "id": "0c9d8e7f-…", "hostname": "www.example.com", "redirectTo": "example.com", "redirectStatus": 308, "…": "…" } }Errors: 400 VALIDATION_ERROR, 404 DOMAIN_NOT_FOUND.
DELETE /sites/{siteId}/domains/{domainId}
Disconnect a custom domain. Scope host:admin. Traffic to that hostname stops
reaching the site and its certificate is released.
curl -X DELETE "$API/sites/$SITE_ID/domains/$DOMAIN_ID" -H "$AUTH"{ "deleted": true, "hostname": "www.example.com" }Domain ids are checked against the site in the path: an id from another site
is 404 DOMAIN_NOT_FOUND, for verify and delete also when the site itself does
not exist.
GET /sites/{siteId}/integrations
The site's integrations: third-party tags (analytics,
marketing pixels, live chat, custom code) added to its HTML pages as they are
served, and its cookie consent banner. Scope host:read.
curl "$API/sites/$SITE_ID/integrations" -H "$AUTH"{
"integrations": [
{
"kind": "ga4",
"name": "Google Analytics 4",
"category": "analytics",
"consent": "analytics",
"enabled": true,
"includePreviews": false,
"config": { "measurementId": "G-AB12CD34EF" },
"summary": "G-AB12CD34EF",
"valid": true,
"createdAt": "2026-10-04T09:12:44.120Z",
"updatedAt": "2026-10-04T09:12:44.120Z"
}
],
"consentBanner": {
"enabled": true,
"message": "We use cookies to understand how our site is used…",
"acceptLabel": "Accept",
"rejectLabel": "Reject",
"privacyPolicyUrl": "https://example.com/privacy",
"privacyLinkLabel": "Privacy policy",
"position": "bottom",
"theme": "light"
}
}consent is what a visitor must accept first when the banner is on
(analytics, marketing, or functional for tags that load straight away).
valid is false when the stored settings no longer pass validation; such a
tag is not added until it is fixed.
Each kind and its config:
kind | config | Format |
|---|---|---|
ga4 | measurementId | G- and 4–12 letters or digits |
gtm | containerId | GTM- and 4–10 letters or digits |
meta_pixel | pixelId | 10–20 digits |
tiktok_pixel | pixelId | 15–25 letters or digits |
clarity | projectId | 6–16 lowercase letters or digits |
hotjar | siteId | 4–10 digits |
crisp | websiteId | a UUID |
tawk | propertyId, widgetId | 24 hex characters; default or 5–16 letters or digits |
custom_code | head, body, consent | HTML, up to 20 KB each (at least one); functional (default), analytics or marketing |
IDs are trimmed and upper- or lower-cased as the service writes them, then
must match the format exactly: anything else (quotes, spaces, markup) is a
400 VALIDATION_ERROR.
GET /sites/{siteId}/integrations/{kind}
One integration, as { "integration": … }. Scope host:read.
Errors: 404 SITE_NOT_FOUND, 404 NOT_FOUND (unknown kind, or not connected).
PUT /sites/{siteId}/integrations/{kind}
Connect an integration or change it. Scope host:admin: tags run on every
visitor's page. Omitted fields keep their current values, so turning one off
is {"enabled": false}.
| Body field | Type | Notes |
|---|---|---|
config | object | The fields for kind (table above). Required to connect; replaces the whole config when sent. Unknown fields are refused. |
enabled | boolean | Default true when connecting. |
includePreviews | boolean | Also add the tag to preview addresses. Default false. |
curl -X PUT "$API/sites/$SITE_ID/integrations/ga4" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"config": {"measurementId": "G-AB12CD34EF"}}'Returns { "integration": … }: 201 when it was connected by this call,
200 otherwise. Changes are live on the site within 30 seconds.
Errors: 400 VALIDATION_ERROR (with details.fields naming each bad field),
404 SITE_NOT_FOUND, 404 NOT_FOUND (unknown kind), 409 CONFLICT (connected
at the same moment by another request), 413 PAYLOAD_TOO_LARGE.
DELETE /sites/{siteId}/integrations/{kind}
Remove an integration. Scope host:admin. Returns { "deleted": true }; the
tag is gone from the site within 30 seconds.
Errors: 404 SITE_NOT_FOUND, 404 NOT_FOUND.
GET /sites/{siteId}/integrations/consent-banner
The cookie consent banner settings, as { "consentBanner": … } (shape above).
Scope host:read.
PUT /sites/{siteId}/integrations/consent-banner
Change some or all of the banner settings. Scope host:admin. Omitted fields
keep their values; unknown fields are refused.
| Body field | Type | Notes |
|---|---|---|
enabled | boolean | While on, analytics and marketing tags load only after the visitor accepts. |
message | string | One line, up to 600 characters. |
acceptLabel, rejectLabel | string | Up to 40 characters each. |
privacyPolicyUrl | string or null | An https:// (or http://) URL, or null / "" for no link. |
privacyLinkLabel | string | Up to 60 characters. |
position | string | bottom, bottom-left or bottom-right. |
theme | string | light or dark. |
curl -X PUT "$API/sites/$SITE_ID/integrations/consent-banner" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "privacyPolicyUrl": "https://example.com/privacy"}'Errors: 400 VALIDATION_ERROR, 404 SITE_NOT_FOUND.
GET /sites/{siteId}/forms
A site's forms, oldest first.
curl "$API/sites/$SITE_ID/forms" -H "$AUTH"{ "forms": [ { "id": "2b3c4d5e-…", "siteId": "6f1c2b8e-…", "name": "Contact", "enabled": true, "createdAt": "2026-09-30T08:12:44.000Z" } ] }Errors: 404 SITE_NOT_FOUND.
Event hooks
Event hooks push events to a URL the moment they happen. They follow the
REST hook pattern Zapier, Make and similar automation apps use for instant
triggers: the app subscribes a URL it controls to one event, receives one
POST per event, and unsubscribes when the automation is switched off. You
can use them from your own code too. For a step-by-step guide without code,
see Connect Zeloxa to Zapier or Make.
Hooks are separate from the integrations on the dashboard's Integrations page (Slack, Discord and signed webhooks), which tokens cannot see or change. The subscriptions a token creates are listed there under Connected apps, where they can be removed.
| Event | Sent when |
|---|---|
form.submission | A visitor sent one of a site's forms and it was not filed as spam. |
deployment.ready | A deployment finished and is live (production or preview; see environment). |
deployment.error | A deployment could not be built or released. |
domain.verified | A custom domain passed verification and serves its site. |
domain.failed | A custom domain needs attention (stuck for 72 hours, or refused). At most once per domain every 30 days. |
Payloads
Each delivery is one flat JSON object. Every field below is always present
(null when it does not apply), so a field you map from a sample is there on
every delivery.
| Field | Events | Notes |
|---|---|---|
id | all | The subject's id: the submission, deployment or domain. |
event | all | The event type. |
eventId | all | Unique per event (a UUID); also sent as X-Zeloxa-Delivery. |
occurredAt | all | When it happened (ISO 8601, UTC). |
siteId, siteName, siteSlug, siteUrl | all | The site. |
formId, formName | form.submission | The form. |
submissionId, submittedAt | form.submission | The submission (submissionId equals id). |
email | form.submission | A valid address from a field named like email, lower-cased; else null. |
fields | form.submission | Every submitted field, name → text, in the order sent. A name sent several times (checkboxes) has its values joined with , . |
deploymentId, deploymentNumber, deploymentStatus, deploymentSource, environment | deployment events | environment is production or preview. |
errorMessage | deployment events | Why it failed; null for deployment.ready. |
gitBranch, gitCommit, gitMessage | deployment events | null when it did not come from Git. |
domainId, hostname, domainUrl | domain events | |
reason, message | domain events | domain.failed: timeout, blocked, moved or certificate, and what to do. null for domain.verified. |
form.submission:
{
"id": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b",
"event": "form.submission",
"eventId": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"occurredAt": "2026-10-03T10:04:11.902Z",
"siteId": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"siteName": "Marketing site",
"siteSlug": "marketing-site",
"siteUrl": "https://marketing-site.zeloxa.app",
"formId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"formName": "Contact",
"submissionId": "7e6d5c4b-3a2f-4e1d-8c0b-9a8f7e6d5c4b",
"submittedAt": "2026-10-03T10:04:11.902Z",
"email": "ada@example.com",
"fields": { "name": "Ada Lovelace", "email": "ada@example.com", "message": "Hello!" }
}deployment.error (deployment.ready has the same fields):
{
"id": "b7a1d2c3-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"event": "deployment.error",
"eventId": "5a0b1c2d-3e4f-4a5b-8c6d-7e8f9a0b1c2d",
"occurredAt": "2026-10-03T10:05:00.000Z",
"siteId": "6f1c2b8e-…",
"siteName": "Marketing site",
"siteSlug": "marketing-site",
"siteUrl": "https://marketing-site.zeloxa.app",
"deploymentId": "b7a1d2c3-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"deploymentNumber": 13,
"deploymentStatus": "error",
"deploymentSource": "github",
"environment": "production",
"errorMessage": "Build failed: npm run build exited with code 1.",
"gitBranch": "main",
"gitCommit": "3f9c1e2a7b4d5c6e8f9a0b1c2d3e4f5a6b7c8d9e",
"gitMessage": "Update pricing page"
}domain.failed (domain.verified has the same fields, with reason and
message set to null):
{
"id": "0c9d8e7f-6a5b-4c3d-8e2f-1a0b9c8d7e6f",
"event": "domain.failed",
"eventId": "e2f3a4b5-c6d7-4e8f-9a0b-1c2d3e4f5a6b",
"occurredAt": "2026-10-05T11:00:00.000Z",
"siteId": "6f1c2b8e-…",
"siteName": "Marketing site",
"siteSlug": "marketing-site",
"siteUrl": "https://marketing-site.zeloxa.app",
"domainId": "0c9d8e7f-6a5b-4c3d-8e2f-1a0b9c8d7e6f",
"hostname": "www.example.com",
"domainUrl": "https://www.example.com",
"reason": "timeout",
"message": "Add the DNS records shown on the project's Domains page at your DNS provider, exactly as listed, then press Re-check."
}Delivery
- An HTTP
POSTwithContent-Type: application/jsonand the same headers as a webhook:X-Zeloxa-Event,X-Zeloxa-Delivery,X-Zeloxa-TimestampandX-Zeloxa-Signature(HMAC-SHA256 of the raw body, keyed with the hook'ssigningSecret), plusX-Zeloxa-Hook-Id. - One attempt, 5-second timeout, redirects not followed; any
2xxis success. Answer quickly and do slow work afterwards. 410 Goneunsubscribes. If the target answers410, the subscription is deleted — that is how Zapier and Make say an automation was turned off or deleted on their side.- A subscription only receives events while the API token that created it is active. Revoke the token and every hook it created stops at once.
- Target URLs follow the same rules as webhooks:
https://on the standard port, public hosts only (no private, loopback, link-local or reserved IP addresses, nolocalhost,*.local,*.internalor single-word hosts), nouser:password@, up to 2048 characters.
GET /hooks
Every hook subscription in the account, newest first. Scope host:read. Full
target URLs and signing secrets are never listed.
curl "$API/hooks" -H "$AUTH"{ "hooks": [ { "id": "8d7c6b5a-…", "event": "form.submission", "source": "zapier", "active": true, "…": "…" } ] }POST /hooks
Subscribe a URL to one event. Scope host:hooks.
| Body field | Type | Required | Notes |
|---|---|---|---|
targetUrl | string | yes | The public https:// URL to send events to. |
event | string | yes | One of the events. |
siteId | string | null | no | Only events of this site. null or "" means every site. |
formId | string | null | no | form.submission only: only this form. Implies its site. |
source | string | no | zapier, make or api, shown in the dashboard. Recognised from the User-Agent when omitted. |
curl -X POST "$API/hooks" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"targetUrl": "https://hooks.example.com/zeloxa", "event": "form.submission", "siteId": "'"$SITE_ID"'"}'201 Created — the hook itself at the top level, plus its signing
secret, shown once:
{
"id": "8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a",
"event": "form.submission",
"siteId": "6f1c2b8e-3d4a-4f5b-9c6d-7e8f9a0b1c2d",
"formId": null,
"source": "api",
"targetHost": "hooks.example.com",
"targetUrlMasked": "https://hooks.example.com/…evia",
"active": true,
"createdAt": "2026-10-03T10:00:00.000Z",
"lastDeliveryAt": null,
"lastDeliveryStatus": null,
"signingSecret": "whsec_…"
}Keep the id: it is what you unsubscribe with. Subscribing again with the
same token, URL, event and filters returns the existing hook with 200 OK and
"signingSecret": null instead of creating a second one.
Errors: 400 VALIDATION_ERROR (details.parameter names the field: a missing
or non-https targetUrl, a private address, an unknown event, formId with
another event), 404 SITE_NOT_FOUND, 404 NOT_FOUND (no such form, or a form
of another site), 409 LIMIT_REACHED (100 hooks).
GET /hooks/{hookId}
One hook. Scope host:read. 404 NOT_FOUND when it does not exist.
{ "hook": { "id": "8d7c6b5a-…", "event": "form.submission", "…": "…" } }DELETE /hooks/{hookId}
Unsubscribe. Scope host:hooks. Answers 204 No Content with no body —
also when the hook was already gone (removed in the dashboard, or after its
target answered 410), so switching an automation off never fails. A
malformed id is 404 NOT_FOUND.
curl -X DELETE "$API/hooks/8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a" -H "$AUTH"POST /hooks/{hookId}/test
Send the hook's newest sample (for its event and
filters) to its target now, signed like a real delivery, and report the
answer. Scope host:hooks. Use it when the receiving side has to see one
delivery before you can map fields — a catch hook in Zapier or a custom
webhook in Make. At most one delivery every 5 seconds per hook
(429 RATE_LIMITED).
curl -X POST "$API/hooks/8d7c6b5a-4f3e-4d2c-9b1a-0f9e8d7c6b5a/test" -H "$AUTH"{ "status": 200, "ok": true, "deliveredAt": "2026-10-03T10:00:05.120Z", "removed": false }status is the target's HTTP status, or 0 when it did not answer. removed
is true when the target answered 410 and the hook was deleted.
GET /hooks/samples/{event}
Recent real items for an event, shaped exactly like payloads:
up to 3, newest first. When there are none yet, one realistic placeholder
(its ids end in …000000000003) so every field can still be mapped. Scope
host:hooks, because form samples are what visitors typed.
Optional query parameters siteId and formId (form.submission only)
filter like a subscription's. Empty values are ignored.
This is the one v1 endpoint that answers with a bare JSON array, which is what automation apps expect from a trigger's sample request.
curl "$API/hooks/samples/form.submission?siteId=$SITE_ID" -H "$AUTH"[
{ "id": "7e6d5c4b-…", "event": "form.submission", "formName": "Contact", "email": "ada@example.com", "fields": { "name": "Ada Lovelace", "message": "Hello!" }, "…": "…" }
]Errors: 400 VALIDATION_ERROR (unknown event, malformed filter),
404 SITE_NOT_FOUND, 404 NOT_FOUND (form).
Redirects and headers
A build can ship a _redirects and a _headers file at its root (next to
index.html). The syntax is the one other common static hosts use, so a
build already made for them works unchanged. Both files are read when the build
is deployed; neither is served as a public file. A line that cannot be used is
skipped and reported as a Warning: in the deployment log — it never fails
the deploy.
_redirects
# /from /to [status]
/blog/* /news/:splat 301
/users/:id /people/:id
/docs/old https://docs.example.com/new 308
/app/* /app/index.html 200- One rule per line: source, destination, optional status.
#starts a comment. - Status:
301,302(the default),303,307,308, or200. A 200 is a rewrite: the destination's file is served at the requested URL. It must be a path on the same site — proxying to another host is not supported, and a rewrite to a missing file is a 404. *(once per source) matches the rest of the path, slashes included, and is used in the destination as:splat./blog/*also matches/blog.:nameas a whole segment matches one segment and is used as:name.- Destinations start with
/orhttps://.http://, other schemes and//hostare refused, as are placeholders in an external host. - Matching ignores the query string and a trailing slash (
/aboutand/about/are the same source) and is case-sensitive. The request's query string is kept on a redirect, after the destination's own. - Rules are checked in file order and the first match wins. A matching rule
applies only when no file exists at the requested path (shadowing): with
/* /index.html 200, scripts, styles and images are still served, and only paths without a file getindex.html. Add!to the status (301!,200!) to force a rule even where a file exists — for example to move a page that is still in the build. A rule whose destination is the URL just requested is ignored rather than looping. - For full-stack builds (
_worker.js), redirects are not applied: your app's router owns its URLs.
_headers
/*
X-Frame-Options: DENY
Content-Security-Policy: default-src 'self'
! Referrer-Policy
/assets/*
Cache-Control: public, max-age=31536000, immutable- A path pattern on its own line (same
*and:namerules as above), followed by indentedName: valuelines.! Nameremoves a header, including the platform's defaults (Referrer-Policy, which a rule can also replace).X-Content-Type-Options: nosniffis always sent. - Every matching rule applies, in file order. A header set by two matching
rules gets both values, comma-joined.
:nameand:splatin a value are filled in from the pattern. - Rules apply to every file served from the deployment, including the 404 page and static assets of full-stack builds; not to redirects.
- These headers cannot be set or removed:
Content-Length,Content-Encoding,Content-Range,Transfer-Encoding,Connection,Keep-Alive,TE,Trailer,Upgrade,Host,Set-Cookie,Location,ETag,Date,Age,Server, and any header starting withX-Zeloxa-,CF-orProxy-.X-Content-Type-Options: nosniffis always sent. On a password-protected site,Cache-Controlis alwaysprivate, no-store.
Limits
| Limit | Value |
|---|---|
| Redirect rules | 2,000 (the rest are skipped) |
| Header path patterns | 100 |
| Headers under one path pattern | 50 |
| Line length, either file | 2,000 characters |
| Size of either file | 512 KB (larger is ignored) |
Paths under .zeloxa/ are reserved for the platform: a build's files there
are not published.
Deploy from CI with curl
The CLI does all of this with zeloxa deploy. If
you would rather not install anything, this script needs only curl, zip
and jq, which every common CI image has.
1. Create a token. In Hosting → API tokens, create a token with the
host:deploy scope. Save it as a CI secret named ZELOXA_TOKEN.
2. Find your site id.
curl -s https://zeloxalabs.com/api/v1/host/sites \
-H "Authorization: Bearer $ZELOXA_TOKEN" | jq -r '.sites[] | "\(.id) \(.name)"'Save it as ZELOXA_SITE_ID (it is not secret).
3. Build, zip, upload — and if the outcome is unclear, check instead of
re-uploading. Save as deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
API="https://zeloxalabs.com/api/v1/host"
: "${ZELOXA_TOKEN:?Set ZELOXA_TOKEN}"
: "${ZELOXA_SITE_ID:?Set ZELOXA_SITE_ID}"
OUT_DIR="${1:-dist}"
ZIP_FILE="$PWD/site.zip"
# Zip the build output's contents, so index.html is at the top of the archive.
# An absolute path, so the zip lands where curl reads it however deep OUT_DIR
# is; removed first, because zip adds to an existing archive and would keep
# files deleted since the last build. Environment files are left out: every
# file in the archive is publicly served.
rm -f "$ZIP_FILE"
(cd "$OUT_DIR" && zip -qr "$ZIP_FILE" . -x '.env*' '*/.env*')
# Upload. The response comes back once the deployment is live, or has failed
# (in which case the previous deployment is still serving).
status=$(curl -sS -o response.json -w '%{http_code}' \
-X POST "$API/sites/$ZELOXA_SITE_ID/deployments" \
-H "Authorization: Bearer $ZELOXA_TOKEN" \
-H "Content-Type: application/zip" \
--data-binary "@$ZIP_FILE" \
--max-time 330)
if [ "$status" = "201" ]; then
echo "Deployment #$(jq -r .deployment.number response.json) is live at $(jq -r .url response.json)"
exit 0
fi
echo "Upload failed: HTTP $status $(jq -r '.error.code // ""' response.json)" >&2
echo "$(jq -r '.error.message // ""' response.json)" >&2
# A 500 that names a deployment means the build was uploaded but the release
# could not be confirmed. Ask which it was instead of uploading again.
deployment_id=$(jq -r '.error.details.deploymentId // empty' response.json)
[ -n "$deployment_id" ] || exit 1
for attempt in 1 2 3 4 5; do
sleep $((attempt * 3))
if curl -sSf -o check.json \
"$API/sites/$ZELOXA_SITE_ID/deployments/$deployment_id" \
-H "Authorization: Bearer $ZELOXA_TOKEN"; then
number=$(jq -r .deployment.number check.json)
if [ "$(jq -r .deployment.current check.json)" = "true" ]; then
echo "Deployment #$number is live after all."
exit 0
fi
echo "Deployment #$number is $(jq -r .deployment.status check.json) and not live." >&2
# Its log says how far it got.
curl -sS "$API/sites/$ZELOXA_SITE_ID/deployments/$deployment_id/logs" \
-H "Authorization: Bearer $ZELOXA_TOKEN" | jq -r '.lines[] | " \(.line)"' >&2
exit 1
fi
done
echo "Could not check deployment $deployment_id; look at Hosting in the dashboard before deploying again." >&2
exit 14. Run it from your pipeline. For example, in GitHub Actions:
name: Deploy
on:
push:
branches: [main]
# One deploy at a time, so an older build can never finish last and replace a
# newer one.
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: bash deploy.sh dist
env:
ZELOXA_TOKEN: ${{ secrets.ZELOXA_TOKEN }}
ZELOXA_SITE_ID: ${{ vars.ZELOXA_SITE_ID }}To roll back from a pipeline (or by hand during an incident):
curl -X POST "https://zeloxalabs.com/api/v1/host/sites/$ZELOXA_SITE_ID/rollback" \
-H "Authorization: Bearer $ZELOXA_TOKEN"When an upload "could not be confirmed"
Rarely, a build is uploaded and stored, but the final step — switching the site to it — gets no confirmation (for example, the connection to the database drops at that moment). Zeloxa Host then cannot tell you whether the new build is live, and it does not guess:
{
"error": {
"code": "INTERNAL_ERROR",
"message": "Deployment #12 was uploaded, but whether it went live could not be confirmed. Check the deployment list before deploying again.",
"details": { "deploymentId": "b7a1d2c3-…", "number": 12 }
}
}The deployment is kept, ready, with its files in place. Either it is live,
or it is an ordinary earlier deployment you can promote. Do not blindly
re-upload: check first with
GET /sites/{siteId}/deployments/{details.deploymentId} and look at
current (or at currentDeploymentId on the site). If it is not live and you
want it to be, promote it — that is
instant, and needs no new upload.
Other 500 INTERNAL_ERROR responses on an upload say whether the previous
deployment is still live (it is, when the message says so); retrying those is
safe.
Versioning
This is version 1. Within v1, changes are additive only: new endpoints, new
optional request fields, new response fields and new error codes may appear,
so ignore fields you do not recognise and treat an unknown error code by its
HTTP status. Anything that would break an existing client gets a new version
(/api/v2/…).
Related:
- Webhooks — get deployment, domain and form events in Slack, Discord or your own endpoint.
- Zapier and Make — send form submissions and deploy events to Google Sheets, Mailchimp, SMS and thousands of other apps.
- MCP server — let AI agents (Cursor, Claude, ChatGPT, …) list sites, check deployments, roll back and manage domains.
- CLI —
zeloxa login,zeloxa deploy,zeloxa rollback, and a ready-made GitHub Actions workflow.
