# Webhook Endpoint Detail — Implementation Plan

**Spec:** docs/specs/2026-05-31-webhook-endpoint-detail.md  ·  **Slug:** webhook-endpoint-detail  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, white-label-api

## Goal
Deliver the standalone webhook endpoint detail experience at `/settings/integrations/webhooks` and `/settings/integrations/webhooks/:id`: full endpoint configuration, per-endpoint event subscription filtering (consolidating retired spec 84), a paginated delivery log with retry and detail inspection (consolidating retired spec 63), secret reveal/rotate, and test dispatch. It also hardens inbound webhook security (timing-safe HMAC rejection, raw-body forensic logging) and adds a 30-day delivery-log retention cron. This spec builds entirely on the `webhook_endpoints`, `webhook_deliveries`, and `webhook.deliver` queue defined upstream by `white-label-api`.

## Architecture
The feature is API + UI on top of upstream `white-label-api` primitives:
- Consumes upstream tables `webhook_endpoints` (id, tenant_id, url, secret [encrypted], events TEXT[], is_active, created_at, updated_at) and `webhook_deliveries` (id, tenant_id, endpoint_id, event_type, payload JSONB, status, response_status, response_body, attempt, delivered_at, next_retry_at, created_at).
- Adds one new table `webhook_delivery_log` for INBOUND webhook forensic records (rejected/accepted external deliveries), distinct from the outbound `webhook_deliveries` table.
- Consumes the `webhook_deliveries.status` column (owned by white-label-api, spec 27) and adds a GIN index on `webhook_endpoints.events` for contains-filtering.
- Consumes upstream queue binding `webhook.deliver` (delivery + retry) and the `RATE_LIMITER_WEBHOOK` binding (inbound rate limiting; reused, never re-created).
- Consumes auth/security exports: `tenantQuery`, `requirePermission` (`settings:read` / `settings:write`), `authMiddleware`, `encryptSecret` / `decryptSecret` (AES-256-GCM via `INTEGRATION_ENCRYPTION_KEY`), `timingSafeEqual`, `rateLimit`, `generateOpaqueToken`, `buildPaginated`, `PaginatedResponse`, `Pagination`.
- A canonical event catalog module (`WEBHOOK_EVENT_CATALOG`) transcribes the full event list from `white-label-api` and is the single source for the subscription UI, the test dropdown, and `GET /api/webhooks/events`.
- Queue consumer filter logic (`isEndpointSubscribed`) gates delivery per endpoint: empty `events` array = subscribe to all; non-empty = membership filter.
- UI is in the Vite+React `app` under `/settings/integrations/webhooks`, using `@zync/ui` (`DataTable`, `Sheet`, `Dialog`, `Button`, `Switch`, `Checkbox`, `Badge`, `toast`) and React Query hooks. RTL/Hebrew via `useDirection`; reduced-motion respected on any reveal/timer animation.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers), Drizzle ORM against Neon Postgres via Hyperdrive.
- **DB package:** `@zync/db` (Drizzle schema + tenant-scoped query helpers).
- **UI:** `apps/zync-app` (Vite + React), `@zync/ui` components, `@tanstack/react-query`.
- **Shared types:** `@zync/types`.
- **Cloudflare bindings:** `QUEUE` (`webhook.deliver` producer/consumer), `RATE_LIMITER_WEBHOOK` (inbound), Hyperdrive `DB`. Secret: `INTEGRATION_ENCRYPTION_KEY` (existing).
- **Cron:** `webhook-log-retention` (daily) registered in `apps/zync-api` wrangler config + scheduled handler.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| 11a (schema) | 1, 2 | `@zync/db` schema + migration | No (foundation for all) |
| 11b (shared) | 3, 4 | `@zync/types`, event catalog, db query helpers | Yes (after 11a) |
| 11c (api) | 5, 6, 7, 8 | `apps/zync-api` routes, queue consumer, inbound security, cron | Partially (5/6/7/8 share router but distinct handlers) |
| 11d (ui) | 9, 10, 11, 12 | `apps/zync-app` pages, hooks, components | Partially (after 11b/11c types stabilize) |
| 11e (tests) | 13 | api + app test files | Yes (after 11c/11d) |

## Tasks

