A single publish body on deed.page is capped at about 4 MB — a serverless platform limit, not your plan quota. Free still allows 25 MB per site; Hobby and Pro go higher. For folders that do not fit one PUT, you already have X-Merge: 1, which adds or overwrites paths across several requests and several versions. When you want the whole tree to land as one version, use the upload session path instead: plan a manifest, upload only the missing blobs, then finalize.
Every example below was run against https://deed.page on October 16, 2026, on throwaway slugs published with a 900-second TTL and deleted afterwards.
Three steps, one version
1. Plan — POST /v1/uploads with a list of {path, sha256, size} (optional content_type). You get a session id (upl_…), a finalize_url, and for each hash either stored: true or a direct upload URL.
2. Upload — PUT only the missing blobs to those URLs, using the headers the plan returned. Bytes go to object storage, not through the API function body.
3. Finalize — POST the finalize_url. Hashes are checked, new bytes are screened, and one version is committed under the per-slug lock.
A session lasts 60 minutes. After that, finalize returns 410 session_expired. A manifest may list at most 5,000 files. Per-site byte caps stay the plan limits.
Curl, two files
SHA1=$(sha256sum index.html | awk '{print $1}')
SIZE1=$(wc -c < index.html | tr -d ' ')
SHA2=$(sha256sum asset.txt | awk '{print $1}')
SIZE2=$(wc -c < asset.txt | tr -d ' ')
curl -sS -X POST https://deed.page/v1/uploads \
-H "Authorization: Bearer $DEEDPAGE_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"slug\":\"acme-pack-7f3k\",\"ttl_seconds\":900,\"files\":[
{\"path\":\"index.html\",\"sha256\":\"$SHA1\",\"size\":$SIZE1},
{\"path\":\"asset.txt\",\"sha256\":\"$SHA2\",\"size\":$SIZE2}
]}"A successful plan looks like:
HTTP 200
{"ok":true,"session":"upl_…","expires_at":"…",
"slug":"acme-pack-7f3k","bytes":185,"files":2,"merge":false,
"missing":[{"sha256":"3c30…","size":151,"stored":false,
"paths":["index.html"],"upload":{"method":"PUT","url":"…","headers":{…}}},
…],
"present":[],
"finalize_url":"https://deed.page/v1/uploads/upl_…/finalize"}PUT each missing[].upload.url with that entry's headers and the raw file bytes. Then:
curl -sS -X POST "$FINALIZE_URL" \
-H "Authorization: Bearer $DEEDPAGE_TOKEN"
HTTP 201
{"ok":true,"url":"https://acme-pack-7f3k.deed.page/",
"version":"v1","bytes":185,"files":2,
"manifest_url":"https://deed.page/v1/sites/acme-pack-7f3k/versions/v1/manifest",
"signature":{"alg":"Ed25519","kid":"…","value":"…"}}The site and every path in the manifest served 200 immediately after finalize.
Content-addressed blobs
Hashes already stored (from another site or an earlier version) come back with stored: true. On a second plan of the same two files, both missing entries were stored: true, present stayed [], and finalize succeeded with no PUTs. Identical bytes are not re-uploaded. That is the difference from X-Merge: merge ships paths through the API again; plan_upload only ships hashes that storage does not already have.
When to use this vs X-Merge
| Goal | Prefer |
|---|---|
| Site fits under ~4 MB | One PUT/POST publish |
| Add a few paths to a live site over several small requests | X-Merge: 1 |
| Whole tree must appear as one version, or you already have sha256s | plan_upload → upload → finalize_upload |
Finalize uses the same per-slug lock and the same If-Match / X-Expected-Version / MCP expected_version checks as a small publish. Two finalizes of the same slug: one wins; the other is 409 version_conflict.
Errors you will actually see
sha256 shorter than 64 hex → 400 {"code":"bad_manifest",
"message":"sha256 for index.html must be 64 lowercase hex characters."}
PUT wrong bytes under a claimed hash, then finalize
→ 422 {"code":"hash_mismatch",
"message":"Blob … does not match the manifest (sha256 or size)."}
POST /v1/uploads/upl_doesnotexist/finalize
→ 404 {"code":"session_not_found",
"message":"Unknown upload session."}Nothing is published on those failures. Fix the manifest or the bytes and plan again.
One-liner script
The repo ships a reference client that hashes a directory, plans, uploads missing blobs, and finalizes:
node scripts/publish-large.mjs ./site \
--slug acme-pack-7f3k \
--origin https://deed.page \
--token "$DEEDPAGE_TOKEN"Auth can also come from DEEDPAGE_TOKEN. Pass --merge to overlay paths on the live site instead of replacing it. On October 16 that script published a two-file fixture as v1 with an Ed25519 signature in the response.
From MCP
plan_upload takes the same manifest (files, optional slug, ttl_seconds, merge, expected_version). Upload every missing blob yourself (the tool does not stream file bytes). Then call finalize_upload with the session id (upl_…). Against production, a plan whose hashes were already stored returned stored: true for both files, and finalize returned the same 201-shaped body as the REST path (url, version, manifest_url, signature).
After it is live
GET /v1/sites/{slug}/manifest is public. It returns the canonical file list and, when signing is configured, an Ed25519 signature you can check with the keys at https://deed.page/.well-known/deed-signing-keys.json — or with node scripts/verify-deed.mjs <manifest-url>.
Plan quotas and request-body caps are unchanged. For agent discovery of the rest of the surface, start at https://deed.page/llms.txt and https://deed.page/docs.