# Marketing — Leads & Pipeline

**Date:** 2026-05-30  
**Status:** Draft  
**Tier:** Business+ (lead forms, inbound webhooks, public form page require Business+; pipeline Kanban and manual lead creation available to all tiers)  
**Depends on:** `foundation-auth-rbac`, `customers-module`, `projects-module`, `invoices-core`, `calendar-module`, `system-communications-notifications`, `contracts-esignature`  
**Referenced by:** `marketing-catalogs-campaigns`, `reports-analytics`, `white-label-api`, `search-completeness`

---

## Overview

Lead capture, tracking, and conversion. Kanban pipeline for leads from first contact through to won. Embeddable white-label lead forms (Business+). Inbound webhooks from Facebook Lead Ads, Google Ads, Zapier, and Make (Business+). Won leads convert directly to customers + optional project. Webhook events emitted on stage changes. Leads appear in global search.

### Mobile route behavior

At phone widths, Marketing overview KPI cards may shrink within the app frame rather than forcing a minimum card width.

Rationale: preserve readable summary data without horizontal overflow.

---

## Data Model

```sql
leads (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  email TEXT,
  phone TEXT,
  company TEXT,
  notes TEXT,
  stage TEXT DEFAULT 'NEW',              -- 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'PROPOSAL' | 'WON' | 'LOST'
  stage_position NUMERIC NOT NULL,       -- fractional index within stage column
  source TEXT DEFAULT 'manual',          -- 'manual' | 'form' | 'webhook' | 'facebook' | 'google' | 'linkedin' | 'instagram' | 'zapier' | 'make' | 'referral' | 'cold_outreach'
  source_metadata JSONB,                 -- original webhook/form payload
  assigned_to UUID REFERENCES users(id), -- nullable
  customer_id UUID REFERENCES customers(id), -- set when lead converted to customer
  contract_id UUID REFERENCES contracts(id), -- set when contract linked (spec 48)
  lost_reason TEXT,
  estimated_value NUMERIC,               -- optional deal value for pipeline value widget
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  utm_content TEXT,
  utm_term TEXT,
  archived_at TIMESTAMPTZ,               -- soft-delete
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
)

lead_activities (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  lead_id UUID NOT NULL REFERENCES leads(id),
  user_id UUID REFERENCES users(id),     -- NULL for system-generated activity
  type TEXT NOT NULL,                    -- 'note' | 'email_sent' | 'call_logged' | 'stage_changed' | 'form_submitted' | 'webhook_received' | 'converted' | 'contract_linked'
  content TEXT,
  metadata JSONB,
  created_at TIMESTAMPTZ DEFAULT now()
)

lead_forms (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  slug TEXT NOT NULL,                    -- used in embed URL: /f/{slug}
  fields JSONB NOT NULL,                 -- see Field Config shape below
  redirect_url TEXT,                     -- after submission; if null show inline success message
  notify_email TEXT,                     -- email to notify on new submission; defaults to tenant owner email
  is_active BOOLEAN DEFAULT true,
  style JSONB,                           -- { primaryColor: string, logoR2Key: string, fontFamily: string }
  created_by UUID NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  UNIQUE (tenant_id, slug)               -- slug unique per tenant, not globally
)

lead_form_submissions (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  form_id UUID NOT NULL REFERENCES lead_forms(id),
  lead_id UUID NOT NULL REFERENCES leads(id),
  payload JSONB NOT NULL,                -- raw form field values keyed by field id
  ip TEXT,
  user_agent TEXT,
  referrer TEXT,
  utm_source TEXT,
  utm_medium TEXT,
  utm_campaign TEXT,
  utm_content TEXT,
  utm_term TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
)

lead_webhooks (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  name TEXT NOT NULL,
  source TEXT NOT NULL,                  -- 'facebook' | 'google' | 'linkedin' | 'instagram' | 'zapier' | 'make' | 'generic'
  secret TEXT NOT NULL,                  -- HMAC signing secret; AES-256-GCM encrypted at rest via INTEGRATION_ENCRYPTION_KEY (decrypted at verification time)
  field_mapping JSONB,                   -- map inbound JSON paths → leads columns; see Field Mapping shape
  is_active BOOLEAN DEFAULT true,
  last_received_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now()
)
```

