Publish
Publish your own build, read and update individual files, promote, and roll back.
Publishing your own build
If you already build the site, publish the output as a zip. Any static build works from frameworks like Manifest, React, Vite, Astro, or just plain HTML. Publishing is two steps: ask the API for a one-time upload URL, then send the zip to it. Every publish lands as a new deployment, and staging and production each keep their history, so a bad release is one call away from being undone.
If you have content but no site, send the pages as Markdown instead and Manifest builds the site from its docs template. See Content.
To change a few files without sending the whole build again, read the files and update individual files.
Start a publish
curl -X POST https://api.manifestx.dev/v1/projects/proj_…/publish \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY" \
-H "Idempotency-Key: build-4182-staging" \
-H "Content-Type: application/json" \
-d '{ "env": "staging", "source": "spa" }'
| Field | Type | Required | Notes |
|---|---|---|---|
env |
string | no | staging (default) or production. |
source |
string | yes | spa for any static build (React, Vite, Astro, plain HTML, or a Manifest app). render for mnfst-render output. |
output_dir |
string | no | Folder inside the zip the site is served from, such as dist. Defaults to the zip root for spa, website for render. |
The answer is 201 Created:
{
"deployment": { "id": "dep_…", "env": "staging", "source": "spa", "output_dir": "", "status": "uploading" },
"upload_url": "https://api.manifestx.dev/v1/uploads/put_…",
"status_url": "https://api.manifestx.dev/v1/uploads/put_…/status",
"expires_in_seconds": 900,
"request_id": "…"
}
The upload URL works once and expires after 15 minutes. The token in the URL is its own authority, so the upload and the status checks need no Authorization header, and the machine that uploads never needs your API key. With an Idempotency-Key, a retried start returns the same URL instead of opening a second deployment.
Upload the zip
curl -X POST "$UPLOAD_URL" \
-H "x-mnfst-async: 1" \
--data-binary @site.zip
With x-mnfst-async: 1 the upload answers 202 with "status": "ingesting" as soon as the zip is stored. Poll status_url about once a second until status is done or error:
{ "status": "done", "ok": true, "deployment_id": "dep_…", "env": "staging", "files": 42, "bytes": 318211, "url": "https://acme-docs-staging.manifestx.ai" }
Without the header the upload waits and answers with the same outcome. The site is live on that environment's URL the moment the status is done. .env files, .git, node_modules, and .claude are never published, even when they are in the zip.
| Limit | Value |
|---|---|
| Zip size | 80 MB |
| Unzipped size | 250 MB |
| Largest single file | 25 MB |
| Files per deployment | 10,000 |
An upload over a limit answers 413, and an expired URL answers 410 with "Publish link expired or already used. Start a new publish." Start a new publish in either case. Sending the zip again to a URL whose upload already finished publishes nothing new: it answers with that first outcome, so a retry after a dropped response is safe.
Read files
Read what is live before you change it. List the files of the live deployment, fetch the ones you need, and send your changes back as a file update with the listed deployment_id as base.
curl "https://api.manifestx.dev/v1/projects/proj_…/files?env=staging" \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY"
| Parameter | Notes |
|---|---|
env |
staging (default) or production. |
limit |
Files per page, 1 to 1,000. Defaults to 1,000. |
cursor |
The next_cursor of the previous page. |
The answer lists the files, sorted by path:
{
"env": "staging",
"deployment_id": "dep_…",
"files": [
{ "path": "_redirects", "size": 64, "content_type": "application/octet-stream", "etag": "9b2e…" },
{ "path": "_redirects.json", "size": 210, "content_type": "application/json; charset=utf-8", "etag": "41c7…", "compiled_from": "_redirects" },
{ "path": "articles/intro.md", "size": 1834, "content_type": "text/markdown; charset=utf-8", "etag": "e3b0…" },
{ "path": "index.html", "size": 2210, "content_type": "text/html; charset=utf-8", "etag": "5d41…" }
],
"next_cursor": null,
"request_id": "…"
}
Paths start at the site root, inside output_dir when the build has one, exactly as a file update takes them. etag changes whenever a file's content does, so compare it to tell which files changed since your last read. precache.json and manifest.utilities.css are left out because Manifest writes them on every publish. A file with compiled_from is compiled from that source file, so edit the source instead. If nothing is live yet, deployment_id is null and files is empty.
When next_cursor isn't null, send it as cursor for the next page. A cursor keeps reading the same deployment even if a new one goes live in the meantime, so every page comes from one version of the site.
Read one file
curl "https://api.manifestx.dev/v1/projects/proj_…/files/articles/intro.md" \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY"
The answer is the file itself, not JSON, from the deployment live on env (staging by default):
| Header | Value |
|---|---|
Content-Type |
The file's type, such as text/markdown; charset=utf-8 or image/png. |
ETag |
The file's etag from the list, in quotes. |
x-manifest-deployment |
The deployment the file came from. Send it as base with your update. |
HTML comes back as stored, without the analytics, badge, and stylesheet tags Manifest adds when it serves or publishes a page. Send If-None-Match with the ETag to get 304 Not Modified when the file hasn't changed. Encode special characters in a path the way a browser would, such as a space as %20. A file that isn't in the live site, or that Manifest writes itself, answers 404 file_not_found.
Update individual files
When only a few files change, such as an article, a data file, a stylesheet, or an image, send just those files. Manifest stores only the files you send, keeps everything else that is live on staging, and publishes the result as a new staging deployment. Every file you don't send stays as it was, so an update takes about as long for a 10,000-file site as for a small one. Promote to ship the result to production.
curl -X PATCH https://api.manifestx.dev/v1/projects/proj_…/files \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY" \
-H "Idempotency-Key: edit-intro-0042" \
-H "Content-Type: application/json" \
-d '{
"base": "dep_…",
"files": [
{ "path": "articles/intro.md", "content": "# Introduction\n\nUpdated text." },
{ "path": "data/team.yaml", "content": "- name: Ada\n role: Editor\n" },
{ "path": "media/logo.png", "content_base64": "iVBORw0KGgo…" },
{ "path": "articles/old.md", "delete": true }
]
}'
| Field | Type | Required | Notes |
|---|---|---|---|
env |
string | no | staging, the default and only value. Promote to reach production. |
base |
string | no | The deployment your changes start from, usually the deployment.id of your last update. If staging has moved on since, nothing changes and the answer is 409. |
files |
array | yes | 1 to 1,000 changes, one per path. |
Each change has a path and exactly one of these:
| Field | Type | Notes |
|---|---|---|
content |
string | The new file as UTF-8 text, for Markdown, YAML, JSON, CSV, HTML, CSS, and other text files. |
content_base64 |
string | The new file as standard base64, for images and other binary files. |
delete |
boolean | true removes the file. |
path starts at the site root, so articles/intro.md is served at /articles/intro.md. If the build was published with an output_dir, paths start inside that folder. A path that doesn't exist yet adds a file.
The answer is 201 Created:
{
"deployment": { "id": "dep_…", "env": "staging", "status": "ready", "files": 43, "bytes": 320544, "base": "dep_…" },
"changes": { "updated": 3, "deleted": 1 },
"url": "https://acme-docs-staging.manifestx.ai",
"request_id": "…"
}
The new version is live on the staging URL as soon as the answer arrives. Send its deployment.id as base on your next update. That way two editors working from the same version can't overwrite each other: the second update gets 409 base_mismatch with the live_deployment_id, so read the current files from staging, apply the change again, and send it with that id as base. Without base, the changes apply to whatever is live on staging. Either way, if another publish lands on staging while an update is running, the update is refused with the same 409, and you can send it again.
Every update is a new deployment, so rollback works as for any publish. Staging keeps its five most recent deployments, so frequent updates push older versions out of reach. Send an Idempotency-Key so a retried update doesn't publish twice.
If nothing is live on staging yet, the files start a new site and must include index.html. A change that would remove index.html is refused. .env files, .git, node_modules, and .claude are refused, and so are precache.json and manifest.utilities.css, which Manifest writes on every publish. When the site has a _redirects or _headers file, Manifest compiles it to _redirects.json or _headers.json, so update the source file instead, or delete it in the same update. Each file update counts as a publish toward a Free project's daily limit.
HTML read back from the staging URL can be sent as is: the analytics and badge tags Manifest adds when serving a page are removed before the file is stored.
Some sites carry a notices list in the answer:
| Notice | Meaning |
|---|---|
template_site |
The site was built by Content. The next content request rebuilds the whole site and replaces these changes. |
prerendered_site |
The build is render output. Pages prerendered from the changed files are not rebuilt, so publish a new build to refresh them. |
| Limit | Value |
|---|---|
| Request body | 12 MB |
| Largest single file | 8 MB, after base64 decoding. Publish larger files in a zip. |
| Changes per request | 1,000 |
| Resulting deployment | The zip limits: 250 MB and 10,000 files |
Promote staging to production
curl -X POST https://api.manifestx.dev/v1/projects/proj_…/promote \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY" \
-H "Idempotency-Key: promote-build-4182"
Promote ships exactly what is live on staging to production, with no rebuild:
{
"deployment": { "id": "dep_…", "env": "production", "files": 42 },
"live_deployment_id": "dep_…",
"url": "https://acme-docs.manifestx.ai",
"request_id": "…"
}
Each promote creates a new production deployment, so send an Idempotency-Key: a retry with the same key returns the first result and creates nothing. A site changed through file updates promotes without copying its files, however large it is.
Roll back
curl -X POST https://api.manifestx.dev/v1/projects/proj_…/rollback \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY" \
-H "Content-Type: application/json" \
-d '{ "env": "production" }'
| Field | Type | Required | Notes |
|---|---|---|---|
env |
string | no | production (default) or staging. |
deployment_id |
string | no | The version to restore. Omit it to go back to the version before the live one. |
Rollback is part of Pro: on a Free project it answers 402 pro_required. Rollback is instant: it points the environment back at an earlier deployment without copying anything. It answers { "env": "production", "live_deployment_id": "dep_…", "url": "https://acme-docs.manifestx.ai", "request_id": "…" }. To roll forward again, roll back to the newer deployment_id.
List deployments
curl "https://api.manifestx.dev/v1/projects/proj_…/deployments?env=staging" \
-H "Authorization: Bearer $MANIFEST_ACCOUNT_KEY"
env is production by default. The answer lists the environment's deployments, newest first:
{
"env": "staging",
"live_deployment_id": "dep_b…",
"deployments": [
{ "id": "dep_b…", "env": "staging", "source": "spa", "status": "ready", "is_live": true, "files": 42, "bytes": 318211, "created_at": 1760140800 },
{ "id": "dep_a…", "env": "staging", "source": "spa", "status": "superseded", "is_live": false, "files": 40, "bytes": 301877, "created_at": 1760054400 }
],
"request_id": "…"
}
ready and superseded deployments can be restored with a rollback; uploading and failed ones can't. Each environment keeps its five most recent deployments plus the live one. Older ones are deleted when a new deployment lands, so keep your build artifacts if you need an older version.
Errors
| Status | error |
Meaning |
|---|---|---|
| 400 | invalid_input |
Missing or malformed field, unknown field, or an env that isn't staging or production. |
| 400 | staging_only |
A file update sent env production. Update staging, then promote. |
| 400 | invalid_cursor |
A file list cursor isn't valid for this project, or its deployment is gone. List again without it. |
| 400 | invalid_path |
A file read's path doesn't name a file, for example it ends in / or contains ... |
| 402 | pro_required |
Rollback on a Free project. Upgrade the project to Pro in the dashboard. |
| 403 | forbidden |
You are a member of this project, not its owner or an admin. |
| 404 | not_found |
No such project, or the key can't reach it. |
| 404 | unknown_deployment |
deployment_id isn't one of this project's deployments for that environment. |
| 404 | file_not_found |
A file read names a file that isn't in the live site, or nothing is live on that environment yet. |
| 409 | nothing_on_staging |
Promote with nothing live on staging. Publish to staging first. |
| 409 | promote_in_progress |
Another promote of this project is still copying. Retry shortly. |
| 409 | no_earlier_version |
Rollback without deployment_id, and there is no earlier version to go back to. |
| 409 | already_live |
That deployment is already live. |
| 409 | not_restorable |
That deployment is still uploading or failed. |
| 409 | base_mismatch |
A file update's base isn't live on staging anymore. Reapply the change on live_deployment_id. |
| 413 | payload_too_large |
A file update body is over 12 MB. |
| 413 | file_too_large |
A file is over 8 MB. Publish it in a zip. |
| 413 | deployment_too_large |
The updated site would pass 250 MB or 10,000 files. |
| 422 | invalid_path |
A path is unsafe, private, written by Manifest, compiled from _redirects or _headers, or listed twice. field names it. |
| 422 | invalid_file |
A change needs exactly one of content, content_base64, and delete, or its base64 is invalid. |
| 422 | file_not_found |
A delete names a file that isn't on staging. |
| 422 | no_index |
The update would leave the site without index.html. |
| 429 | publish_limit_reached |
A Free project's daily publish limit is reached. Wait the seconds in Retry-After. |
| 500 | update_failed |
The update failed. The message says whether staging is unchanged, or to check deployments before retrying. |
| 503 | update_timed_out |
Storing the changed files took too long, and staging is unchanged. Retry, or send fewer files per request. |
| 503 | promote_failed |
A promote couldn't finish, and production is unchanged. Retry shortly. |
| 503 | publish_unavailable |
A publish couldn't be started. Retry shortly. |
Article does not exist
There is no documentation at this path.