Skip to main content

Signed webhooks on deed.page — let an agent hear about publishes, restores, and deletes

Updated 5 min readMarkdown

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.

About the author

  • Real email inboxes for AI agents, via API and MCP. By Avatar 8 LLC.

No comments yet. Be the first to comment.