Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesLeads CrmLeads & CRM — Behavior

Leads & CRM — Behavior

State machine — lead_status

FromToTrigger
(insert)newAny lead-create path (minisite, manual, platform). Initial lead_status_events row inserted with from_status=null, to_status=new.
newcontactedPATCH …/leads/:id with status:'contacted', a board move into a contacted-category stage, or automatically on the first outbound message in the lead’s conversation (markContacted, best-effort). Event row inserted.
contactedtrial_bookedPATCH/board move with status/category:'trial_booked'. Typically combined with trialDate (the board’s drag-to-trial prompts for one). Event row.
trial_bookedconvertedOnly POST …/leads/:id/convert (owner/admin). A bare PATCH status:'converted' or a board move into a converted stage is rejected (400) — see invariant below. Membership created; event row inserted.
anylostPATCH/board move with status/category:'lost'. Event row.
any open statecustomerAutomatically on course-purchase activation (LeadPurchaseServicemarkCustomer: moves the lead to its pipeline’s customer-category stage when one exists, explicit match only), or manually via PATCH/board/bulk move. The one-time-purchase “won” — paid, not a member. Freely settable, unlike converted (no membership behind it to protect). Never applied to already-customer/converted leads.
any(any other open stage)Allowed — stages within new/contacted/trial_booked/lost move freely (it’s a manual sales process). The only guarded transition is into converted.

statusChangedAt (on leads) is bumped on every status change; serves as the “last touched” timestamp distinct from updated_at. Status is derived from the lead’s current pipeline stage category; moving a lead between stages keeps leads.status in sync and appends a lead_status_events row (with from_stage_id/to_stage_id).

