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

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

```bash
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.

```bash
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

```http
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:

```js
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:

```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

```bash
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](https://deed.page/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](https://deed.page/docs). Related: [version history and rollback](https://deed.page/blog/version-history-and-rollback) and [custom domains via API](https://deed.page/blog/custom-domains-via-api).
