Memo fires HMAC-signed POSTs to any HTTPS endpoint you register whenever a subscribed event happens. The dispatcher is a cron-driven worker over a webhook_deliveries queue; failed deliveries retry with exponential backoff for up to 6 attempts.
/<your-username>/settings/webhooks.| Event | When it fires |
|---|---|
visit.opened | A visitor passes the final gate and the content handler inserts a fresh visits row. Payload includes country, city, and visitorPersistentId (long-lived browser cookie) for cross-session attribution. |
visit.completed | Beacon's first page_leave arrives. Fires exactly once per visit; idempotent via visits.ended_at IS NULL. Payload now includes engagementScore (0..1300) and sectionsEntered (distinct data-memo-section ids seen by the visitor). |
nda.signed | A visitor signs an agreement |
link.created | An owner mints a new access link |
link.updated | A link is PATCHed (active / name / watermark / config) |
link.expired | The expire-links cron flips an expired link to inactive |
project.published | A new version commits cleanly |
version.rollback | projects.current_version_id flips to a prior version |
space.created | An owner creates a data-room / space |
space.updated | A space is PATCHed (name / config) |
space.deleted | Declared but not yet wired — no fire site emits this event today |
Every mutation that has both a REST and an MCP entrypoint fires the same webhook from both. The dispatcher (fireWebhookEvent in src/lib/webhooks/store.ts) is called once per fire site; the cron worker handles delivery + retry uniformly. This table is the source of truth for which file emits which event.
| Event | REST fire site | MCP fire site |
|---|---|---|
visit.opened | src/pages/[slug]/index.astro (the content handler, only when INSERT … RETURNING returns a fresh row — was view.astro before /view was collapsed into /) | — (visitor surface, no MCP) |
visit.completed | src/pages/api/beacon.ts (first page_leave, idempotent on ended_at IS NULL) | — (visitor surface, no MCP) |
nda.signed | src/pages/api/v/[slug]/sign.ts | — (visitor surface, no MCP) |
link.created | src/pages/api/owner/links/index.ts POST | src/lib/mcp/tools.ts → createLink |
link.updated | src/pages/api/owner/links/[id].ts PATCH | src/lib/mcp/tools.ts → updateLink and src/lib/mcp/tools-v3.ts → updateLinkFullTool |
link.expired | src/pages/api/cron/expire-links.ts (no actor — cron is the actor) | — (cron-only) |
project.published | src/pages/api/owner/projects/[id]/publish.ts | src/lib/mcp/tools.ts → publish |
version.rollback | src/pages/api/owner/projects/[id]/versions/rollback.ts | src/lib/mcp/tools-v3.ts → rollbackVersionTool |
space.created | src/pages/api/owner/spaces/index.ts POST | — (no space MCP tool) |
space.updated | src/pages/api/owner/spaces/[id].ts PATCH | — (no space MCP tool) |
space.deleted | — (declared in WEBHOOK_EVENTS but no fire site; the DELETE handler does not yet emit it) | — |
Notes:
space.deleted is declared in WEBHOOK_EVENTS but not yet wired (the space DELETE handler does not emit it).link.created, link.updated, project.published, and version.rollback fire from both REST and MCP — this is the parity rule: an MCP client invoking update_link_full cannot bypass the webhook that the matching REST PATCH would have produced.visit.* and nda.signed are visitor-side and have no MCP equivalent (MCP is owner-only). link.expired is cron-only — no human-driven entrypoint.linkId / versionId / etc. fields). Receivers don't need a separate code path per source.POST <your-url> HTTP/1.1
Host: …
content-type: application/json
memo-event: project.published
memo-delivery-id: <uuid>
memo-endpoint-id: <uuid>
memo-signature: t=1717024811,v1=<hex hmac-sha256>
{
"event": "project.published",
"ts": "2026-…",
"data": { … event-specific payload … }
}
import { createHmac } from "node:crypto";
function verify(secret: string, header: string, body: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (Math.abs(Date.now() / 1000 - t) > 300) return false; // 5 min tolerance
const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
return parts.v1 === expected;
}
The tolerance window prevents replays. Receivers MUST verify both the signature and the timestamp.
nextAttemptAt is set to now + 30s × 2^(attempts-1) (30 s, 1 m, 2 m, 4 m, 8 m, 16 m).webhook_deliveries for forensic inspection.webhook_endpoints.last_delivery_at column updates only on success.| Surface | Route | Purpose |
|---|---|---|
| UI | /u/<username>/settings/webhooks | Create, toggle active, delete. Secret revealed exactly once. |
| API | GET/POST /api/owner/webhooks, PATCH/DELETE /api/owner/webhooks/<id> | |
| MCP | list_webhooks, create_webhook, delete_webhook | |
| Cron | /api/cron/dispatch-webhooks (Vercel cron + QStash-compatible) | The worker that POSTs deliveries. Authorize header same as other crons. |
Every lifecycle action writes an audit_logs row:
| Action | When |
|---|---|
webhook.create | Owner mints a new subscription (UI or MCP) |
webhook.update | Owner toggles active |
webhook.delete | Owner removes the subscription |
The delivery log itself (webhook_deliveries) is the per-event audit; you can SELECT * FROM memo.webhook_deliveries ORDER BY created_at DESC to see every attempt for every endpoint.
Webhooks are one of three "watch this in near-real-time" mechanisms memo exposes. They're best for owner-side automation (Slack/Notion/internal CRM). For human-eyes-on the activity, see:
/api/owner/projects/<id>/activity/since every 8s and slides new rows in with a yellow-fade. No webhook receiver required; only works when the tab is open./u/<username>/people/<emailHash>.skim / deep_read / return / share_detected chips on Activity / People rows.The engagementScore and sectionsEntered fields in the visit.completed payload mirror the columns / Mongo events the Activity surfaces use, so a webhook receiver can build its own dashboard that matches memo's view of "hottest leads" exactly.
| Concern | Mitigation |
|---|---|
| Replay attack | 5-min timestamp tolerance baked into the signature header |
| Forged delivery | HMAC-SHA256 over <timestamp>.<body> with a 32-byte secret |
| Secret leaked from a backup | Stored hash-only; raw shown exactly once at create |
| Hostile endpoint | Memo retries with exponential backoff; max 6 attempts; exhausted rows stop |
| Endpoint slowness | Each delivery has a default 10s timeout; future v3.1 will surface a per-endpoint timeout |
| Audit-trail tampering | audit_logs + webhook_deliveries are append-only; the dispatcher updates delivered_at / attempts but never deletes rows |