### Task 1: DB schema — apply foundation delta + new inbound log table + indexes
**Blocks:** 3, 4, 5, 6, 7, 8  ·  **Blocked by:** —
**Files:**
- Modify: `packages/db/src/schema/webhooks.ts` (extend upstream `webhook_endpoints` / `webhook_deliveries` schema definitions from `white-label-api`)
- Create: `packages/db/migrations/<timestamp>_webhook_endpoint_detail.sql`
**Steps:**
- [ ] Consume `webhook_deliveries.status` (4-state `pending|delivered|failed|test`, owned by white-label-api spec 27); do NOT re-add the column or its CHECK.
- [ ] Add a GIN index on `webhook_endpoints.events` to support contains-filtering in the queue consumer.
- [ ] Add a composite index on `webhook_deliveries (endpoint_id, created_at DESC)` for the paginated delivery log.
- [ ] Create the new `webhook_delivery_log` table for INBOUND external webhook forensic records.
- [ ] Reflect the new table + indexes in the Drizzle schema module and export them.
**Schema / Interfaces:**
```sql
-- webhook_deliveries.status (4-state: pending|delivered|failed|test, DEFAULT 'pending')
-- is owned by white-label-api (spec 27) in its webhook_deliveries CREATE — consumed here,
-- NOT re-added. This module adds only the read-side indexes and the inbound forensic table.

-- Indexes for filter + pagination:
CREATE INDEX IF NOT EXISTS webhook_endpoints_events_gin
  ON webhook_endpoints USING GIN (events);
CREATE INDEX IF NOT EXISTS webhook_deliveries_endpoint_created_idx
  ON webhook_deliveries (endpoint_id, created_at DESC);

-- New table: inbound external webhook forensic log (spec 105 §Inbound Webhook Security).
-- Distinct from the OUTBOUND webhook_deliveries table.
CREATE TABLE webhook_delivery_log (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID REFERENCES tenants(id),            -- nullable: pre-auth inbound may not resolve a tenant
  endpoint_id     UUID REFERENCES webhook_endpoints(id),  -- nullable: inbound may not map to a known endpoint
  source          TEXT NOT NULL,                          -- inbound source identifier (e.g. 'payment-provider', path segment)
  source_ip       TEXT,
  event_type      TEXT,
  status          TEXT NOT NULL
                    CHECK (status IN ('accepted', 'rejected_invalid_signature', 'rejected_rate_limited')),
  signature_valid BOOLEAN NOT NULL DEFAULT false,
  raw_body        TEXT,                                   -- truncated to first 10 KB, stored regardless of signature outcome
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX webhook_delivery_log_tenant_created_idx
  ON webhook_delivery_log (tenant_id, created_at DESC);
```
**Acceptance:**
- [ ] Migration runs cleanly twice in a row (idempotent) against a Neon branch.
- [ ] `webhook_deliveries.status` accepts `pending`/`delivered`/`failed`/`test` and rejects any other value.
- [ ] `webhook_delivery_log` exists with the CHECK on `status` and the tenant/created index.
- [ ] GIN index on `webhook_endpoints.events` is present (`\d webhook_endpoints`).

### Task 2: Register retention cron + inbound rate limiter wiring in wrangler config
**Blocks:** 8  ·  **Blocked by:** —
**Files:**
- Modify: `apps/zync-api/wrangler.toml`
**Steps:**
- [ ] Add a daily cron trigger `0 3 * * *` and route it to the `webhook-log-retention` job in the scheduled handler (Task 8).
- [ ] Confirm the `RATE_LIMITER_WEBHOOK` binding already exists (declared by `system-communications-notifications`); do NOT create a new binding — reference the existing one.
- [ ] Confirm the `webhook.deliver` queue producer + consumer bindings exist (declared by `white-label-api`); reference them, do not redeclare.
**Schema / Interfaces:**
```toml
# apps/zync-api/wrangler.toml — add to existing [triggers] crons list (do not remove existing entries):
[triggers]
crons = [
  # ... existing crons ...
  "0 3 * * *",   # webhook-log-retention (daily 03:00 UTC)
]
```
**Acceptance:**
- [ ] `wrangler.toml` lists the daily cron; existing crons untouched.
- [ ] No duplicate `RATE_LIMITER_WEBHOOK` or `webhook.deliver` binding is introduced.

