Leads & CRM — Behavior
State machine — lead_status
| From | To | Trigger |
|---|---|---|
| (insert) | new | Any lead-create path (minisite, manual, platform). Initial lead_status_events row inserted with from_status=null, to_status=new. |
new | contacted | PATCH …/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. |
contacted | trial_booked | PATCH/board move with status/category:'trial_booked'. Typically combined with trialDate (the board’s drag-to-trial prompts for one). Event row. |
trial_booked | converted | Only 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. |
| any | lost | PATCH/board move with status/category:'lost'. Event row. |
| any open state | customer | Automatically on course-purchase activation (LeadPurchaseService → markCustomer: 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 samerequireStaffgate guards every conversations + pipelines endpoint. convertedonly via convert —updateLeadrejects (400) a move/status-set into aconverted-category stage unless the lead is already converted (repositioning). The only path that setsconvertedisconvertLead, which creates the membership — so a “converted” lead always has one. Enforced server-side, covering the board, the AIupdatetool, and direct API calls.- Stage ↔ status are one move —
stageIdwins; a barestatusresolves 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 gate —
sendMessageon a lead conversation consultsLeadConsentService.isAllowedfor consent-gated channels (email/sms/whatsapp) and 403s with theLEAD_CHANNEL_BLOCKEDmarker when blocked, closing the gap where an unsubscribed lead could still be emailed from the inbox/compose. Internalmanualnotes 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 inlead_consent_events) is the sanctioned path when a lead explicitly re-engages. - Conversation assignee must be a member —
assignrejects anassignedToUserIdthat isn’t an active member of the org (integrity + prevents leaking a non-member’s name via the inbox list). - Inbound is idempotent + re-opens —
recordInbounddedups on(channel, external_message_id)(viaonConflictDoNothing),+1sunread_count, setsstatus='open', and emitsconversation.inbound_received. Threads resolve from the per-conversationReply-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,recordInboundForOrgLeadresolves org + lead (never cross-org), lazily finds-or-creates the lead’s email thread, appends the reply, and runsmarkContacted(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) —convertLeadOnSelfSignupanddemoteReleasedJoinMembershipare the two writes nobody clicks, and they used to find their lead byemail OR phone. With the flag ON they useisSameLeadPerson(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 nolead_status_eventsrow 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 + idempotent —
organization_leads.converted_membership_idset 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.email→memberships.user_id),convertLeadcreates nothing: it returnsoutcome:'existing_member'with the member’s name andexistingMember.membershipIdand writes nothing. The write isPOST …/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) andoutcome:'attached'comes back. NoMEMBERSHIP_ACTIVATED— the membership’s welcome fan-out already ran. The membership must be in the org, not soft-deleted, andactive; 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 anything —
GET …/leads/:id/link-candidateslists 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 thanisSameLeadPerson: 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 aconvertedstage). - 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
OrganizationLeadsControlleris gated by@RequiresFeature('lead_management'). Orgs without the feature get 403 from a separate guard. - Primary pipeline is swappable —
POST …/leads/:leadId/pipelines/:pipelineId/set-primarypromotes a secondary membership to the primary placement (the oneleads.statusmirrors) 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 fabricateconverted). Aprimary_changedtimeline 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
newstage, aloststage, and at least one WIN stage:convertedORcustomer. 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).convertLeadlands the new member on the pipeline’sconvertedstage, else itscustomerstage, else leaves the lead’s stage untouched — never an unrelated column. - Bulk stage move honors the convert invariant —
POST …/leads/bulk-move-stagemoves leads already in the pipeline (primary via the fullmoveLeadToStagemachinery, secondary via a batch membership update). Unconverted leads aimed at aconvertedstage 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)
LeadPurchaseServiceattaches a per-coursepurchase-category tag (find-or-create, named after the course) to the buyer’s lead in the seller’s org, appends apurchasetimeline event, and callsmarkCustomer(auto-move to the pipeline’scustomerstage, when one exists). Analytics countscustomeras a win alongsideconverted(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/:idreturnstimeline: 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 singlestageId) 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 (reusingcreateLead). 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:
| URL | Renders |
|---|---|
/dashboard/leads | Board. 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/list | Table view. |
/dashboard/leads/inbox | Conversations 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
campaignRefper surface (lead-popup,contact-form) through the same-origin proxy;createLeadfind-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 viacampaignId— validated to belong to the org. - Manual buckets via
POST …/leads/campaigns(name + optional channel/color; externalRef null). - Analytics:
getAnalytics.byCampaignreports 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_membersnever 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
- Public
POST /leads/organization/:orgIdwith{name, email, phone, locale, note, campaignRef?, minisiteEventId?}. - Validation via
createOrganizationLeadSchema(Zod). The controller then applies public-boundary policy:campaignId(staff-path field) is always dropped;campaignRefis limited to the refs the minisite’s own surfaces send (lead-popup,contact-form) so a public caller can’t mint arbitrary dashboard buckets;minisiteEventIdpasses through (the service validates it against the org’s events and resolves the per-event campaign server-side). - Service dedup against existing leads in this org by email or phone.
- INSERT
leadsrow (source forced tominisite). - INSERT
organization_leadsrow (stamped with the resolved campaign, if any). - INSERT
lead_status_eventsrow (from=null, to=new, changedAt=lead.createdAt, actor null). - Auto-task: find org owner; INSERT
tasksrow of typecontact_lead, priorityhigh, sourceauto,dueDate=tomorrow,assigneeId=owner.userId. Errors swallowed. - 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 detailedConflictException(withexistingLeadId/existingLeadName) is reserved for authenticated staff paths and the idempotent webhook ingestion.
G2 — Staff manually creates a lead
POST /organizations/:orgId/leadswith body. Same path as G1 except:- Auth required (
requireMembership+isStaffRole). actorUserIdresolved from caller for the status event.- Source defaults to
manual(overridable via body).
- Auth required (
G3 — Staff updates lead status
PATCH …/leads/:leadIdwith{status: 'trial_booked', trialDate: '2026-06-01T10:00:00Z'}.- Service fetches current status, then UPDATEs
leads(andstatusChangedAt). - If status changed: INSERT
lead_status_eventsrow withfrom=prev, to=new, actor. - UPDATEs
organization_leads.trial_dateif provided. - Returns the lead with full event history.
G4 — Convert to member
POST .../leads/:leadId/convertwith{firstName, lastName, email, role?}.- Owner/admin check.
- Load
organization_leadsrow; reject if already converted. - Find-or-create
usersrow by email (usesonConflictDoNothingthen re-finds; TODO: verify race-safety). - Check no existing non-deleted membership for (user, org). Reject if present.
- INSERT
membershipswithrole: input.role ?? 'member', status:'active', source_lead_id: leadId. - UPDATE
organization_leads.converted_membership_id = newMembership.id. - UPDATE
leads.status='converted', statusChangedAt=now. - INSERT
lead_status_eventsrow. - Emit
MEMBERSHIP_ACTIVATED { source:'lead_converted', organizationId, userId, membershipId, role }. - Return
{ membershipId, userId, role, status }.
G5 — Analytics
GET .../leads/analytics.- 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.
- Totals with
- Returns aggregated payload with
conversionRate(rounded integer percent).
G6 — Lead arrives via Meta Lead Ads (webhook)
- Coach has an active
meta_lead_adsconnection (a Page subscribed toleadgen). - Meta POSTs
/webhooks/meta/leadgen;verifyMetaSignaturevalidates theX-Hub-Signature-256HMAC. - Route the
leadgenchange bypage_id→ the owning org’s connection → enqueue on thelead-ingestionqueue. MetaLeadProcessorServicefetches the lead from the Graph API byleadgen_id, maps form fields, resolves/creates thelead_campaignshook bucket, creates the lead (source=facebook|instagram) viacreateLead, and writeslead_attribution. Idempotent onleadgen_id.
G7 — Lead arrives via Click-to-WhatsApp (webhook)
- Coach has an active
whatsapp_cloudconnection (number registered + WABA subscribed). - Meta POSTs
/webhooks/whatsapp; HMAC validated (same app secret). - Route the
messageschange byphone_number_id→ connection. Only messages carrying a CTWAreferralqualify (organic inbound is dropped, by design). Enqueue with job namewhatsapp. WhatsAppProcessorServiceresolves the hook bucket fromreferral.source_id(named from the adheadline), creates the lead (source=whatsapp,phone=wa_id,name=profile name), and writeslead_attribution. Idempotent onctwa_clid(falling back towamid).
Full pipeline detail (connect flows, code map, env) in lead-ingestion.md.
G8 — Board: move a lead between stages
- Drag a card on the kanban board → web optimistically updates the React Query cache →
PATCH …/leads/:leadId { stageId }. - Service
moveLeadToStage: updatesorganization_leads.stage_id+leads.status(= stage category) + appends alead_status_eventsrow; emitsLEAD_STATUS_CHANGEDon a category change (drives automations). - Drag into a
convertedstage (lead not yet converted) → the UI opens the Convert dialog instead (the API would reject a direct move). Drag into atrial_bookedstage → the UI prompts for a trial date, thenPATCH { stageId, trialDate }.
G9 — Inbox: email a lead (outbound + threading)
- 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. sendMessage: insert an outboundconversation_messagesrow (queued), call the email adapter (Resend) withReply-To: contact+<conversationId>@<INBOUND_EMAIL_DOMAIN>and, if the contact has replied before,In-Reply-To/References= their last inboundexternal_message_id; persist the provider id + delivery status.- First outbound flips the lead
new → contacted(markContacted, best-effort, logged on failure). - 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)
- The lead replies to
contact+<id>@…; Cloudflare Email Routing → the zero-dep Email Worker forwards the raw MIME toPOST /webhooks/email/inbound?secret=…(fail-closed on the secret; throttled). - The body is extracted from the MIME (charset-aware) and quoted history/signature stripped;
parseConversationIdrecovers the thread from the address. recordInboundappends an inbound message (idempotent),+1unread, re-opens the thread, emitsconversation.inbound_received. The inbox list refreshes via query invalidation. See the runbook: inbox-email-inbound.md.
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Public minisite dup | Silently swallowed: 201 {data: {received: true}}, identical to a fresh create (anti-enumeration; no new row inserted). |
| Manual create dup | 409 {message: 'A lead with this email or phone already exists in this organization', existingLeadId, existingLeadName}. |
| Convert already-converted lead | 400 ‘Lead has already been converted’. |
| Convert when an active membership exists | 200 outcome:'existing_member' naming the member, nothing written; confirm via POST …/link-member → outcome:'attached'. |
| Convert when a non-active membership exists | 400 ‘User is already a member of this organization’. |
| Link to a membership outside the org or soft-deleted | 404 ‘Member not found’. |
| Link to a non-active membership | 400 ‘Member is not active’. |
| Link an already-converted lead | 400 ‘Lead has already been converted’. |
| Convert by coach | 403 ‘Only owners and admins can convert leads’. |
| Update by member | 403 ‘Staff access required’. |
| Cross-org access (any endpoint) | 404 ‘Lead not found’ (LEFT JOIN with organization_leads.organization_id=$orgId). |
lead_management feature off | 403 from @RequiresFeature guard before the handler runs. |
| Public lead body invalid Zod | 400 with result.error.flatten().fieldErrors. |
findOrCreate user race | onConflictDoNothing 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 send | Non-blocking, but logged (warn) — the send still succeeds. |
Set status:'converted' via PATCH / board drag | 400 ‘A lead can only be marked converted by converting it to a member’. |
| Assign a conversation to a non-member | 400 ‘Assignee is not a member of this org’. |
| Inbound webhook, missing/invalid secret | 403 ‘Invalid webhook secret’ (fail-closed). The secret query param is redacted from logs. |
Inbound webhook, unroutable / duplicate external_message_id | 200 {ok:true} (acked so the provider doesn’t retry); no duplicate message recorded. |
| Outbound send rejected by provider | Message persisted with delivery_status='failed' + error_message; endpoint returns 400 with the provider error. |
Side effects
| Operation | Side effects |
|---|---|
| Public lead submit | leads + organization_leads + lead_status_events + auto tasks row. |
| Status update | lead_status_events row appended; statusChangedAt bumped. |
| Convert | New memberships row; MEMBERSHIP_ACTIVATED event (downstream: forms fan-out, etc.); lead row updated; event row appended. |
| Analytics | None (read-only). |
Permissions
| Endpoint | Public | owner | admin | coach | member |
|---|---|---|---|---|---|
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/inbound | secret-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.