Developer docs

Host API reference

Every endpoint of the Zeloxa Host API v1: authentication, scopes, errors, limits and curl examples for sites, deployments, rollbacks, logs and custom domains.

On this page

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.

  1. In the dashboard, open Hosting → API tokens and create a token.
  2. Choose its scopes and, optionally, an expiry date.
  3. 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 gets 401 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" (with error="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 FORBIDDEN unless 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.

ScopeAllowsIncludes
host:readList and read sites, deployments, logs, domains and site integrations; re-check a domain's verification; whoami—
host:deployUpload a deployment, promote a deployment, roll backhost:read
host:hooksSubscribe and unsubscribe event hooks; read hook samples (which include form submissions)host:read
host:adminCreate sites (each site is billed monthly), add and remove custom domains, connect and change site integrations and the consent bannerhost: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/json objects (the one exception is hook samples, a bare array). Request bodies must be sent with Content-Type: application/json; any other content type is a 400 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-stream and application/x-zip-compressed are also accepted). Form uploads (multipart/form-data, i.e. curl -F) are refused with a 400 that 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 optional note and an error's optional details.
  • 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 — 12 or #12. In a URL path, # must be written %23 (/deployments/%2312); plain 12 is 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. HEAD works wherever GET does; OPTIONS returns the Allow header. Any other method gets 405 METHOD_NOT_ALLOWED with an Allow header. An unknown path under /api/v1 gets a JSON 404 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.

HTTPCodeMeaning
400VALIDATION_ERRORThe 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.
400INVALID_ARCHIVEThe 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.
401UNAUTHORIZEDNo credentials were sent.
401INVALID_TOKENThe token is malformed, unknown, expired or revoked, or the account is suspended.
403INSUFFICIENT_SCOPEThe token is valid but lacks the scope. details.required, details.granted.
403FORBIDDENThe request is not allowed this way (a dashboard session used from outside the dashboard).
403ORGANIZATION_REQUIREDThe signed-in user has no organization yet (dashboard sessions only).
403SITE_LIMIT_REACHEDThe account has reached its site limit. details.limit. Contact Zeloxa to raise it.
404SITE_NOT_FOUNDNo such site in this account.
404DEPLOYMENT_NOT_FOUNDNo such deployment on that site.
404DOMAIN_NOT_FOUNDNo such domain on that site.
404NOT_FOUNDNo such endpoint, or another missing resource.
405METHOD_NOT_ALLOWEDThe endpoint does not support that method. See the Allow header and details.allowed.
409DEPLOYMENT_NOT_READYThe deployment cannot be made live (its status is not ready), or there is no earlier deployment to roll back to. details.status when known.
409DOMAIN_TAKENThat hostname is already connected to a site.
409LIMIT_REACHEDAn account limit other than the site limit is reached (for example API tokens or integrations). details.limit. Remove one to make room.
409CONFLICTThe request clashes with something that already exists (for example the same URL twice). details.field when known.
413PAYLOAD_TOO_LARGEThe request body is over the limit. details.limitBytes.
429RATE_LIMITEDToo many requests. Wait and retry with backoff.
500INTERNAL_ERRORSomething failed on Zeloxa's side. For uploads, read this before retrying.
502UPSTREAM_ERRORA provider Zeloxa depends on failed (storage, edge network, certificates). The operation did not happen; retrying is safe.
503SERVICE_UNAVAILABLEThe 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_ERROR on an upload — the deployment was recorded as failed and cleaned up, and the live site is unchanged. Retrying uploads a new deployment.
  • 500 INTERNAL_ERROR on an upload with details.deploymentId — check before retrying, see below.

Limits