### Field Config shape (lead_forms.fields JSONB)

```ts
type FieldConfig = {
  id: string;           // stable UUID, used as key in submission payload
  type: 'text' | 'email' | 'phone' | 'textarea' | 'select' | 'checkbox';
  label: string;
  placeholder?: string;
  required: boolean;
  options?: string[];   // for type='select' only
};
// lead_forms.fields = FieldConfig[]  (ordered array; index = display order)
```

### Field Mapping shape (lead_webhooks.field_mapping JSONB)

```ts
type FieldMapping = {
  [jsonPath: string]: 'name' | 'email' | 'phone' | 'company' | 'notes';
  // e.g. { "entry[0].changes[0].value.leads_data[0].full_name": "name" }
};
```

---

## Pipeline Kanban (`/marketing/pipeline`)

```
┌────────────┬────────────┬─────────────┬────────────┬──────────┐
│   NEW      │ CONTACTED  │  QUALIFIED  │  PROPOSAL  │   WON    │
├────────────┼────────────┼─────────────┼────────────┼──────────┤
│ [Lead card]│ [Lead card]│ [Lead card] │            │          │
│ [Lead card]│            │             │            │          │
└────────────┴────────────┴─────────────┴────────────┴──────────┘
[Show lost leads] toggle (hidden by default)
```

**LOST column:** hidden by default. Shown via "Include lost" toggle in filter bar. Lost leads render with muted styling.

**Lead card:** name, company, stage age (e.g. "3d"), assigned user avatar, estimated value if set, source icon (form/webhook/manual).

**Drag-and-drop between columns.** Position tracked via fractional indexing (same pattern as tasks-board-engine: rebalance when gap < 0.001). Stage change:
1. Update `leads.stage` + `leads.stage_position` in same transaction
2. Log `lead_activities` entry (type='stage_changed', metadata={from, to})
3. Emit `lead.stage_updated` outbound webhook
4. If `assigned_to` set: send in-app notification to assignee on stage change

**+ New lead** button → sheet form: name, email, phone, company, estimated value, notes, assign to, initial stage. Source defaults to 'manual'.

**Filter bar (URL-synced):** stage (multi), source, assigned_to, date range, include lost toggle.

**View toggle:** Kanban (default) | List. Stored in `localStorage`.

### List View

Table: Name, Company, Stage, Source, Assigned, Value, Stage age, Created.  
Sortable by: Name, Stage, Created, Value.  
Bulk actions: assign, move to stage, archive.

### List Performance

Lead list uses **cursor-based pagination**:

```ts
// API: GET /api/leads?cursor={encodedCursor}&limit=50
interface LeadListResponse {
  items: Lead[]
  nextCursor: string | null
  total: number
}
```

**Virtual scroll (TanStack Virtual):** when list > 200 rows, activate. Row height: 64px. Overscan: 5 rows.

**Invariant:** list API max 100 rows per request. Bulk operations use the bulk-operations spec (spec 42), not the list endpoint.

### Lead Detail Side-Panel

Slide-in panel on card click (or list row click). Sections:

**Info tab:**
- Name, email, phone, company (all editable inline)
- Source badge + source_metadata expandable (raw webhook/form payload)
- UTM fields (editable, collapsed by default)
- Estimated value field
- Assigned to (user picker)
- Contract link (shows linked contract from `contract_id`; "Link contract" action if none)

**Activity tab:**
- Chronological feed of `lead_activities` (notes, emails, calls, stage changes, form submissions, webhook events, conversion)
- Add note: inline textarea → creates activity (type='note')
- Log call: duration picker, outcome text → creates activity (type='call_logged')
- Send email: opens compose sheet (system-communications-notifications); sent email logged as activity (type='email_sent')

