Skip to content

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.