LimitValue
Upload size (the zip, as sent)100 MB
Total unpacked size of a build100 MB
Largest single file in a build25 MB
Files in a build5,000
Entries in an archive, skipped ones included50,000
Path of a file in a build1,024 characters; longer entries are skipped
Upload request duration300 seconds
JSON request body64 KB (160 KB for PUT …/integrations/{kind})
Site name1–120 characters
Sites per account50 (contact Zeloxa for more)
Hook subscriptions per account100
limit on deployment lists1–100, default 20
limit on log pages1–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"
}
FieldTypeNotes
urlstringThe site's address on Zeloxa Host: https://<slug>.zeloxalabs.com.
publishedbooleantrue once a deployment has gone live.
runtime"static" | "worker"What the live deployment is: static files, or a full-stack app.
currentDeploymentIdstring | nullThe deployment being served; null until the first deploy.
deploySource"local" | "github"Where builds come from. Uploads through this API set it to local.
githubRepo, githubBranchstring | nullSet for sites connected to GitHub.
lastDeployAtstring | nullWhen 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
}
FieldTypeNotes
numberintegerPer-site, starting at 1. Use it anywhere a {ref} is accepted.
statusstringpending, uploading, building, ready, error or canceled. Only ready deployments can be live.
sourcestringupload (this API or the dashboard), cli, github or redeploy.
fileCount, totalBytesintegerThe unpacked build.
git*string | nullSet for deployments built from GitHub.
errorMessagestring | nullWhy a deployment failed.
readyAtstring | nullWhen it finished; null until then.
currentbooleanWhether this is the deployment the site serves right now.
environmentstringproduction, or preview for branch and pull request builds. A preview can never be promoted or rolled back to.
previewAliasstring | nullA preview's address label: it is served at <previewAlias>--<slug>.<your hosting domain>.
pullRequestinteger | nullThe 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
}
FieldTypeNotes
eventstringform.submission, deployment.ready, deployment.error, domain.verified or domain.failed.
siteId, formIdstring | nullFilters; null means every site, or every form.
sourcestringzapier, make or api — what created it, as declared or recognised from the client.
targetHost, targetUrlMaskedstringWhere deliveries go. The full URL is never returned after it is saved.
activebooleanfalse once the token that created it is revoked or expired: nothing is sent to it.
lastDeliveryStatusinteger | nullThe 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}/domains