### Task 3: Canonical webhook event catalog module
**Blocks:** 5, 7, 9, 10  ·  **Blocked by:** 1
**Files:**
- Create: `packages/types/src/webhooks/event-catalog.ts`
- Modify: `packages/types/src/index.ts` (re-export)
**Steps:**
- [ ] Transcribe the full event catalog from `white-label-api` into a typed, category-grouped constant.
- [ ] Provide a flat list of all event-type strings (`WEBHOOK_EVENT_TYPES`) and a typed union (`WebhookEventType`).
- [ ] Provide per-event metadata (`description`, `payloadShape`) so `GET /api/webhooks/events` and the UI dropdown share one source.
- [ ] Export everything from the package root.
**Schema / Interfaces:**
```ts
export type WebhookEventCategory =
  | 'Time' | 'CRM' | 'Projects' | 'Tasks' | 'Support'
  | 'Finance' | 'Expenses' | 'Payouts' | 'Billing' | 'Users'
  | 'Tenant' | 'Calendar';

export interface WebhookEventDef {
  type: string;            // e.g. 'invoice.paid'
  category: WebhookEventCategory;
  description: string;
  payloadShape: Record<string, string>; // field -> type label, for catalog discovery
}

export const WEBHOOK_EVENT_CATALOG: readonly WebhookEventDef[] = [
  // Time
  { type: 'timer.started',    category: 'Time',     description: 'A time entry timer was started',  payloadShape: { timeEntryId: 'uuid', taskId: 'uuid', startedAt: 'iso8601' } },
  { type: 'timer.stopped',    category: 'Time',     description: 'A time entry timer was stopped',  payloadShape: { timeEntryId: 'uuid', durationSeconds: 'number', stoppedAt: 'iso8601' } },
  { type: 'timer.auto_paused',category: 'Time',     description: 'A timer was auto-paused on idle', payloadShape: { timeEntryId: 'uuid', pausedAt: 'iso8601' } },
  // CRM
  { type: 'lead.created',         category: 'CRM',  description: 'A lead was created',           payloadShape: { leadId: 'uuid', source: 'string', createdAt: 'iso8601' } },
  { type: 'lead.stage_updated',   category: 'CRM',  description: 'A lead changed pipeline stage', payloadShape: { leadId: 'uuid', fromStage: 'string', toStage: 'string' } },
  { type: 'proposal.viewed',      category: 'CRM',  description: 'A proposal was viewed',        payloadShape: { proposalId: 'uuid', viewedAt: 'iso8601' } },
  { type: 'proposal.accepted',    category: 'CRM',  description: 'A proposal was accepted',      payloadShape: { proposalId: 'uuid', acceptedAt: 'iso8601' } },
  // Projects
  { type: 'project.created',        category: 'Projects', description: 'A project was created',        payloadShape: { projectId: 'uuid', name: 'string' } },
  { type: 'project.status_changed', category: 'Projects', description: 'A project status changed',      payloadShape: { projectId: 'uuid', status: 'string' } },
  // Tasks
  { type: 'task.created',   category: 'Tasks', description: 'A task was created',   payloadShape: { taskId: 'uuid', title: 'string' } },
  { type: 'task.assigned',  category: 'Tasks', description: 'A task was assigned',  payloadShape: { taskId: 'uuid', assigneeId: 'uuid' } },
  { type: 'task.completed', category: 'Tasks', description: 'A task was completed', payloadShape: { taskId: 'uuid', completedAt: 'iso8601' } },
  // Support
  { type: 'ticket.created',  category: 'Support', description: 'A support ticket was created',  payloadShape: { ticketId: 'uuid', subject: 'string' } },
  { type: 'ticket.replied',  category: 'Support', description: 'A ticket received a reply',     payloadShape: { ticketId: 'uuid', replyId: 'uuid' } },
  { type: 'ticket.resolved', category: 'Support', description: 'A ticket was resolved',         payloadShape: { ticketId: 'uuid', resolvedAt: 'iso8601' } },
  // Finance
  { type: 'invoice.proforma_approved', category: 'Finance', description: 'A proforma invoice was approved', payloadShape: { invoiceId: 'uuid', approvedAt: 'iso8601' } },
  { type: 'invoice.issued',  category: 'Finance', description: 'An invoice was issued',  payloadShape: { invoiceId: 'uuid', issuedAt: 'iso8601' } },
  { type: 'invoice.paid',    category: 'Finance', description: 'An invoice was paid',    payloadShape: { invoiceId: 'uuid', paidAt: 'iso8601', amountPaid: 'number' } },
  { type: 'invoice.overdue', category: 'Finance', description: 'An invoice became overdue', payloadShape: { invoiceId: 'uuid', dueAt: 'iso8601' } },
  { type: 'retainer.depleted', category: 'Finance', description: 'A retainer balance was depleted', payloadShape: { retainerId: 'uuid', depletedAt: 'iso8601' } },
  // Expenses
  { type: 'expense.submitted', category: 'Expenses', description: 'An expense was submitted', payloadShape: { expenseId: 'uuid', amount: 'number' } },
  { type: 'expense.approved',  category: 'Expenses', description: 'An expense was approved',  payloadShape: { expenseId: 'uuid', approvedAt: 'iso8601' } },
  // Payouts
  { type: 'payout.generated', category: 'Payouts', description: 'A contractor payout was generated', payloadShape: { payoutId: 'uuid', amount: 'number' } },
  // Billing
  { type: 'payment.completed', category: 'Billing', description: 'A payment completed', payloadShape: { paymentId: 'uuid', amount: 'number' } },
  { type: 'payment.failed',    category: 'Billing', description: 'A payment failed',    payloadShape: { paymentId: 'uuid', reason: 'string' } },
  // Users
  { type: 'user.invited', category: 'Users', description: 'A user was invited',  payloadShape: { invitationId: 'uuid', email: 'string' } },
  { type: 'role.updated', category: 'Users', description: 'A user role changed', payloadShape: { userId: 'uuid', role: 'string' } },
  // Tenant
  { type: 'tenant.provisioned', category: 'Tenant', description: 'A tenant was provisioned', payloadShape: { tenantId: 'uuid', provisionedAt: 'iso8601' } },
  // Calendar
  { type: 'calendar.booking_created', category: 'Calendar', description: 'A calendar booking was created', payloadShape: { bookingId: 'uuid', startAt: 'iso8601' } },
] as const;

export const WEBHOOK_EVENT_TYPES: readonly string[] =
  WEBHOOK_EVENT_CATALOG.map((e) => e.type);

export type WebhookEventType = (typeof WEBHOOK_EVENT_CATALOG)[number]['type'];
```
**Acceptance:**
- [ ] `WEBHOOK_EVENT_TYPES` contains exactly the event strings from the `white-label-api` catalog (29 events).
- [ ] Module exported from `@zync/types` root; importable as `import { WEBHOOK_EVENT_CATALOG } from '@zync/types'`.