Invariants

  • Org isolation on org-leads — every read/write requires requireMembership(orgId, clerkId) + staff role check (isStaffRole(role) returns true for owner/admin/coach). Convert requires owner/admin. The same requireStaff gate guards every conversations + pipelines endpoint.
  • converted only via convertupdateLead rejects (400) a move/status-set into a converted-category stage unless the lead is already converted (repositioning). The only path that sets converted is convertLead, which creates the membership — so a “converted” lead always has one. Enforced server-side, covering the board, the AI update tool, and direct API calls.
  • Stage ↔ status are one movestageId wins; a bare status resolves to the matching-category stage in the lead’s current pipeline; both update together so stage and status can’t drift. Default pipeline is seeded lazily (ensureDefaultPipeline, race-safe via the partial-unique default index).
  • 1:1 sends respect the consent gatesendMessage on a lead conversation consults LeadConsentService.isAllowed for consent-gated channels (email/sms/whatsapp) and 403s with the LEAD_CHANNEL_BLOCKED marker when blocked, closing the gap where an unsubscribed lead could still be emailed from the inbox/compose. Internal manual notes and member conversations are never gated. The web mirrors the state up front: blocked banner in the thread composer, disabled compose dialog with notice, and an “Unsubscribed” hero CTA in the lead drawer. Staff re-grant via the consent panel (audited in lead_consent_events) is the sanctioned path when a lead explicitly re-engages.
  • Conversation assignee must be a memberassign rejects an assignedToUserId that isn’t an active member of the org (integrity + prevents leaking a non-member’s name via the inbox list).
  • Inbound is idempotent + re-opensrecordInbound dedups on (channel, external_message_id) (via onConflictDoNothing), +1s unread_count, sets status='open', and emits conversation.inbound_received. Threads resolve from the per-conversation Reply-To (contact+<id>@<domain>).
  • Bulk email is broadcast-on-send, thread-on-reply (FIT-224) — a bulk send writes no conversation and advances no stage (a blast ≠ 200 conversations). Each send instead carries a lead-keyed Reply-To (contact+ol-<organizationLeadId>@<domain>). On a reply, recordInboundForOrgLead resolves org + lead (never cross-org), lazily finds-or-creates the lead’s email thread, appends the reply, and runs markContacted (new→contacted) — because a reply is real engagement. No conversation exists until the lead actually replies.
  • Dedup is per-org — same email + phone in different orgs is allowed; same email or phone in the same org returns 409 with existingLeadId.
  • Automated writes match on a person, not a field (crm-lead-identity, per-org, default OFF)convertLeadOnSelfSignup and demoteReleasedJoinMembership are the two writes nobody clicks, and they used to find their lead by email OR phone. With the flag ON they use isSameLeadPerson (lead-identity.ts): equal email is conclusive; equal phone counts only when the name agrees (or the lead has no name) and no second, differently-named person in the org answers to that number. Candidates resolve oldest-first. Reopening is change-detecting under the same flag: a lead already in the target stage records no lead_status_events row and does not repeat a sentence its note already ends with, and note appends happen in SQL so simultaneous releases cannot overwrite each other. Rationale and the production incident: subscriptions-plans/behavior.md.
  • Convert is one-way + idempotentorganization_leads.converted_membership_id set once. Re-attempt raises 400 ‘Lead has already been converted’.
  • Convert onto an existing member links, never duplicates — when the email already belongs to an active member of the org (users.emailmemberships.user_id), convertLead creates nothing: it returns outcome:'existing_member' with the member’s name and existingMember.membershipId and writes nothing. The write is POST …/leads/:id/link-member { membershipId } (linkLeadToMember, owner/admin): the lead is linked to that membership (converted_membership_id, converted stage, status event with the staff actor, LEAD_STATUS_CHANGED) and outcome:'attached' comes back. No MEMBERSHIP_ACTIVATED — the membership’s welcome fan-out already ran. The membership must be in the org, not soft-deleted, and active; a non-active existing membership (cancelled, pending_invitation, …) keeps today’s 400 ‘User is already a member of this organization’ on convert and 400 ‘Member is not active’ on link. This is the repair path for a lead the self-signup match missed (no email on the lead, a name in another script).
  • Link candidates are offered before staff type anythingGET …/leads/:id/link-candidates lists active members who share the lead’s canonical email or phone (matchedBy:'email'|'phone', email first; empty for a converted lead). Matching is deliberately looser than isSameLeadPerson: this is a list a person reads and chooses from, not a write, so a shared family number shows every member on it and the name is what staff decide by. The convert dialog shows them as “Possible matches” with a Link button each; every link — from a candidate or from a typed email — goes through the same confirmation step naming the member, on both routes into the dialog (the detail sheet and the board’s drag into a converted stage).
  • Auto-task never blocks lead create — wrapped in try/catch. If no owner-role membership is found in the org, the task is silently skipped.
  • Platform feature gate — every method on OrganizationLeadsController is gated by @RequiresFeature('lead_management'). Orgs without the feature get 403 from a separate guard.
  • Primary pipeline is swappablePOST …/leads/:leadId/pipelines/:pipelineId/set-primary promotes a secondary membership to the primary placement (the one leads.status mirrors) and demotes the old primary to a removable membership. Status re-mirrors from the new primary stage’s category (status event + automations on a real change); the convert invariant holds (a swap can’t fabricate converted). A primary_changed timeline event records it. Bridge until the opportunity-model refactor removes the primary/secondary asymmetry (planned ADR).
  • Pipeline win coverage is either-flavor — a pipeline must keep a new stage, a lost stage, and at least one WIN stage: converted OR customer. A course funnel can drop its Converted stage entirely once it has a Customer stage (and the last win stage can flip between the two flavors). convertLead lands the new member on the pipeline’s converted stage, else its customer stage, else leaves the lead’s stage untouched — never an unrelated column.
  • Bulk stage move honors the convert invariantPOST …/leads/bulk-move-stage moves leads already in the pipeline (primary via the full moveLeadToStage machinery, secondary via a batch membership update). Unconverted leads aimed at a converted stage and leads not in the pipeline are skipped (reported in the result), never silently converted or added.
  • Course purchases tag the contact + move it to Customer (FIT-227) — on entitlement activation (free checkout immediately; paid via the payment webhook’s pending→active flip) LeadPurchaseService attaches a per-course purchase-category tag (find-or-create, named after the course) to the buyer’s lead in the seller’s org, appends a purchase timeline event, and calls markCustomer (auto-move to the pipeline’s customer stage, when one exists). Analytics counts customer as a win alongside converted (conversionRate = (converted + customers) / total). Webhook replays don’t duplicate (only the real pending→active transition records). Buyers with no lead in the org (plain members) are no-ops. This is what makes “email everyone about course X except its buyers” reliable via the bulk-email exclude-tags picker — no stage hygiene required. Backfill for pre-existing entitlements: scripts/backfill-purchase-tags.ts.
  • Unified timeline (FIT-227)GET …/leads/:id returns timeline: status flips + lead_activity_events (pipeline add/remove, stage moves incl. same-category and secondary-pipeline ones, purchases, per-recipient bulk-email sends) + inbox messages (last 100), merged newest-first with pre-resolved display names. Activity writes are best-effort: they never fail the operation they record.
  • Bulk email stage scoping — the pipeline target accepts stageIds[] (multi-select; union’d with the legacy single stageId) filtered on the lead’s effective stage in that pipeline (coalesce(membership.stage_id, primary.stage_id)).
  • Ingestion routes on an active connection — inbound webhooks resolve the owning org via integration_connections.external_account_id (page_id / phone_number_id). No active connection → the event is dropped (no feature flag involved). Processors are idempotent on the external lead id and dedup the lead by phone/email (reusing createLead). Connect/disconnect requires owner/admin.