GET /

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 fieldTypeRequiredNotes
namestringyes1–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
  }
}
FieldNotes
trailingSlashauto (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/.
spaFallbackWhat 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.
discourageSearchEnginestrue adds x-robots-tag: noindex, nofollow to every response and serves a robots.txt that disallows everything (unless the build ships its own).
primaryDomain.modeauto (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.effectiveThe 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.
liveDeploymentLooksLikeSpaWhat 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 fieldTypeNotes
trailingSlashstringauto, always or never.
spaFallbackstringauto, on or off.
discourageSearchEnginesboolean
primaryDomainstringauto, 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.

QueryNotes
limit1–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.html at its top level (or _worker.js for a full-stack app). If everything sits inside one folder (you zipped dist itself rather than its contents), that folder is stripped automatically.
  • .git, node_modules, .DS_Store, __MACOSX and ._* entries are ignored, as is any path that tries to leave the archive (../).
  • Builds with a _worker.js at 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.zip

201 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.

QueryNotes
afterReturn lines with seq greater than this. Default: from the start.
limit1–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 fieldTypeRequiredNotes
tonumber | stringnoA 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 fieldTypeRequiredNotes
hostnamestringyese.g. www.example.com. No https://, path or port. Lowercased; a trailing dot is removed; international names are converted to their ASCII (punycode) form.
redirectTostring | nullnoRedirect 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.
redirectStatusnumberno301, 302, 307 or 308 (default). Only used with redirectTo.
withCompanionbooleannoAlso 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 fieldTypeRequiredNotes
redirectTostring | nullyesnull to serve the site; otherwise the hostname to redirect to.
redirectStatusnumberno301, 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:

kindconfigFormat
ga4measurementIdG- and 4–12 letters or digits
gtmcontainerIdGTM- and 4–10 letters or digits
meta_pixelpixelId10–20 digits
tiktok_pixelpixelId15–25 letters or digits
clarityprojectId6–16 lowercase letters or digits
hotjarsiteId4–10 digits
crispwebsiteIda UUID
tawkpropertyId, widgetId24 hex characters; default or 5–16 letters or digits
custom_codehead, body, consentHTML, 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 fieldTypeNotes
configobjectThe fields for kind (table above). Required to connect; replaces the whole config when sent. Unknown fields are refused.
enabledbooleanDefault true when connecting.
includePreviewsbooleanAlso 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 fieldTypeNotes
enabledbooleanWhile on, analytics and marketing tags load only after the visitor accepts.
messagestringOne line, up to 600 characters.
acceptLabel, rejectLabelstringUp to 40 characters each.
privacyPolicyUrlstring or nullAn https:// (or http://) URL, or null / "" for no link.
privacyLinkLabelstringUp to 60 characters.
positionstringbottom, bottom-left or bottom-right.
themestringlight 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.

EventSent when
form.submissionA visitor sent one of a site's forms and it was not filed as spam.
deployment.readyA deployment finished and is live (production or preview; see environment).
deployment.errorA deployment could not be built or released.
domain.verifiedA custom domain passed verification and serves its site.
domain.failedA 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.

FieldEventsNotes
idallThe subject's id: the submission, deployment or domain.
eventallThe event type.
eventIdallUnique per event (a UUID); also sent as X-Zeloxa-Delivery.
occurredAtallWhen it happened (ISO 8601, UTC).
siteId, siteName, siteSlug, siteUrlallThe site.
formId, formNameform.submissionThe form.
submissionId, submittedAtform.submissionThe submission (submissionId equals id).
emailform.submissionA valid address from a field named like email, lower-cased; else null.
fieldsform.submissionEvery submitted field, name → text, in the order sent. A name sent several times (checkboxes) has its values joined with , .
deploymentId, deploymentNumber, deploymentStatus, deploymentSource, environmentdeployment eventsenvironment is production or preview.
errorMessagedeployment eventsWhy it failed; null for deployment.ready.
gitBranch, gitCommit, gitMessagedeployment eventsnull when it did not come from Git.
domainId, hostname, domainUrldomain events
reason, messagedomain eventsdomain.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 POST with Content-Type: application/json and the same headers as a webhook: X-Zeloxa-Event, X-Zeloxa-Delivery, X-Zeloxa-Timestamp and X-Zeloxa-Signature (HMAC-SHA256 of the raw body, keyed with the hook's signingSecret), plus X-Zeloxa-Hook-Id.
  • One attempt, 5-second timeout, redirects not followed; any 2xx is success. Answer quickly and do slow work afterwards.
  • 410 Gone unsubscribes. If the target answers 410, 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, no localhost, *.local, *.internal or single-word hosts), no user: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 fieldTypeRequiredNotes
targetUrlstringyesThe public https:// URL to send events to.
eventstringyesOne of the events.
siteIdstring | nullnoOnly events of this site. null or "" means every site.
formIdstring | nullnoform.submission only: only this form. Implies its site.
sourcestringnozapier, 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, or 200. 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. :name as a whole segment matches one segment and is used as :name.
  • Destinations start with / or https://. http://, other schemes and //host are refused, as are placeholders in an external host.
  • Matching ignores the query string and a trailing slash (/about and /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 get index.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 :name rules as above), followed by indented Name: value lines. ! Name removes a header, including the platform's defaults (Referrer-Policy, which a rule can also replace). X-Content-Type-Options: nosniff is always sent.
  • Every matching rule applies, in file order. A header set by two matching rules gets both values, comma-joined. :name and :splat in 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 with X-Zeloxa-, CF- or Proxy-. X-Content-Type-Options: nosniff is always sent. On a password-protected site, Cache-Control is always private, no-store.

Limits

LimitValue
Redirect rules2,000 (the rest are skipped)
Header path patterns100
Headers under one path pattern50
Line length, either file2,000 characters
Size of either file512 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 1

4. 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.

Ready to try it?

Create an API token in the dashboard. You choose what it can do, and it is shown once.

Get an API token

Something unclear or missing? Tell us.