Documentation

Leads and scoring

Lead pipelines, qualification, conversion, scoring, and enrichment.

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

POST/crm/leads/detailJWT
GET/crm/leads/detailJWT
GET/crm/leads/detail/:idJWT
PUT/crm/leads/detail/:idJWT
DELETE/crm/leads/detail/:idJWT

The 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.

POST/crm/leads/pipelinesJWT
GET/crm/leads/pipelinesJWT
GET/crm/leads/pipelines/:idJWT
PUT/crm/leads/pipelines/:idJWT
DELETE/crm/leads/pipelines/:idJWT

Stages are managed through their own endpoints to preserve order:

POST/crm/leads/pipelines/stages/:idJWT
PUT/crm/leads/pipelines/stages/:id/:stageIdJWT
DELETE/crm/leads/pipelines/stages/:id/:stageIdJWT
PUT/crm/leads/pipelines/stages/reorder/:idJWT

A 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

POST/crm/leads/pipelines/:id/route-unassignedJWT

Idempotent batch — runs the pipeline's auto-route rules over every unassigned lead in any stage. Safe to run on a cron.

Qualify, disqualify, convert

POST/crm/leads/qualify/:idJWT
POST/crm/leads/disqualify/:idJWT
POST/crm/leads/convert/:idJWT
POST/crm/leads/convert/contact/:contactIdJWT

qualify 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

POST/crm/leads/assign/:idJWT
POST/crm/leads/follow-up/:idJWT

Assign 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.

POST/crm/leads/score/:idJWT
POST/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.

POST/crm/leads/enrich/:idJWT
POST/crm/leads/enrich/batchJWT
GET/crm/leads/enrichment/status/:idJWT
POST/crm/leads/enrich/autoJWT

enrich/: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

POST/crm/leads/activities/:idJWT
GET/crm/leads/activities/:idJWT
GET/crm/leads/analyticsJWT
POST/crm/leads/import/contactsJWT
POST/crm/leads/import/facebookJWT
POST/crm/leads/bulk-import/contactsJWT

Activities 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.created
  • lead.assigned
  • lead.scored (carries score and scoreBreakdown)
  • lead.qualified / lead.disqualified
  • lead.converted (carries contactId and opportunityId)
  • lead.stage_changed (carries fromStage, toStage)

Wire these into Automation flows for Slack alerts, follow-up tasks, or auto-enrollment in nurture campaigns.