Routes — the leads surface is addressable

The dashboard leads page is an optional catch-all route (apps/web/src/app/[lang]/(protected)/dashboard/leads/[[...view]]/page.tsx); views and the board’s pipeline are URL state, not component state:

URLRenders
/dashboard/leadsBoard. Client-side router.replace canonicalizes to the active pipeline’s URL once pipelines load.
/dashboard/leads/<pipelineId>Board scoped to that pipeline. Unknown/archived/stale ids fall back to the default pipeline (and the URL is fixed via replace).
/dashboard/leads/listTable view.
/dashboard/leads/inboxConversations inbox.

Reserved view names (list, inbox) win over pipeline ids by parse order. Tab toggles and the pipeline selector navigate (router.push) instead of setting state, so refresh/deep-link/back all work — and Spotter sees the active pipeline in its <page_context> pathname. Filters, search, the open lead sheet, and the inbox conversation selection remain client state and survive view switches (same page component, soft navigation).

Creating a pipeline navigates to /dashboard/leads/<pipelineId>?editPipeline=1; the query marker keeps the stage editor open across the soft-navigation remount and is removed when the editor closes.

Campaign hooks — granular acquisition attribution

lead_campaigns answers “which exact ad/form/surface brought this lead”, not just the channel:

  • Auto-minted by Meta/WhatsApp ingestion on (org, channel, externalRef) — externalRef is the provider’s form/ad/source id.
  • Minisite capture passes a campaignRef per surface (lead-popup, contact-form) through the same-origin proxy; createLead find-or-creates the hook with the same conflict-safe upsert (channel = source, externalRef = ref).
  • Staff paths (add-lead dialog, Spotter leads.create) attach an existing hook via campaignId — validated to belong to the org.
  • Manual buckets via POST …/leads/campaigns (name + optional channel/color; externalRef null).
  • Analytics: getAnalytics.byCampaign reports per-hook totals, wins (converted + customer), and win rate — the “which ad actually produces members” view.

Segments were removed (2026-07-04). lead_segments / lead_segment_members never saw production use; grouping is served by pipelines (process + interest), tags (contact labels), and campaign hooks (lead_campaigns, granular acquisition source). Bulk audiences target lead ids, pipeline/stage, tags, or a select-all-matching filter.

Golden paths

G1 — Lead arrives via minisite

  1. Public POST /leads/organization/:orgId with {name, email, phone, locale, note, campaignRef?, minisiteEventId?}.
  2. Validation via createOrganizationLeadSchema (Zod). The controller then applies public-boundary policy: campaignId (staff-path field) is always dropped; campaignRef is limited to the refs the minisite’s own surfaces send (lead-popup, contact-form) so a public caller can’t mint arbitrary dashboard buckets; minisiteEventId passes through (the service validates it against the org’s events and resolves the per-event campaign server-side).
  3. Service dedup against existing leads in this org by email or phone.
  4. INSERT leads row (source forced to minisite).
  5. INSERT organization_leads row (stamped with the resolved campaign, if any).
  6. INSERT lead_status_events row (from=null, to=new, changedAt=lead.createdAt, actor null).
  7. Auto-task: find org owner; INSERT tasks row of type contact_lead, priority high, source auto, dueDate=tomorrow, assigneeId=owner.userId. Errors swallowed.
  8. Return {data: {received: true}} — a fixed minimal body. The public endpoint never returns the lead row, and a dedup hit returns the same 201 + body as a fresh create: any distinguishable response would be an enumeration oracle (probe a phone/email → learn the person is a lead of this org). The service’s detailed ConflictException (with existingLeadId/existingLeadName) is reserved for authenticated staff paths and the idempotent webhook ingestion.

