Webhooks (Phase 2) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Give Tending outbound webhooks — organisations register HMAC-signed endpoints and Tending delivers minimised, Watershed-shaped events (moment.created, connection.created, observation.generated, quality.shifted, follow_up.due) with retries and a dead-letter path.
Architecture: A src/lib/webhooks/ module with pure envelope/sign/SSRF/backoff helpers (unit-testable, no DB) and a thin emit → enqueue → deliver layer over two new tables. Events are emitted from existing write paths via a single emitEvent() call; failed deliveries are retried by a CRON_SECRET-guarded cron on a backoff schedule. Envelope shape matches watershed-spec.md §4.1 so it is forward-compatible with the future spine.
Tech Stack: Next.js 15 App Router, Drizzle (Neon), Vitest, Node crypto (HMAC — no new deps), Zod. Reference design: docs/plans/2026-07-09-data-portability-design.md (Part 2).
Testing note: No test DB in this repo — pure helpers (envelope, sign/verify, SSRF guard, backoff) are TDD'd with Vitest; DB/route/cron pieces are verified by tsc + build + review + a manual authenticated smoke.
Dependency note: The follow_up.due event (Task 8) depends on the follow-up-reminders work (PR #8) being merged into this branch's base — its cron does the scheduled→new flip this event hooks. If PR #8 is not yet merged, implement Tasks 1–7 + 9–10 and leave follow_up.due as a documented TODO until the reminder cron exists.
Task 1: Schema — webhook tables + migration
Files: Create src/lib/db/schema/webhooks.ts; modify src/lib/db/schema/index.ts (export it); run npm run db:generate.
webhook_endpoints:id(uuid pk),organisationId(fk → organisations, cascade),url(text),secret(text, ≥32-byte hex),events(text[] — subscribed event names),active(boolean default true),lastDeliveryAt(timestamptz null),lastStatus(text null),createdAt.webhook_deliveries:id(uuid pk),eventId(uuid — the envelope id, idempotency key),endpointId(fk → webhook_endpoints, cascade),event(text),payload(jsonb — the full signed envelope),status(enumpending|delivered|failed|dead— addwebhookDeliveryStatusEnumtoenums.ts),attempts(int default 0),lastAttemptAt(timestamptz null),lastStatusCode(int null),nextRetryAt(timestamptz null),createdAt.- Add relations in
relations.tsif the project wires them there.
Steps: define tables → npm run db:generate → review the generated SQL (new enum + two tables) → commit schema + migration. Apply to DB (db:push) is a user-gated step per [[mycelia-local-uses-prod-db]] — do NOT run it from a subagent; note it for the human.
Commit: feat(webhooks): endpoint + delivery tables
Task 2: Event envelope (pure, TDD)
Files: Create src/lib/webhooks/envelope.ts + envelope.test.ts.
Build the Watershed §4.1 envelope + Zod schema. buildEnvelope({ event, organisationId, tenantId, actor, subject, data }) returns { id, schemaVersion: 1, event, occurredAt, sourceApp: "tending", tenant: { app: "tending", id }, actor, subject, data }. id is a UUID (use crypto.randomUUID()). Export a Zod envelopeSchema and a typed WebhookEvent union of the 5 event names.
Test: envelope has all required fields, sourceApp === "tending", schemaVersion === 1, validates against envelopeSchema, and a bad event name fails the schema. (Pass a fixed occurredAt/id in via params or accept the generated ones and assert shape, not exact values — do NOT rely on Date.now() in assertions.)
Commit: feat(webhooks): event envelope + schema
Task 3: HMAC sign/verify (pure, TDD)
Files: Create src/lib/webhooks/sign.ts + sign.test.ts.
sign(rawBody: string, secret: string): string → crypto.createHmac("sha256", secret).update(rawBody).digest("hex"). verify(rawBody, secret, signature): boolean → timing-safe compare (crypto.timingSafeEqual). generateSecret(): string → 32 random bytes hex.
Test: verify(body, secret, sign(body, secret)) is true; wrong secret / tampered body → false; generateSecret() returns 64 hex chars and differs across calls (vary by calling twice — do NOT assert against Math.random).
Commit: feat(webhooks): HMAC sign/verify
Task 4: SSRF URL guard (pure, TDD)
Files: Create src/lib/webhooks/verify-url.ts + verify-url.test.ts.
isSafeWebhookUrl(url: string): boolean — must be https: (allow http: only for localhost in dev if needed), reject private/loopback/link-local/metadata ranges (127.*, 10.*, 172.16–31.*, 192.168.*, 169.254.*, ::1, fc00::/7), reject non-http(s) schemes and malformed URLs. Model on Glade's validateWebhookUrl (referenced in watershed-spec.md §2.2).
Test: accepts https://example.com/hook; rejects http://169.254.169.254, https://10.0.0.1, https://localhost (prod), file:///etc/passwd, and garbage strings.
Commit: feat(webhooks): SSRF url guard
Task 5: Backoff + delivery core (backoff pure/TDD; deliver/emit reviewed)
Files: Create src/lib/webhooks/backoff.ts + backoff.test.ts; src/lib/webhooks/deliver.ts; src/lib/webhooks/emit.ts.
backoff.ts(pure):nextRetryAt(attempts: number, now: Date): Date | nullon schedule 1m/10m/1h/6h/24h; returnsnullafter the 5th attempt (→ caller marksdead). Test each boundary + the null-after-last case (passnowin).deliver.ts:deliverOne(delivery, endpoint)— POST the storedpayload(raw JSON string) toendpoint.urlwith headersX-Tending-Signature(=sign(rawBody, endpoint.secret)),X-Tending-Event,X-Tending-Delivery(= delivery id), 10sAbortSignal.timeout. On 2xx → markdelivered; else computenextRetryAt→failed(ordeadif null); update the delivery row + endpointlastDeliveryAt/lastStatus. Guard the URL withisSafeWebhookUrlbefore sending.emit.ts:emitEvent(orgId, event, { actor, subject, data })— build envelope, find active endpoints subscribed toevent, insert apendingdelivery per endpoint (payload = signed envelope JSON), then fire delivery best-effort via Next'safter()(see how other code usesafter); never throw into the caller's request path.
Commit: feat(webhooks): emit + deliver with backoff
Task 6: First emission point — moment.created (+ minimisation)
Files: Modify src/app/api/moments/route.ts (POST); create src/lib/webhooks/payloads.ts + test (pure minimisers).
payloads.ts(pure, TDD):momentCreatedPayload(moment, connectionNames, spaceName)→{ content: truncate(content, 500), connectionNames, spaceName, source };subject={ kind: "moment", ref: "tending:moment:<id>", url: <app url> }. Test truncation + shape.- In the moments POST, after the existing best-effort AI blocks, call
emitEvent(orgId, "moment.created", …)inside its own try/catch (mirrors the other best-effort blocks — must never fail moment creation).
Commit: feat(webhooks): emit moment.created
Task 7: Retry cron
Files: Create src/app/api/cron/webhook-deliveries/route.ts; modify vercel.json.
GET guarded by Authorization: Bearer ${CRON_SECRET} (mirror src/app/api/cron/trial-reminders/route.ts: missing secret → 503, bad header → 401). Select webhook_deliveries where status in ('pending','failed') and (nextRetryAt is null or nextRetryAt <= now), limit a batch; call deliverOne for each; return { checked, delivered, failed, dead }. Add a vercel.json cron entry (e.g. */10 * * * *). Dead deliveries stay dead and surface in the UI (Task 9).
Commit: feat(webhooks): delivery retry cron
Task 8: Remaining emission points
Files: src/app/api/moments/route.ts (quality.shifted, thresholded — only when a spectrum moves materially, e.g. |Δ|>0.3 or crosses 0), the connection-create route (connection.created), the observation-generation route (observation.generated), and — only if PR #8 is merged — the follow-up cron (follow_up.due on the scheduled→new flip). Each is one emitEvent call with a minimised payload (add minimisers to payloads.ts, each TDD'd). Reuse the best-effort try/catch pattern; never block the primary write.
Commit: feat(webhooks): emit connection.created, observation.generated, quality.shifted (+ follow_up.due if available)
Task 9: Developers settings UI — webhooks
Files: Create src/components/developers/webhook-manager.tsx (client) + a route/API to CRUD endpoints (src/app/api/webhooks/route.ts GET/POST, .../[id]/route.ts PATCH/DELETE, admin+, org-scoped, using getOrgContext); add a "Developers" section to settings (new page settings/developers/page.tsx or a block on the settings page).
UI: list endpoints (url, subscribed events, active toggle, last delivery status), add-endpoint form (url validated client+server via isSafeWebhookUrl, event checkboxes, secret generated server-side and shown once), rotate secret, delete, and a small dead-letter indicator per endpoint. Admin+ only.
Commit: feat(webhooks): developers settings UI
Task 10: Final verification
npx tsc --noEmit,npm run lint,npm test,npm run build— all green.- Manual smoke: register an endpoint pointing at a request-bin, create a moment, confirm a signed delivery arrives and verifies against the secret; force a failure and confirm the cron retries then dead-letters.
- Fresh-eyes review of the whole webhooks diff (data-safety: no over-broad payloads, SSRF guard enforced, secrets never logged/returned after creation; correctness of retry/idempotency).
- Update
STATE.md.
Out of scope
- Inbound signed event handling (arrives with the Watershed spine).
- Extracting envelope/sign into the shared
@goodship/watershedpackage (later).