A lead is a pre-customer record. It carries pipeline state, a numeric score, and the qualification metadata that distinguishes "passed our filter" from "bought something". Once qualified, a lead converts to a contact (and optionally an opportunity).
The Leads controller lives at /crm/leads/*. Most endpoints are JWT-gated; pipeline configuration is admin-only.
Lead model
{
// identity
email: string;
firstName?: string;
lastName?: string;
phone?: string;
company?: string;
// pipeline
pipelineId: string;
stage: string;
status: 'new' | 'qualified' | 'disqualified' | 'converted' | 'nurturing';
priority?: 'low' | 'medium' | 'high';
// scoring
score?: number;
scoreBreakdown?: { rule: string; points: number }[];
scoredAt?: string;
// attribution
source?: string;
campaignId?: string;
referrer?: string;
utmParams?: Record<string, string>;
// assignment
assignedTo?: string;
assignedAt?: string;
// qualification
qualifiedAt?: string;
qualifiedBy?: string;
qualificationReason?: string;
// conversion
convertedAt?: string;
convertedToContactId?: string;
convertedToOpportunityId?: string;
// custom
properties?: Record<string, any>;
}
Lead CRUD
/crm/leads/detailJWT/crm/leads/detailJWT/crm/leads/detail/:idJWT/crm/leads/detail/:idJWT/crm/leads/detail/:idJWTThe list endpoint accepts status, priority, source, assignedTo, search, page, pageSize, sortField, sortDirection as query params.
// Create
const lead = await fetch('/api/crm/leads/detail', {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
body: JSON.stringify({
email: '[email protected]',
firstName: 'Alice',
lastName: 'Doe',
company: 'Acme',
pipelineId: 'inbound-saas',
stage: 'new',
source: 'pricing-page',
properties: { team_size: 25, budget_range: '10-50k' },
}),
}).then(r => r.json());
// List with filters
const leads = await fetch(
'/api/crm/leads/detail?status=new&priority=high&page=1&pageSize=50',
{ headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` } },
).then(r => r.json());
Pipelines and stages
A pipeline is a named ordered set of stages with optional auto-routing rules. Most orgs run two or three pipelines: inbound, outbound, channel-partner.
/crm/leads/pipelinesJWT/crm/leads/pipelinesJWT/crm/leads/pipelines/:idJWT/crm/leads/pipelines/:idJWT/crm/leads/pipelines/:idJWTStages are managed through their own endpoints to preserve order:
/crm/leads/pipelines/stages/:idJWT/crm/leads/pipelines/stages/:id/:stageIdJWT/crm/leads/pipelines/stages/:id/:stageIdJWT/crm/leads/pipelines/stages/reorder/:idJWTA typical stage definition:
{
"id": "stage-discovery",
"name": "Discovery Call",
"order": 2,
"probability": 0.2,
"expectedDurationDays": 7,
"autoRoute": {
"rules": [
{ "if": { "field": "score", "op": ">=", "value": 80 }, "assignTo": "round-robin:senior-ae" }
]
}
}
Route unassigned leads
/crm/leads/pipelines/:id/route-unassignedJWTIdempotent batch — runs the pipeline's auto-route rules over every unassigned lead in any stage. Safe to run on a cron.
Qualify, disqualify, convert
/crm/leads/qualify/:idJWT/crm/leads/disqualify/:idJWT/crm/leads/convert/:idJWT/crm/leads/convert/contact/:contactIdJWTqualify sets status: 'qualified' and records qualifiedAt/qualifiedBy. The body accepts { reason?, notes? }. Disqualify is the same with a reason like bad-fit, no-budget, wrong-region.
Convert promotes a lead into a contact (and optionally an opportunity):
await fetch(`/api/crm/leads/convert/${leadId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
body: JSON.stringify({
createOpportunity: true,
opportunity: {
name: 'Acme — Pro plan',
amount: 12000,
currency: 'USD',
expectedCloseDate: '2026-06-30',
stage: 'negotiation',
},
}),
});
The reverse — turning an existing contact into a lead — uses convert/contact/:contactId.
Assign and follow up
/crm/leads/assign/:idJWT/crm/leads/follow-up/:idJWTAssign sets assignedTo and emits lead.assigned. Follow-up logs an activity and optionally schedules a task.
Scoring
Lead scoring is rule-driven. Configure rules once (typically in the admin UI) and the engine evaluates them on demand or in batch. Behind the scenes, scoring uses the Rule Engine module — every rule is an expression over the lead and its related records.
/crm/leads/score/:idJWT/crm/leads/score/batchJWT// Single
const scored = await fetch(`/api/crm/leads/score/${leadId}`, {
method: 'POST',
headers: { orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
}).then(r => r.json());
// scored.data: { score: 72, scoreBreakdown: [...], scoredAt: "..." }
// Batch — score every lead matching a filter
await fetch('/api/crm/leads/score/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json', orgid: ORG_ID, Authorization: `Bearer ${jwt}` },
body: JSON.stringify({
filter: { status: 'new' },
rules: ['demographic-fit', 'engagement-30d', 'budget-fit'],
}),
});
A scoring rule looks like:
{
"name": "demographic-fit",
"points": 20,
"condition": {
"and": [
{ "field": "company", "op": "exists" },
{ "field": "properties.team_size", "op": ">=", "value": 10 }
]
}
}
Negative-points rules ("disqualifier") are first-class — they reduce the score but don't block.
Enrichment
Enrichment fetches firmographic data (company size, industry, revenue, technographics) from third-party providers configured under Data Enrichment.
/crm/leads/enrich/:idJWT/crm/leads/enrich/batchJWT/crm/leads/enrichment/status/:idJWT/crm/leads/enrich/autoJWTenrich/:id runs enrichment synchronously and returns the merged record. batch queues a background job; poll enrichment/status/:id for progress. enrich/auto configures the org to enrich every new lead automatically — set it once.
The enrichment hook runs through LeadProspectorService for prospecting (find new leads) and LeadEnrichmentService for filling in existing records. Both delegate to whichever provider the org has configured (Clearbit, ZoomInfo, Apollo, etc.).
Activity, analytics, import
/crm/leads/activities/:idJWT/crm/leads/activities/:idJWT/crm/leads/analyticsJWT/crm/leads/import/contactsJWT/crm/leads/import/facebookJWT/crm/leads/bulk-import/contactsJWTActivities attach to a lead and feed the timeline UI. Analytics returns conversion / velocity / source breakdown for a date range. Imports accept CSV, an array of contact SKs, or a Facebook Lead Ads form id.
Events fired
Every lead transition fires an automation event:
lead.createdlead.assignedlead.scored(carriesscoreandscoreBreakdown)lead.qualified/lead.disqualifiedlead.converted(carriescontactIdandopportunityId)lead.stage_changed(carriesfromStage,toStage)
Wire these into Automation flows for Slack alerts, follow-up tasks, or auto-enrollment in nurture campaigns.