deed.page

Blog · 2026-10-16

Publish large sites as one version — plan_upload, direct blobs, finalize

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

GoalPrefer
Site fits under ~4 MBOne PUT/POST publish
Add a few paths to a live site over several small requestsX-Merge: 1
Whole tree must appear as one version, or you already have sha256splan_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.