G2 — Staff manually creates a lead

  1. POST /organizations/:orgId/leads with body. Same path as G1 except:
    • Auth required (requireMembership + isStaffRole).
    • actorUserId resolved from caller for the status event.
    • Source defaults to manual (overridable via body).

G3 — Staff updates lead status

  1. PATCH …/leads/:leadId with {status: 'trial_booked', trialDate: '2026-06-01T10:00:00Z'}.
  2. Service fetches current status, then UPDATEs leads (and statusChangedAt).
  3. If status changed: INSERT lead_status_events row with from=prev, to=new, actor.
  4. UPDATEs organization_leads.trial_date if provided.
  5. Returns the lead with full event history.

G4 — Convert to member

  1. POST .../leads/:leadId/convert with {firstName, lastName, email, role?}.
  2. Owner/admin check.
  3. Load organization_leads row; reject if already converted.
  4. Find-or-create users row by email (uses onConflictDoNothing then re-finds; TODO: verify race-safety).
  5. Check no existing non-deleted membership for (user, org). Reject if present.
  6. INSERT memberships with role: input.role ?? 'member', status:'active', source_lead_id: leadId.
  7. UPDATE organization_leads.converted_membership_id = newMembership.id.
  8. UPDATE leads.status='converted', statusChangedAt=now.
  9. INSERT lead_status_events row.
  10. Emit MEMBERSHIP_ACTIVATED { source:'lead_converted', organizationId, userId, membershipId, role }.
  11. Return { membershipId, userId, role, status }.

G5 — Analytics

  1. GET .../leads/analytics.
  2. Service runs 3 queries scoped to this org’s leads:
    • Totals with count(*) + count(*) filter (where status='converted') + count(*) filter (where createdAt >= startOfMonth).
    • Group-by source.
    • Group-by status.
  3. Returns aggregated payload with conversionRate (rounded integer percent).

G6 — Lead arrives via Meta Lead Ads (webhook)

  1. Coach has an active meta_lead_ads connection (a Page subscribed to leadgen).
  2. Meta POSTs /webhooks/meta/leadgen; verifyMetaSignature validates the X-Hub-Signature-256 HMAC.
  3. Route the leadgen change by page_id → the owning org’s connection → enqueue on the lead-ingestion queue.
  4. MetaLeadProcessorService fetches the lead from the Graph API by leadgen_id, maps form fields, resolves/creates the lead_campaigns hook bucket, creates the lead (source=facebook|instagram) via createLead, and writes lead_attribution. Idempotent on leadgen_id.

G7 — Lead arrives via Click-to-WhatsApp (webhook)

  1. Coach has an active whatsapp_cloud connection (number registered + WABA subscribed).
  2. Meta POSTs /webhooks/whatsapp; HMAC validated (same app secret).
  3. Route the messages change by phone_number_id → connection. Only messages carrying a CTWA referral qualify (organic inbound is dropped, by design). Enqueue with job name whatsapp.
  4. WhatsAppProcessorService resolves the hook bucket from referral.source_id (named from the ad headline), creates the lead (source=whatsapp, phone=wa_id, name=profile name), and writes lead_attribution. Idempotent on ctwa_clid (falling back to wamid).

Full pipeline detail (connect flows, code map, env) in lead-ingestion.md.

G8 — Board: move a lead between stages

  1. Drag a card on the kanban board → web optimistically updates the React Query cache → PATCH …/leads/:leadId { stageId }.
  2. Service moveLeadToStage: updates organization_leads.stage_id + leads.status (= stage category) + appends a lead_status_events row; emits LEAD_STATUS_CHANGED on a category change (drives automations).
  3. Drag into a converted stage (lead not yet converted) → the UI opens the Convert dialog instead (the API would reject a direct move). Drag into a trial_booked stage → the UI prompts for a trial date, then PATCH { stageId, trialDate }.

G9 — Inbox: email a lead (outbound + threading)

  1. From the lead detail or inbox, POST …/conversations { leadId, channel:'email', subject?, body } → find-or-create the thread (idempotent on (org, channel, externalContactId)), then send.
  2. sendMessage: insert an outbound conversation_messages row (queued), call the email adapter (Resend) with Reply-To: contact+<conversationId>@<INBOUND_EMAIL_DOMAIN> and, if the contact has replied before, In-Reply-To/References = their last inbound external_message_id; persist the provider id + delivery status.
  3. First outbound flips the lead new → contacted (markContacted, best-effort, logged on failure).
  4. Subject is stored once and reused with a Re: prefix on subsequent replies. Blank subject falls back to a localized default in the contact’s locale.