### Task 4: DB query helpers for endpoints, deliveries, and inbound log
**Blocks:** 5, 6, 7, 8  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/queries/webhooks.ts`
- Modify: `packages/db/src/index.ts` (re-export)
**Steps:**
- [ ] Implement tenant-scoped query helpers using `tenantQuery` (never raw Drizzle from routes — enforced by repo lint `no-raw-drizzle-from-routes`).
- [ ] `listWebhookEndpoints(tenantId)` — endpoints with delivery summary (count + success rate aggregated from `webhook_deliveries`).
- [ ] `getWebhookEndpoint(tenantId, id)` — single endpoint; throws/returns null if not in tenant.
- [ ] `createWebhookEndpoint(tenantId, { url, events, encryptedSecret })` — insert; default `events = '{}'`.
- [ ] `updateWebhookEndpoint(tenantId, id, { url?, events?, is_active? })` — partial update + `updated_at = now()`.
- [ ] `deleteWebhookEndpoint(tenantId, id)`.
- [ ] `rotateWebhookSecret(tenantId, id, encryptedSecret)` — update `secret` + `updated_at`.
- [ ] `listWebhookDeliveries(tenantId, endpointId, { status?, from?, to?, page?, pageSize })` — paginated, ordered `created_at DESC`, using `buildPaginated`.
- [ ] `getWebhookDelivery(tenantId, endpointId, deliveryId)`.
- [ ] `insertWebhookDelivery(...)` and `requeueWebhookDelivery(tenantId, endpointId, deliveryId)` — set `status='pending'`, clear `next_retry_at` (immediate retry, bypasses backoff).
- [ ] `recordInboundWebhook({ tenantId, endpointId, source, sourceIp, eventType, status, signatureValid, rawBody })` — insert into `webhook_delivery_log`, truncating `rawBody` to 10 KB.
- [ ] `isEndpointSubscribed(endpoint, eventType)` — pure helper: `endpoint.events.length === 0 || endpoint.events.includes(eventType)`.
- [ ] `purgeExpiredWebhookDeliveries()` — delete `webhook_deliveries` where `created_at < now() - interval '30 days'`.
**Schema / Interfaces:**
```ts
export interface WebhookEndpointRow {
  id: string; tenantId: string; url: string;
  events: string[]; isActive: boolean;
  createdAt: string; updatedAt: string;
  // secret intentionally omitted from list/detail serializers
}
export interface WebhookEndpointSummary extends WebhookEndpointRow {
  deliveryCount: number;
  successRate: number; // 0..1
}
export interface WebhookDeliveryRow {
  id: string; tenantId: string; endpointId: string;
  eventType: string; payload: unknown;
  status: 'pending' | 'delivered' | 'failed' | 'test';
  responseStatus: number | null; responseBody: string | null;
  attempt: number; deliveredAt: string | null;
  nextRetryAt: string | null; createdAt: string;
}
export function isEndpointSubscribed(
  endpoint: Pick<WebhookEndpointRow, 'events'>, eventType: string,
): boolean {
  return endpoint.events.length === 0 || endpoint.events.includes(eventType);
}
export function listWebhookDeliveries(
  tenantId: string, endpointId: string,
  opts: { status?: string; from?: string; to?: string; page?: number; pageSize?: number },
): Promise<PaginatedResponse<WebhookDeliveryRow>>;
export function purgeExpiredWebhookDeliveries(): Promise<{ deleted: number }>;
```
**Acceptance:**
- [ ] All helpers tenant-scope via `tenantQuery`; no helper accepts cross-tenant ids without `tenant_id` filter.
- [ ] `isEndpointSubscribed` returns true for empty `events` regardless of `eventType`.
- [ ] `requeueWebhookDelivery` sets `status='pending'` and `next_retry_at=NULL`.
- [ ] `purgeExpiredWebhookDeliveries` deletes only rows older than 30 days.
- [ ] `recordInboundWebhook` truncates `rawBody` to ≤ 10 KB before insert.

### Task 5: Webhook endpoints CRUD + secret routes
**Blocks:** 9, 10, 13  ·  **Blocked by:** 3, 4
**Files:**
- Create: `apps/zync-api/src/routes/webhooks.ts`
- Modify: `apps/zync-api/src/routes/index.ts` (mount router under `/api/webhooks`)
**Steps:**
- [ ] Mount router with `authMiddleware`; apply `requirePermission('settings:read')` on GETs and `requirePermission('settings:write')` on mutations.
- [ ] Validate all bodies/queries with zod (`require-zod-validation-in-routes`); reject extra/invalid fields.
- [ ] `GET /api/webhooks` → `listWebhookEndpoints` (serialized without secret).
- [ ] `POST /api/webhooks` → generate HMAC secret via `generateOpaqueToken`, encrypt via `encryptSecret` (AES-256-GCM, `INTEGRATION_ENCRYPTION_KEY`), store, return `{ id, url, secret }` with the plaintext secret ONCE; require HTTPS URL.
- [ ] `GET /api/webhooks/:id` → endpoint detail + recent delivery summary (no secret).
- [ ] `PATCH /api/webhooks/:id` → update `url`, `events` (empty array = all events), `is_active`.
- [ ] `DELETE /api/webhooks/:id`.
- [ ] `POST /api/webhooks/:id/rotate-secret` → OWNER role required in addition to `settings:write`; generate + encrypt new secret, return plaintext ONCE.
- [ ] `GET /api/webhooks/:id/secret` → reveal: re-auth gate (confirm password or 2FA if `enforce_2fa`); decrypt via `decryptSecret`; return plaintext (client masks after 30s).
- [ ] `GET /api/webhooks/events` → return `WEBHOOK_EVENT_CATALOG` (tenant-auth; reflects available events).
**Schema / Interfaces:**
```ts
// zod
const createWebhookSchema = z.object({
  url: z.string().url().startsWith('https://'),
  events: z.array(z.string()).optional().default([]),
});
const updateWebhookSchema = z.object({
  url: z.string().url().startsWith('https://').optional(),
  events: z.array(z.string()).optional(),     // [] = all events
  is_active: z.boolean().optional(),
});
const revealSecretSchema = z.object({
  password: z.string().optional(),
  totp_code: z.string().length(6).optional(),
}).refine((v) => v.password || v.totp_code, { message: 're_auth_required' });

