Skip to content

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 (enum pending|delivered|failed|dead — add webhookDeliveryStatusEnum to enums.ts), attempts (int default 0), lastAttemptAt (timestamptz null), lastStatusCode (int null), nextRetryAt (timestamptz null), createdAt.
  • Add relations in relations.ts if the project wires them there.

Steps: define tables → npm run db:generatereview 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): stringcrypto.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 | null on schedule 1m/10m/1h/6h/24h; returns null after the 5th attempt (→ caller marks dead). Test each boundary + the null-after-last case (pass now in).
  • deliver.ts: deliverOne(delivery, endpoint) — POST the stored payload (raw JSON string) to endpoint.url with headers X-Tending-Signature (= sign(rawBody, endpoint.secret)), X-Tending-Event, X-Tending-Delivery (= delivery id), 10s AbortSignal.timeout. On 2xx → mark delivered; else compute nextRetryAtfailed (or dead if null); update the delivery row + endpoint lastDeliveryAt/lastStatus. Guard the URL with isSafeWebhookUrl before sending.
  • emit.ts: emitEvent(orgId, event, { actor, subject, data }) — build envelope, find active endpoints subscribed to event, insert a pending delivery per endpoint (payload = signed envelope JSON), then fire delivery best-effort via Next's after() (see how other code uses after); 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/watershed package (later).