An agent says "published, here is the link." A reviewer, a second agent, or a CI job may want more than a link: proof that the bytes at that URL are exactly the bytes that were published, at a specific version. deed.page gives every version a signed manifest for that. No token is needed to read or check one.
Every example below was run against https://deed.page on October 19, 2026, on a throwaway slug published with a 900-second TTL and deleted afterwards.
What gets signed
Each version has a canonical manifest with five keys in a fixed order: slug, version, created_at, bytes, and files sorted by path, each {path, sha256, size}. The signed bytes are the UTF-8 of that JSON object. The signature is Ed25519, and the public keys are at:
curl -s https://deed.page/.well-known/deed-signing-keys.jsonThat returns {"ok":true,"signing":true,"keys":[{"kid":"…","alg":"Ed25519","kty":"OKP","crv":"Ed25519","x":"…","current":true}]}. The kid is the first 16 hex characters of SHA-256 over the raw 32-byte public key. Keys kept after a rotation stay in that list, so older signatures still verify.
The publish response already points at it
A normal publish now returns the manifest URL and the signature with the live URL:
curl -s -X PUT "https://deed.page/v1/sites?slug=acme-report-7f3k" \
-H 'Content-Type: application/gzip' -H 'X-Ttl: 900' \
--data-binary @site.tar.gzThe response includes "version":"v1", "manifest_url":"https://deed.page/v1/sites/acme-report-7f3k/versions/v1/manifest", and "signature":{"alg":"Ed25519","kid":"…","value":"…"}. Store manifest_url next to the URL you report. It names one exact version.
Three ways to read a manifest
- Live version:
GET https://deed.page/v1/sites/{slug}/manifest - One retained version:
GET https://deed.page/v1/sites/{slug}/versions/v1/manifest - On the site itself:
https://{slug}.deed.page/.deed/manifest.json, and on a version permalink such ashttps://v1--{slug}.deed.page/.deed/manifest.json
Each returns manifest, canonical (the exact signed string), signature, keys_url, and site_url. For the live version, site_url is https://{slug}.deed.page/. For an older version, it is the immutable permalink https://v{n}--{slug}.deed.page/. An unknown slug is 404 not_found. A version that retention has dropped, or never existed, is 404 version_not_found.
signature is null when the server has no signing key configured. It is set on deed.page today. A verifier should still treat null as "not signed".
Over MCP
The get_manifest tool takes slug and an optional version such as "v1". It is read-only and needs no token:
curl -s https://deed.page/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_manifest","arguments":{"slug":"acme-report-7f3k","version":"v1"}}}'The result carries the same document as the REST call, in both content[0].text and structuredContent. An unknown slug comes back with isError: true and not_found.
A verifier you can paste
Save this as verify.mjs (Node 18 or newer, no dependencies):
import { createHash, createPublicKey, verify } from "node:crypto";
const doc = await (await fetch(process.argv[2])).json();
const m = doc.manifest;
const files = [...m.files]
.map(({ path, sha256, size }) => ({ path, sha256, size }))
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
const canonical = JSON.stringify({
slug: m.slug, version: m.version, created_at: m.created_at,
bytes: files.reduce((n, f) => n + f.size, 0), files,
});
if (canonical !== doc.canonical) throw new Error("canonical mismatch");
if (!doc.signature) throw new Error("unsigned");
const { keys } = await (await fetch(doc.keys_url)).json();
const jwk = keys.find((k) => k.kid === doc.signature.kid);
if (!jwk) throw new Error("unknown kid");
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: jwk.x }, format: "jwk" });
const sig = Buffer.from(doc.signature.value, "base64url");
if (!verify(null, Buffer.from(canonical), key, sig)) throw new Error("bad signature");
for (const f of files) {
const res = await fetch(new URL(f.path, doc.site_url));
const bytes = Buffer.from(await res.arrayBuffer());
const hash = createHash("sha256").update(bytes).digest("hex");
if (!res.ok || hash !== f.sha256 || bytes.length !== f.size) throw new Error(`${f.path} differs`);
}
console.log(`verified ${m.slug} v${m.version}: ${files.length} files, kid ${doc.signature.kid}`);Run it against a manifest URL:
node verify.mjs https://deed.page/v1/sites/acme-report-7f3k/versions/v1/manifestIt prints verified acme-report-7f3k v1: 2 files, kid … and exits 0. Any failure throws and exits non-zero. It rebuilds the canonical string itself instead of trusting the canonical field, and it checks every file's hash and size at site_url. Changing a single character of the signed string, such as one path, makes verify return false.
Pin a version, not "latest"
After we published v2 of the test slug, the v1 manifest still verified, because its site_url is the v1-- permalink and those bytes never change. The live URL is different. Manifest responses carry Cache-Control: public, max-age=60, while the site files revalidate on every request. So for up to a minute after an update, GET /v1/sites/{slug}/manifest can still return the previous version, and checking it against the new files fails. That happened once in our test. If a live check fails right after a publish, wait a minute and retry. For a stable answer, verify the manifest_url from the publish response or a versions/vN manifest.
Permalinks last as long as the version is retained: the last 10 on Free, 50 on Hobby, and every version on Pro. Deleting a site takes the live host and the permalinks down at once. In our test, a cached manifest response stayed readable for up to a minute after the delete, and then returned 404 not_found. The signing keys document is cached for five minutes.
Where this fits
- A CI step can publish, then run the verifier before posting the link anywhere.
- A reviewing agent can check a URL it was handed without holding the owner's token.
The full reference is in the docs under "Signed manifests", and the endpoints are in the OpenAPI spec. deed.page is operated by Avatar 8 LLC (ops@avatar33.com).