**Stage actions (header):**
- "Move to [next stage]" quick button
- "Mark Won" → triggers convert-to-customer flow or sets stage=WON
- "Mark Lost" → prompt for `lost_reason` (text), sets stage=LOST

### Won Lead → Convert to Customer

"Convert to Customer" CTA appears when stage = 'WON' and `customer_id` is null.

Conversion flow:
1. Sheet opens: prefill customer name (lead.company ?: lead.name), email, phone
2. Optional: create linked project immediately (project name, type selector)
3. On submit:
   - `POST /api/customers` → creates customer record
   - Sets `leads.customer_id = newCustomerId` in same transaction
   - Optionally `POST /api/projects` if project step filled
   - Activity logged: type='converted', metadata={customerId, projectId?}
   - Lead stage stays 'WON' — preserved for analytics; not deleted
4. After conversion: CTA replaced by "View Customer →" link to `/customers/:id`

---

## Lead Forms (`/marketing/forms`)

**Business+ only.** Gated behind tier check; non-Business tenants see upgrade prompt.

Table: Name, Slug, Active toggle, Submissions (count), Created.

### Form Builder

Step-by-step builder (no drag-and-drop in V1 — field order = array index in `fields` JSONB):

- Field types: text, email, phone, textarea, select, checkbox
- Per-field: label, placeholder, required toggle
- Select fields: add options list
- Style: logo upload (R2, allowlist: JPG/PNG/WEBP only — no SVG, public-read bucket — form page is unauthenticated so no signed URLs), primary color hex, font family (system stack only in V1)
- Redirect URL after submit (optional; if empty show inline "Thanks" message)
- Notification email on submit (defaults to tenant owner email)

Preview pane: live preview of rendered form at right side.

Slug: auto-generated from form name (kebab-case), editable. Must be unique per tenant. Cannot be changed after first submission.

### Embed Code

Two options:

```html
<!-- Option 1: iframe embed -->
<iframe
  src="https://zync.is/f/{slug}?tenant={tenantSlug}"
  width="100%"
  height="600"
  frameborder="0"
></iframe>

<!-- Option 2: direct link (white-labeled if custom domain) -->
https://zync.is/f/{slug}?tenant={tenantSlug}
```

Copy-to-clipboard buttons for each. UTM params can be appended to either URL by the tenant's marketing team; the public handler strips and stores them.

### Public Form Page (`/f/{slug}?tenant={tenantSlug}`)

Rendered in `zync-www` (Astro + React island) — public, no auth. Deployed to `zync.is/f/…`. Submits to `POST /api/forms/{slug}?tenant={tenantSlug}` served same-origin under `zync.is` (route mapping owned by infra spec).

Layout:
```
┌─────────────────────────────────┐
│  [Tenant logo]                  │
│  [Form name / headline]         │
│                                 │
│  [Field 1]                      │
│  [Field 2]                      │
│  [Field N...]                   │
│                                 │
│  [Submit button]                │
│                                 │
│  Powered by Zync (white-label:  │
│  hidden if custom domain)       │
└─────────────────────────────────┘
```

- Styled via `lead_forms.style` (primary color, logo, font)
- "Powered by Zync" footer hidden for Enterprise tenants with custom domain
- On submit: `POST /api/forms/{slug}` — see handler below
- On success: redirect to `redirect_url` OR inline message "Thanks! We'll be in touch."
- On validation error: inline field errors, no page reload
- Fully responsive (mobile-first)
- No cookies, no session required

### Form Submission Handler

`POST /api/forms/{slug}?tenant={tenantSlug}` (no auth — public endpoint):