// Routes (exact paths):
// GET    /api/webhooks
// POST   /api/webhooks                       -> { id, url, secret }
// GET    /api/webhooks/:id
// PATCH  /api/webhooks/:id
// DELETE /api/webhooks/:id
// POST   /api/webhooks/:id/rotate-secret     -> { secret }  (OWNER only)
// GET    /api/webhooks/:id/secret            -> { secret }  (re-auth gated, 30s client TTL)
// GET    /api/webhooks/events                -> WebhookEventDef[]
```
**Acceptance:**
- [ ] Secret returned in plaintext only on create + rotate + reveal; never in list/detail.
- [ ] Non-HTTPS URL is rejected with 400.
- [ ] `rotate-secret` returns 403 for non-OWNER even with `settings:write`.
- [ ] `GET /api/webhooks/:id/secret` returns 401/403 without valid password or TOTP.
- [ ] All routes scoped to caller tenant; cross-tenant id returns 404.

### Task 6: Delivery log routes (list, detail, retry)
**Blocks:** 11, 13  ·  **Blocked by:** 4
**Files:**
- Modify: `apps/zync-api/src/routes/webhooks.ts`
**Steps:**
- [ ] `GET /api/webhooks/:id/deliveries` → `requirePermission('settings:read')`; paginated via `listWebhookDeliveries`; zod-validate `status`, `from`, `to`, `page`.
- [ ] `GET /api/webhooks/:id/deliveries/:deliveryId` → single delivery for the detail panel (payload, response body, computed signature header preview).
- [ ] `POST /api/webhooks/:id/deliveries/:deliveryId/retry` → `requirePermission('settings:write')`; only allowed for `status='failed'`; call `requeueWebhookDelivery` then enqueue `webhook.deliver` job immediately (bypass `next_retry_at`).
**Schema / Interfaces:**
```ts
const deliveryListQuery = z.object({
  status: z.enum(['pending', 'delivered', 'failed', 'test']).optional(),
  from: z.string().datetime().optional(),
  to: z.string().datetime().optional(),
  page: z.coerce.number().int().min(1).optional().default(1),
});
// GET  /api/webhooks/:id/deliveries                       -> PaginatedResponse<WebhookDeliveryRow>
// GET  /api/webhooks/:id/deliveries/:deliveryId           -> WebhookDeliveryRow (+ signatureHeaderPreview)
// POST /api/webhooks/:id/deliveries/:deliveryId/retry     -> { status: 'pending' }
```
**Acceptance:**
- [ ] Retry on a non-`failed` delivery returns 409.
- [ ] Retry sets delivery `status='pending'`, `next_retry_at=NULL`, and enqueues a `webhook.deliver` job.
- [ ] Delivery list is paginated and filterable by status/date; ordered newest first.

### Task 7: Test dispatch route + queue-consumer subscription filter
**Blocks:** 10, 13  ·  **Blocked by:** 3, 4
**Files:**
- Modify: `apps/zync-api/src/routes/webhooks.ts`
- Modify: `apps/zync-api/src/queue/webhook-deliver.ts` (the `webhook.deliver` consumer from `white-label-api`)
**Steps:**
- [ ] `POST /api/webhooks/:id/test` → `requirePermission('settings:write')`; zod-validate `event_type ∈ WEBHOOK_EVENT_TYPES`; build a representative test payload from the catalog `payloadShape`; sign with the endpoint's decrypted secret; POST synchronously; insert a `webhook_deliveries` row with `status='test'`; return `{ status, response_status, response_body, latency_ms }`.
- [ ] In the `webhook.deliver` consumer, before HTTP dispatch call `isEndpointSubscribed(endpoint, event.type)`; if false, skip delivery and write NO delivery-log row (per spec).
- [ ] Ensure the consumer computes `X-Zync-Signature: sha256=HMAC(secret, "${timestamp}.${body}")`, `X-Zync-Timestamp`, `X-Zync-Event`, `X-Zync-Delivery` (timestamp injected at delivery time, included in HMAC) — preserve upstream `white-label-api` header contract.
**Schema / Interfaces:**
```ts
const testDispatchSchema = z.object({
  event_type: z.string().refine((t) => WEBHOOK_EVENT_TYPES.includes(t), {
    message: 'unknown_event_type',
  }),
});
// POST /api/webhooks/:id/test
//   -> { status: 'test', response_status: number | null, response_body: string | null, latency_ms: number }