G10 — Inbox: a lead replies (inbound)

  1. The lead replies to contact+<id>@…; Cloudflare Email Routing → the zero-dep Email Worker forwards the raw MIME to POST /webhooks/email/inbound?secret=… (fail-closed on the secret; throttled).
  2. The body is extracted from the MIME (charset-aware) and quoted history/signature stripped; parseConversationId recovers the thread from the address.
  3. recordInbound appends an inbound message (idempotent), +1 unread, re-opens the thread, emits conversation.inbound_received. The inbox list refreshes via query invalidation. See the runbook: inbox-email-inbound.md.

Edge cases & error states

ScenarioBehavior
Public minisite dupSilently swallowed: 201 {data: {received: true}}, identical to a fresh create (anti-enumeration; no new row inserted).
Manual create dup409 {message: 'A lead with this email or phone already exists in this organization', existingLeadId, existingLeadName}.
Convert already-converted lead400 ‘Lead has already been converted’.
Convert when an active membership exists200 outcome:'existing_member' naming the member, nothing written; confirm via POST …/link-memberoutcome:'attached'.
Convert when a non-active membership exists400 ‘User is already a member of this organization’.
Link to a membership outside the org or soft-deleted404 ‘Member not found’.
Link to a non-active membership400 ‘Member is not active’.
Link an already-converted lead400 ‘Lead has already been converted’.
Convert by coach403 ‘Only owners and admins can convert leads’.
Update by member403 ‘Staff access required’.
Cross-org access (any endpoint)404 ‘Lead not found’ (LEFT JOIN with organization_leads.organization_id=$orgId).
lead_management feature off403 from @RequiresFeature guard before the handler runs.
Public lead body invalid Zod400 with result.error.flatten().fieldErrors.
findOrCreate user raceonConflictDoNothing may return undefined; service re-fetches via email; if still missing, 400 ‘Could not create user’.
Auto-task failure (no owner / DB error)Silent — wrapped in try/catch. Lead create still succeeds.
markContacted failure after a sendNon-blocking, but logged (warn) — the send still succeeds.
Set status:'converted' via PATCH / board drag400 ‘A lead can only be marked converted by converting it to a member’.
Assign a conversation to a non-member400 ‘Assignee is not a member of this org’.
Inbound webhook, missing/invalid secret403 ‘Invalid webhook secret’ (fail-closed). The secret query param is redacted from logs.
Inbound webhook, unroutable / duplicate external_message_id200 {ok:true} (acked so the provider doesn’t retry); no duplicate message recorded.
Outbound send rejected by providerMessage persisted with delivery_status='failed' + error_message; endpoint returns 400 with the provider error.

Side effects

OperationSide effects
Public lead submitleads + organization_leads + lead_status_events + auto tasks row.
Status updatelead_status_events row appended; statusChangedAt bumped.
ConvertNew memberships row; MEMBERSHIP_ACTIVATED event (downstream: forms fan-out, etc.); lead row updated; event row appended.
AnalyticsNone (read-only).

Permissions

EndpointPublicowneradmincoachmember
POST /leads (platform)
POST /leads/organization/:orgId (minisite)
GET /organizations/:orgId/leads/analytics
GET /organizations/:orgId/leads
GET …/leads/:id
POST …/leads (manual)
PATCH …/leads/:id
POST …/leads/:id/convert
POST …/leads/bulk-move-stage
POST …/leads/bulk-tags
* /organizations/:orgId/lead-pipelines*
* /organizations/:orgId/conversations*
POST /webhooks/email/inboundsecret-gated (@Public, fail-closed, throttled)

All non-public endpoints additionally require the lead_management feature on the org’s platform_tier. Conversations + pipelines use the same requireStaff gate as the CRM; the inbound webhook is the only public conversations route and is authenticated by EMAIL_INBOUND_SECRET, not Clerk.

Ingestion endpoints (separate from the CRM table above): connect/disconnect of an integration (…/integrations/meta|whatsapp/*) requires owner/admin; the inbound webhooks (/webhooks/meta/leadgen, /webhooks/whatsapp) are public, authenticated by the Meta signature/verify-token handshake. See lead-ingestion.md.