1. Look up `lead_forms` by slug + tenant_slug; 404 if not found or `is_active = false`
2. Validate required fields per `lead_forms.fields` config
3. Rate limit: `RATE_LIMITER_LEAD_FORM` (20 submissions/min per form, keyed by form id + IP)
4. Extract UTM params from query string (utm_source, utm_medium, utm_campaign, utm_content, utm_term)
5. Extract `referrer` from `Referer` header
5a. Extract `catalogShareId` from POST body if present (hidden field injected by spec 23's catalog page when `lead_form_id` is set on the share; absent for standalone form embeds)
6. Create `leads` record (`source = 'form'`, `source_metadata = { formId, formName, catalogShareId? }`)
7. Create `lead_form_submissions` record (all UTM fields)
8. Create `lead_activities` entry (type='form_submitted')
9. Emit `lead.created` outbound webhook
10. Emit CF AE event: `lead_captured` with `{ tenantId, formId, leadId, catalogShareId?, utmSource, utmMedium, utmCampaign }` — **form/webhook ingestion only; manual leads do NOT emit this event.** `catalogShareId` is non-null only when form is embedded in a catalog share (spec 23 passes it as a hidden query param when rendering `lead_form_id`). Spec 23's funnel filters step 2 on `catalogShareId IS NOT NULL` to scope to the catalog population.
11. Send notification email to `lead_forms.notify_email` via Resend (template: lead name, email, form name, link to pipeline)
12. Return `{ ok: true, redirectUrl }` (client redirects if redirectUrl set) or `{ ok: true }` for inline message

---

## Inbound Webhooks (`/marketing/webhooks`)

**Business+ only.** Gated same as forms.

Table: Name, Source, Active toggle, Last received (timestamp).

### Webhook Endpoint

`POST /api/webhooks/leads/{webhookId}` (no auth — HMAC verified):

1. Look up `lead_webhooks` by id; 404 if not found or `is_active = false`
2. Verify HMAC signature per source (see per-source rules below). Return 403 on mismatch.
3. Update `lead_webhooks.last_received_at = now()`
4. Apply `field_mapping` to map payload paths to leads columns
5. Create `leads` record (`source` from webhook config, `source_metadata = raw payload`)
6. Log `lead_activities` entry (type='webhook_received', metadata=payload)
7. Emit `lead.created` outbound webhook
8. Emit CF AE event: `lead_captured` with `{ tenantId, webhookId, leadId, source, utmSource, utmMedium, utmCampaign }` — no `catalogShareId` (webhook leads originate outside catalog funnel)
9. Return HTTP 200 `{ ok: true }`

On mapping error (required `name` field empty after mapping): create lead with name='(unknown)' + log warning in metadata.

### Facebook Webhook Verification (GET)

Facebook requires GET challenge before it will send POSTs.

`GET /api/webhooks/leads/{webhookId}?hub.mode=subscribe&hub.challenge={challenge}&hub.verify_token={token}`:
1. Look up webhook; decrypt `lead_webhooks.secret` (AES-256-GCM via `INTEGRATION_ENCRYPTION_KEY`); verify `hub.verify_token` matches decrypted secret
2. Return plaintext `hub.challenge` with HTTP 200

### Supported Sources

**Facebook Lead Ads:**
- HMAC verification: `X-Hub-Signature-256` header; SHA-256 HMAC of raw body using `lead_webhooks.secret`
- Payload path: `entry[0].changes[0].value.leads_data[0].field_data[]` (array of `{name, values}`)
- Default mapping: `full_name → name`, `email → email`, `phone_number → phone`
- Tenant configures field_mapping to override / add company/notes fields

**Google Ads Lead Form:**
- Verification: `google_key` field in JSON body (string); compare against `lead_webhooks.secret` (no HMAC header — Google posts shared secret in body)
- Payload: `user_column_data[]` (array of `{column_name, string_value}`)
- Default mapping: `FULL_NAME → name`, `EMAIL → email`, `PHONE_NUMBER → phone`

**Zapier / Make / Generic:**
- HMAC verification: optional (if `lead_webhooks.secret` non-empty, verify `X-Zync-Signature` header; else accept unsigned)
- Payload: arbitrary JSON; field_mapping required
- If no field_mapping configured: store raw payload in `source_metadata`, create lead with name='(from webhook)'

---

## Overview Page (`/marketing/overview`)

Default route for `/marketing` (redirects to `/marketing/overview`).

Summary widgets:
- **Leads this month** (count, vs last month delta)
- **Conversion rate** (WON ÷ (WON + LOST) for closed leads this month)
- **Pipeline value** (sum of `estimated_value` for non-LOST non-archived active leads)
- **Leads by stage** (horizontal bar: count per stage)
- **Leads by source** (pie: manual / form / facebook / google / linkedin / instagram / zapier / make / referral / cold outreach / other)
- **Recent activity** (last 10 `lead_activities` across all leads, newest first)

Data: single `GET /api/marketing/overview` endpoint returning all widgets in one response.

---

## Permissions

| Action | Required permission |
|--------|-------------------|
| View pipeline / leads | `marketing:read` |
| Create / edit / archive leads | `marketing:write` |
| Convert lead to customer | `marketing:write` + `customers:write` |
| Manage lead forms | `marketing:write` (Business+ tier) |
| Manage inbound webhooks | `marketing:write` (Business+ tier) |
| View analytics / overview | `marketing:read` |

CONTRACTOR role: no access to marketing module.

---

## API Endpoints

```
GET    /api/marketing/overview              → overview page data (all widgets)

GET    /api/leads                           → list (filterable: stage, source, assigned_to, archived, include_lost)
POST   /api/leads                           → create
GET    /api/leads/:id                       → detail + activities
PATCH  /api/leads/:id                       → update (stage, fields, assigned_to, estimated_value)
DELETE /api/leads/:id                       → archive (soft-delete: sets archived_at)

POST   /api/leads/:id/convert               → convert WON lead to customer
                                               body: { customerData, projectData? }
                                               returns: { customerId, projectId? }

GET    /api/leads/:id/activities            → paginated activity feed
POST   /api/leads/:id/activities            → add note or call log
                                               body: { type: 'note'|'call_logged', content, metadata? }

GET    /api/lead-forms                      → list forms (Business+ only)
POST   /api/lead-forms                      → create form
GET    /api/lead-forms/:id                  → form detail + fields
PATCH  /api/lead-forms/:id                  → update form / toggle active / update style
DELETE /api/lead-forms/:id                  → delete (only if no submissions)

GET    /api/lead-forms/:id/submissions      → paginated submissions list

POST   /api/forms/:slug                     → public form submission (no auth, rate-limited)
GET    /api/forms/:slug/public?tenant={tenantSlug} → form config for public renderer (no auth)
                                               returns: { name, fields, style, tenantName, tenantLogoUrl }

GET    /api/lead-webhooks                   → list configured webhooks (Business+ only)
POST   /api/lead-webhooks                   → create webhook config (returns secret once)
PATCH  /api/lead-webhooks/:id               → update name / field_mapping / toggle active
DELETE /api/lead-webhooks/:id               → delete
GET    /api/webhooks/leads/:webhookId       → Facebook verification challenge (GET)
POST   /api/webhooks/leads/:webhookId       → inbound lead webhook (no auth, HMAC verified)
```

---

## Outbound Webhook Events

| Event | Payload |
|-------|---------|
| `lead.created` | `{ leadId, name, email, source, stage, tenantId }` |
| `lead.stage_updated` | `{ leadId, name, previousStage, newStage, tenantId }` |
| `lead.converted` | `{ leadId, customerId, projectId, tenantId }` |

---

## Analytics Engine Events

Emitted to CF Analytics Engine (write-only binding `ANALYTICS_ENGINE`):

| Event | Fields |
|-------|--------|
| `lead_captured` | `tenantId`, `source` ('form'\|'facebook'\|'google'\|etc), `formId`?, `webhookId`?, `leadId`, `catalogShareId`?, `utmSource`, `utmMedium`, `utmCampaign` |

`lead_captured` emitted by form submission handler (step 10) and inbound webhook handler (step 8). Manual `+ New lead` does NOT emit it.

`catalogShareId` is present only when form is embedded in a catalog share (`spec 23` passes it). Spec 23's funnel filters step 2 on `catalogShareId IS NOT NULL` to keep catalog→lead population intact. Standalone form submissions and webhook leads contribute to the event stream but not to the catalog funnel denominator.

This feeds:
- Spec 23 funnel dashboard step 2 (event name matches spec 23 Funnel Dashboard table exactly)

---

## Global Search

Leads indexed in global search (spec 37). Searchable fields: `name`, `email`, `company`. Result card shows: name, company, stage badge, assigned user. Requires `marketing:read`. CONTRACTOR role excluded.

---

## Notifications

On lead assignment (`assigned_to` changed): in-app notification to the new assignee.  
Notification type: `lead.assigned` — "You've been assigned lead: [name]" with link to pipeline panel.  
Uses system-communications-notifications spec notification routing.

---

## Foundation Deltas

### Rate Limiter binding

New binding: `RATE_LIMITER_LEAD_FORM` — CF native RateLimiter, 20 submissions/minute per form (keyed by form id + IP). Add to wrangler.toml following same pattern as `RATE_LIMITER_EXPENSE_UPLOAD` (expenses spec).

### Analytics Engine binding

`ANALYTICS_ENGINE` binding already declared by spec 23. No new binding needed.

### Secrets

No new Cloudflare secrets. `lead_webhooks.secret` encrypted at rest in Postgres via `INTEGRATION_ENCRYPTION_KEY` (AES-256-GCM, same pattern as SMTP password, OAuth tokens, payment credentials). Decrypted in the webhook handler before HMAC verification. Shown once on creation (plaintext response from POST); stored encrypted thereafter.

### Notification event type

Add `lead.assigned` to notification type registry (spec 3 — system-communications-notifications).

---

## Architecture Decisions

| Decision | Choice | Reason |
|----------|--------|--------|
| Leads separate from customers | `leads` table, `customer_id` set on convert | Lead lifecycle (Won/Lost) distinct from customer; analytics need unconverted leads preserved |
| Fractional index for stage position | NUMERIC, same as tasks-board-engine | Consistent drag-drop pattern; rebalance when gap < 0.001 |
| Form submission public endpoint | No auth, rate-limited | Lead forms embed on external sites; auth would block conversion |
| HMAC per inbound webhook config | Per-webhook secret, AES-256-GCM encrypted at rest | Each source has own auth scheme; one secret per config avoids cross-source collision; encrypted to match all other stored secrets in system |
| Google Lead Form verification | Body field `google_key` comparison, not HMAC header | Google's webhook protocol posts shared secret in body (no `X-Google-Signature`); verified by string comparison after decryption |
| Public form page host | `zync.is/f/{slug}` (zync-www, Astro) | No-auth, SEO/perf-sensitive, embeddable → zync-www not zync-app; same-origin POST eliminates CORS |
| `lead_captured` AE event excludes manual leads | Explicit population boundary | Manual pipeline leads have no UTM/catalog context; including them would inflate funnel step 2 and break catalog→lead conversion rate |
| UTM params on both leads + submissions | Denormalized | Direct attribution query on leads; submission payload preserved for audit; no join needed for funnel analytics |
| Field mapping JSONB | Not fixed columns | Each source (FB/Google/Zapier) has different payload shape; JSONB mapping avoids hardcoding per source |
| LOST column hidden by default | Toggle to show | Clutters kanban; most views focus on active pipeline; LOST preserved for conversion rate analytics |
| Tier gating (Business+) for forms/webhooks | Hard gate with upgrade prompt | Manual pipeline Kanban is core CRM; automated capture is a growth-tier feature |
| slug unique per tenant (not globally) | UNIQUE(tenant_id, slug) | White-label: same slug on different tenant domains should work; global uniqueness would conflict |
| Facebook GET verification | Separate GET handler | Facebook requires challenge-response before sending leads; POST-only handler would fail verification |
| Form slug immutable after first submission | Enforce in PATCH | Changing slug breaks existing embeds; UI warns if no submissions yet; blocks change if submissions > 0 |