// consumer gate (spec 105 §Delivery Filter Logic):
const endpoint = await getWebhookEndpoint(job.tenantId, job.endpointId);
if (!isEndpointSubscribed(endpoint, job.eventType)) return; // skip, no log entry
```
**Acceptance:**
- [ ] Test dispatch writes a `status='test'` row and returns latency + response.
- [ ] Consumer skips (no HTTP, no log row) when endpoint not subscribed; delivers when `events` is empty or includes the type.
- [ ] Signature header binds timestamp + body; timestamp injected at delivery time.

### Task 8: Inbound webhook security + retention cron
**Blocks:** 13  ·  **Blocked by:** 1, 2, 4
**Files:**
- Modify: `apps/zync-api/src/middleware/inbound-webhook.ts` (or create if absent)
- Modify: `apps/zync-api/src/scheduled.ts` (cron dispatch)
**Steps:**
- [ ] Apply the existing `RATE_LIMITER_WEBHOOK` binding to all inbound `POST /api/webhooks/*` external receiver endpoints via `rateLimit` (reuse binding; do not create a new one).
- [ ] On inbound delivery: store truncated raw body (first 10 KB) via `recordInboundWebhook` REGARDLESS of signature outcome (body-logging invariant).
- [ ] Verify HMAC with `timingSafeEqual` (no string `===` — enforced by `no-string-equality-for-tokens`).
- [ ] On invalid HMAC: respond `200 OK` with empty body (never 401/403 — avoids timing/oracle leak); log row with `status='rejected_invalid_signature'`, `signature_valid=false`, `source_ip`.
- [ ] On valid HMAC: log row with `status='accepted'`, `signature_valid=true`.
- [ ] On rate-limit rejection: log `status='rejected_rate_limited'`.
- [ ] Register `webhook-log-retention` in the scheduled handler keyed to the daily cron; call `purgeExpiredWebhookDeliveries()`.
**Schema / Interfaces:**
```ts
// Inbound verification (security-critical):
const valid = timingSafeEqual(providedSig, expectedSig);
await recordInboundWebhook({
  tenantId, endpointId, source, sourceIp,
  eventType, signatureValid: valid,
  status: valid ? 'accepted' : 'rejected_invalid_signature',
  rawBody: rawBody.slice(0, 10 * 1024),
});
if (!valid) return new Response(null, { status: 200 }); // empty 200, no leak

// Scheduled (apps/zync-api/src/scheduled.ts):
case '0 3 * * *': // webhook-log-retention
  await purgeExpiredWebhookDeliveries();
  break;
```
**Acceptance:**
- [ ] Invalid signature → HTTP 200 empty body; a `rejected_invalid_signature` log row with `source_ip` and truncated body exists.
- [ ] Raw body is logged even when signature is invalid; truncated to ≤ 10 KB.
- [ ] HMAC compared with `timingSafeEqual` (no `===`).
- [ ] Inbound endpoints are rate-limited by `RATE_LIMITER_WEBHOOK`.
- [ ] Daily cron purges deliveries older than 30 days.

### Task 9: React Query hooks + API client for webhooks
**Blocks:** 10, 11, 12  ·  **Blocked by:** 5, 6
**Files:**
- Create: `apps/zync-app/src/features/webhooks/api.ts`
- Create: `apps/zync-app/src/features/webhooks/hooks.ts`
**Steps:**
- [ ] Typed fetch wrappers for every route in Tasks 5–7 (list/create/get/update/delete endpoints; reveal/rotate secret; deliveries list/detail/retry; test dispatch; events catalog).
- [ ] React Query hooks: `useWebhookEndpoints`, `useWebhookEndpoint(id)`, `useWebhookEvents`, `useWebhookDeliveries(id, filters)`, `useWebhookDelivery(id, deliveryId)`.
- [ ] Mutation hooks: `useCreateWebhook`, `useUpdateWebhook`, `useDeleteWebhook`, `useRotateWebhookSecret`, `useRevealWebhookSecret`, `useTestWebhook`, `useRetryDelivery`; invalidate the relevant query keys on success and surface `toast`.
**Schema / Interfaces:**
```ts
export function useWebhookEndpoints(): UseQueryResult<WebhookEndpointSummary[]>;
export function useWebhookEndpoint(id: string): UseQueryResult<WebhookEndpointRow>;
export function useWebhookDeliveries(
  id: string, filters: { status?: string; from?: string; to?: string; page?: number },
): UseQueryResult<PaginatedResponse<WebhookDeliveryRow>>;
export function useRevealWebhookSecret(id: string): UseMutationResult<{ secret: string }>;
export function useTestWebhook(id: string): UseMutationResult<
  { status: string; response_status: number | null; response_body: string | null; latency_ms: number }
>;
```
**Acceptance:**
- [ ] Every API route has a corresponding typed client function and hook.
- [ ] Mutations invalidate endpoint/delivery query keys and toast on success/failure.

### Task 10: Webhook list page + create/edit modal
**Blocks:** —  ·  **Blocked by:** 3, 9
**Files:**
- Create: `apps/zync-app/src/features/webhooks/WebhookListPage.tsx`
- Create: `apps/zync-app/src/features/webhooks/WebhookFormDialog.tsx`
- Modify: `apps/zync-app/src/routes.tsx` (route `/settings/integrations/webhooks`)
**Steps:**
- [ ] List page rendering each endpoint: URL, status `Badge`, subscribed event count (or "All events"), success rate; "View details →" link to `/settings/integrations/webhooks/:id`.
- [ ] `[+ New webhook]` opens `WebhookFormDialog`: URL field + event-subscription radio ("All events" vs "Selected events only" grouped checkboxes from `WEBHOOK_EVENT_CATALOG`).
- [ ] On create success: show the one-time secret banner ("Copy your secret — it won't be shown again.") with copy button.
- [ ] Edit reuses the same dialog (PATCH); URL + events + active toggle.
- [ ] Use `@zync/ui` `Dialog`, `Button`, `Switch`, `Checkbox`, `Badge`, `Input`; honor RTL via `useDirection`; respect `prefers-reduced-motion` on dialog transitions.
**Acceptance:**
- [ ] List shows "All events" when `events` is empty, else the event count.
- [ ] Create flow shows the secret exactly once in a copy banner.
- [ ] Form rejects non-HTTPS URL client-side before submit.
- [ ] Layout mirrors correctly in RTL; checkbox groups have accessible `aria` labels/fieldset legends per category.

### Task 11: Endpoint detail page (config, subscriptions, secret, deliveries)
**Blocks:** —  ·  **Blocked by:** 3, 9, 12
**Files:**
- Create: `apps/zync-app/src/features/webhooks/WebhookDetailPage.tsx`
- Create: `apps/zync-app/src/features/webhooks/EventSubscriptionEditor.tsx`
- Create: `apps/zync-app/src/features/webhooks/SecretReveal.tsx`
- Modify: `apps/zync-app/src/routes.tsx` (route `/settings/integrations/webhooks/:id`)
**Steps:**
- [ ] Header: URL, status `Badge`, `[Edit]` (opens `WebhookFormDialog`), back link to list.
- [ ] Config block: URL, masked secret with `[Reveal]` and `[Rotate secret]`, created date.
- [ ] `SecretReveal`: `[Reveal]` triggers re-auth prompt (password or TOTP), calls `useRevealWebhookSecret`, shows secret for a 30-second client-side countdown then re-masks. `[Rotate secret]` confirms, calls `useRotateWebhookSecret`, shows new secret once with the "Update your receiver verification code after rotating." warning. Respect `prefers-reduced-motion` on the countdown indicator.
- [ ] `EventSubscriptionEditor`: radio "All events" (saves `events: []`) vs "Selected events only" (grouped checkboxes by category from `WEBHOOK_EVENT_CATALOG`); `[Save subscriptions]` PATCHes `{ events }`.
- [ ] Deliveries section embeds the delivery log table (Task 12) + `[Send test event ▾]` dropdown (Task 12).
**Acceptance:**
- [ ] `[Reveal]` requires re-auth and auto-re-masks after 30s.
- [ ] `[Rotate secret]` shows new secret once with the rotation warning.
- [ ] Selecting "All events" persists `events: []`; selecting specific events persists the chosen list.
- [ ] Page is keyboard-navigable; secret value is not rendered in the DOM until revealed.

### Task 12: Delivery log table, detail panel, test dispatch dropdown
**Blocks:** 11  ·  **Blocked by:** 6, 9
**Files:**
- Create: `apps/zync-app/src/features/webhooks/DeliveryLogTable.tsx`
- Create: `apps/zync-app/src/features/webhooks/DeliveryDetailSheet.tsx`
- Create: `apps/zync-app/src/features/webhooks/TestDispatchMenu.tsx`
**Steps:**
- [ ] `DeliveryLogTable` via `@zync/ui` `DataTable` with `DataTablePagination`: columns time, event type, status icon + response status, latency, action. `[Details]` on success rows opens `DeliveryDetailSheet`; `[Retry]` on `failed` rows calls `useRetryDelivery`. Filter controls for status/date. "Show all N deliveries →" link.
- [ ] `DeliveryDetailSheet` (`Sheet`): event, time (UTC), status (response code + latency), attempt "X of N", pretty-printed request payload, response body, and the `X-Zync-Signature: sha256=...` header preview. Payload/body rendered as escaped text (no `dangerouslySetInnerHTML`; `no-raw-html-in-pages`).
- [ ] `TestDispatchMenu` (`DropdownMenu`): lists all event types from `useWebhookEvents`; selecting one calls `useTestWebhook` and surfaces the result in a `toast`.
**Acceptance:**
- [ ] Failed rows show `[Retry]`; clicking re-queues and the row transitions to `pending`/`delivered` on refetch.
- [ ] Detail sheet shows payload, response body, attempt count, and signature header.
- [ ] Test dropdown lists all catalog events and toasts the dispatch result (status + latency).
- [ ] No raw HTML injection; payload rendered as escaped text.

### Task 13: Tests — API contract, subscription filter, inbound security, retention
**Blocks:** —  ·  **Blocked by:** 5, 6, 7, 8
**Files:**
- Create: `apps/zync-api/test/webhooks.routes.test.ts`
- Create: `apps/zync-api/test/webhooks.inbound.test.ts`
- Create: `packages/db/test/webhooks.queries.test.ts`
**Steps:**
- [ ] CRUD: create returns secret once; list/detail never include secret; non-HTTPS rejected; cross-tenant id → 404.
- [ ] Permissions: `settings:read` vs `settings:write` enforced; `rotate-secret` requires OWNER; reveal requires re-auth.
- [ ] Subscription filter: `isEndpointSubscribed` true for empty `events`; consumer skips non-subscribed without writing a log row.
- [ ] Delivery log: pagination + status/date filters; retry only on `failed`, re-queues with `next_retry_at=NULL`.
- [ ] Test dispatch: writes `status='test'` row, returns latency.
- [ ] Inbound: invalid HMAC → 200 empty body + `rejected_invalid_signature` log row with truncated body and `source_ip`; valid HMAC compared via `timingSafeEqual`; rate limiter applied.
- [ ] Retention: `purgeExpiredWebhookDeliveries` deletes only > 30-day rows.
**Acceptance:**
- [ ] All tests pass against a Neon test branch.
- [ ] Inbound security tests assert HTTP 200 (not 401/403) on bad signature and presence of the forensic log row.
