Sometimes another system needs to know a deed.page site changed: a second agent that shares the link, a CI job waiting on a custom domain, or a version log. deed.page sends a signed JSON POST to a URL you register, on every plan, including Free.
Every response below was captured against production on October 24, 2026. The test site, webhooks, and token were deleted or revoked afterwards.
Register a webhook
curl -s -X POST "https://deed.page/v1/webhooks" \
-H "Authorization: Bearer $DEEDPAGE_TOKEN" \
-H "content-type: application/json" \
-d '{"url":"https://hooks.example.com/deed"}'
# {"ok":true,"id":"whk_…","url":"https://hooks.example.com/deed",
# "events":["site.published","site.version_restored","site.deleted",
# "site.taken_down","domain.active","report.received"],
# "secret":"whsec_…",
# "secret_note":"Shown once. We store it to sign deliveries and do not return it again."}
Store secret right away, next to the token. GET /v1/webhooks lists endpoints and the plan limit, never the secret. Omit events to get all six, or pass a subset.
| Event | Sent when | data |
|---|---|---|
site.published | a publish changed files | slug, url, version |
site.version_restored | a restore made a new version | slug, url, version, restored_from |
site.deleted | the owner deleted the site | slug |
site.taken_down | the operator took the site down | slug, reason, scope |
domain.active | a custom hostname went live | slug, domain, url |
report.received | someone reported your site | slug, report_id, reason (never the reporter's contact) |
An identical republish returns "unchanged": true and sends nothing. In our test, publishing v2 produced one site.published delivery, the same bytes again produced none, and restoring v1 produced site.version_restored with "version":"v3".
The same thing over MCP
https://deed.page/mcp has create_webhook, list_webhooks, and delete_webhook. Pass the token as a Bearer header or a token argument.
curl -s https://deed.page/mcp \
-H "content-type: application/json" \
-H "accept: application/json, text/event-stream" \
-H "Authorization: Bearer $DEEDPAGE_TOKEN" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"create_webhook",
"arguments":{"url":"https://hooks.example.com/deed","events":["site.published","site.deleted"]}}}'
# structuredContent: {"ok":true,"id":"whk_…","events":["site.published","site.deleted"],"secret":"whsec_…",…}
Without a token it returns isError: true.
What a delivery looks like
POST /deed HTTP/1.1
Content-Type: application/json
User-Agent: deed.page-webhooks
Deed-Signature: t=1792848217,v1=fd6836…
{"id":"evt_…","type":"site.published","created":"2026-10-24T13:23:37.000Z",
"data":{"slug":"rain-log-7f3k","url":"https://rain-log-7f3k.deed.page/","version":"v2"}}
v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with your whsec_ secret. Verify the raw bytes before parsing. Reject a timestamp more than 5 minutes from your clock, either direction; deed.page doesn't check this for you.
Verify the signature
Node, no dependencies:
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.DEED_WEBHOOK_SECRET; // the whsec_… value, shown once
const seen = new Set(); // use a real store in production
function verify(raw, header) {
const parts = Object.fromEntries(
(header || "").split(",").map((p) => p.split("=", 2)),
);
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-minute window
const want = createHmac("sha256", SECRET).update(`${t}.${raw}`).digest();
const got = Buffer.from(parts.v1, "hex");
return got.length === want.length && timingSafeEqual(got, want);
}
createServer((req, res) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (!verify(raw, req.headers["deed-signature"])) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(raw);
res.writeHead(204).end(); // answer fast, work after
if (seen.has(event.id)) return; // a retry can repeat an id
seen.add(event.id);
console.log(event.type, event.data.slug, event.data.version ?? "");
});
}).listen(8787);
The same check in Python:
import hashlib, hmac, time
def verify(secret: str, raw: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1 or abs(time.time() - int(t)) > 300:
return False
want = hmac.new(secret.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)
We tested both against deed.page's own signing function: a valid header passed; a changed body, a 10-minute-old timestamp, and the wrong secret failed; a repeated event id was logged once.
Test it, then read the delivery log
curl -s -X POST "https://deed.page/v1/webhooks/$HOOK_ID/test" -H "Authorization: Bearer $DEEDPAGE_TOKEN"
# {"ok":true,"id":"whk_…","note":"…","delivery":{"type":"webhook.test","ok":true,"status":204,"attempts":1,"error":null,…}}
curl -s "https://deed.page/v1/webhooks/$HOOK_ID/deliveries" -H "Authorization: Bearer $DEEDPAGE_TOKEN"
Delivery is best-effort, not a queue. deed.page tries up to 3 times: right away, then after 0.5 seconds, then after 2 seconds. Any 2xx counts as success. Redirects are not followed. Against an endpoint that always returns 500, both the test and the site.deleted event were logged as "ok":false,"status":500,"attempts":3,"error":"HTTP 500", and then the event is gone. If a missed event matters, answer 2xx fast and reconcile with GET /v1/sites or list_versions on a schedule. The log keeps the 30 most recent deliveries across your webhooks.
Limits and errors
Free allows 1 webhook, Hobby 5, and Pro 20 (pricing). Targets must be public http or https on port 80 or 443. Private and local addresses are refused.
| Status | code | Cause we saw |
|---|---|---|
| 400 | bad_target | http://localhost/…, http://10.0.0.5/…, https://169.254.169.254/…, port 8443, or ftp:// |
| 400 | bad_request | an unknown event such as site.updated |
| 401 | unauthorized | no Bearer token |
| 401 | token_revoked | a token that was revoked or rotated |
| 403 | suspended | "This token is disabled." |
| 403 | webhook_limit | "Free allows 1 webhook. See /pricing." |
| 404 | not_found | test, deliveries, or delete on an unknown id |
To rotate a secret, delete the webhook (DELETE /v1/webhooks/{id} or delete_webhook) and create it again. The new secret comes back once.
Full reference: deed.page/docs. Related: version history and rollback and custom domains via API.