This system receives leads from GHL, scores them against all eligible orders using a 0-100 point algorithm, and delivers them via webhook to the winning broker's CRM.
POST https://ppl-leadr-mgmt.vercel.app/api/leads/incoming
Content-Type: application/json
{
"ghl_contact_id": "ghl_abc123", // REQUIRED — unique GHL contact ID
"vertical": "MCA", // optional — e.g. "MCA", "Equipment", "SBA"
"first_name": "John", // optional
"last_name": "Smith", // optional
"email": "john@example.com", // optional — valid email
"phone": "+17025551234", // optional — min 7 chars
"business_name": "Smith Industries", // optional
"credit_score": 720, // optional — integer 300–850 (can be string, auto-coerced)
"funding_amount": 50000, // RECOMMENDED — positive number (rejected if missing/invalid)
"funding_purpose": "expansion", // optional
"state": "NV", // optional
"ai_call_notes": "Interested in MCA", // optional
"ai_call_status": "completed" // optional
}| Field | Type | Required | Notes |
|---|---|---|---|
ghl_contact_id |
string | ✅ Yes | Must be unique per lead. Used for idempotency — duplicate webhooks with same ID return the existing lead. |
vertical |
string | Optional | Lead's funding vertical (e.g. "MCA", "Equipment", "SBA"). Used for order matching. If omitted, only orders accepting "All" verticals will match. |
first_name |
string | Optional | |
last_name |
string | Optional | |
email |
string | Optional | Must be valid email format if provided. Also used for email+phone dedup. |
phone |
string | Optional | Min 7 characters. Also used for email+phone dedup. |
business_name |
string | Optional | |
credit_score |
number | Optional | Integer 300–850. Can be sent as string — auto-coerced. Leads with credit < 600 are rejected. |
funding_amount |
number | Recommended | Positive number. Can be sent as string — auto-coerced. Leads with missing or invalid funding_amount are rejected. |
funding_purpose |
string | Optional | |
state |
string | Optional | US state code or full name. |
ai_call_notes |
string | Optional | Notes from AI voice call. |
ai_call_status |
string | Optional | Status of the AI call (e.g. "completed", "no_answer"). |
Before scoring, every lead passes through pre-flight checks. If any check fails, the lead is stored with status rejected and never enters the scoring engine.
| Check | Condition | Rejection Reason |
|---|---|---|
| Credit floor | credit_score provided and < 600 |
credit_too_low |
| Loan amount | funding_amount missing, null, or <= 0 |
invalid_loan_amount |
| Active orders | Zero active orders in the system | no_active_orders |
Two layers of dedup run before any validation:
- ghl_contact_id — exact match on contact ID (existing behavior)
- email + phone — if both are provided and match an existing lead (case-insensitive email), returns the existing lead
Success — lead created & assigned
{
"lead_id": "a1b2c3d4-...",
"assignment": {
"status": "assigned",
"broker_id": "f7e8d9c0-...",
"order_id": "b2c3d4e5-..."
}
}Success — lead created, no matching order
{
"lead_id": "a1b2c3d4-...",
"assignment": {
"status": "unassigned",
"reason": "no matching order for vertical"
}
}Rejected — pre-flight validation failed
{
"lead_id": "a1b2c3d4-...",
"status": "rejected",
"reason": "credit_too_low"
}Duplicate (same ghl_contact_id or email+phone already exists)
{
"lead_id": "a1b2c3d4-...",
"status": "duplicate",
"message": "lead already exists"
}Validation Error (400)
{
"error": "invalid_payload",
"details": { ... }
}curl -X POST https://ppl-leadr-mgmt.vercel.app/api/leads/incoming \
-H "Content-Type: application/json" \
-d '{
"ghl_contact_id": "ghl_test_001",
"vertical": "MCA",
"first_name": "Jane",
"last_name": "Doe",
"email": "jane@example.com",
"phone": "+15551234567",
"credit_score": 680,
"funding_amount": 35000,
"state": "TX"
}'PATCH https://ppl-leadr-mgmt.vercel.app/api/leads/update
Content-Type: application/json
Updates an existing lead by ghl_contact_id. Use this to push AI call results or updated lead info back from GHL after the lead has been created.
{
"ghl_contact_id": "ghl_abc123", // REQUIRED — identifies which lead to update
"ai_call_notes": "Qualified, wants $50k MCA",
"ai_call_status": "completed",
"credit_score": 720,
"funding_amount": 50000
}| Field | Type | Notes |
|---|---|---|
ghl_contact_id |
string | Required — used to find the lead. |
first_name |
string | |
last_name |
string | |
email |
string | Valid email or empty string. |
phone |
string | |
business_name |
string | |
vertical |
string | |
credit_score |
number | Integer 300–850. Auto-coerced from string. |
funding_amount |
number | Positive number. Auto-coerced from string. |
funding_purpose |
string | |
state |
string | |
ai_call_notes |
string | |
ai_call_status |
string |
Protected fields (cannot be updated via this endpoint): assigned_broker_id, assigned_order_id, assigned_at, status. These are managed by the assignment engine only.
Success
{
"lead_id": "a1b2c3d4-...",
"updated_fields": ["ai_call_notes", "ai_call_status", "credit_score"]
}Lead not found (404)
{
"error": "lead_not_found",
"ghl_contact_id": "ghl_abc123"
}Validation Error (400)
{
"error": "validation_error",
"details": { ... }
}curl -X PATCH https://ppl-leadr-mgmt.vercel.app/api/leads/update \
-H "Content-Type: application/json" \
-d '{
"ghl_contact_id": "ghl_test_001",
"ai_call_status": "completed",
"ai_call_notes": "Qualified. Wants $50k MCA for expansion. Good credit."
}'When a lead passes pre-flight validation, the scoring engine runs automatically:
Each active order is checked against hard filters. If any filter fails, the order is disqualified:
| Filter | Rule |
|---|---|
| Order status | Must be active |
| Broker status | Broker's assignment_status must be active |
| Capacity | leads_remaining > 0 (unless bonus mode is on) |
| Vertical match | Lead's vertical must be in order's verticals array (or order accepts "All") |
| Credit tier 680+ | Orders with credit_score_min >= 680 never receive leads with credit < 680 |
| Credit tier 600+ | Orders with credit_score_min >= 600 reject leads below that minimum |
| Loan range | If lead has funding_amount, it must fall within order's loan_min / loan_max range |
Each eligible order is scored:
| Component | Max Points | Formula |
|---|---|---|
| Credit Fit | 40 | (lead.credit - order.credit_min) / (850 - order.credit_min) * 40 |
| Capacity | 30 | (1 - fill_rate) * 30 where fill_rate = delivered / total |
| Tier Match | 20 | 20pts exact tier match, 10pts fallback (680+ lead to 600+ order) |
| Loan Fit | 10 | 10pts if funding_amount within order's loan range |
| Priority Bonus | +8 | Orders with priority = "high" |
| Urgency Bonus | +5/-5 | +5 if fill > 80%, -5 if fill < 10% |
Tiebreaker: lowest fill rate wins (most capacity remaining).
The highest-scoring eligible order wins. The lead is assigned atomically using advisory locks to prevent race conditions.
Every scoring decision is logged. For each lead, a routing_logs entry is created for every order considered (not just the winner), including:
- Whether it was eligible or disqualified (and why)
- Full score breakdown (all 6 components)
- Whether it was the selected winner
Admins can view this on the lead detail page under "Routing Audit".
If no eligible order exists, the lead goes to the unassigned queue. An admin can manually assign it from the dashboard.
Assignment failure does not block the API response — the lead is always stored, and the assignment result is returned in the response body.
| Type | Behavior |
|---|---|
one_time |
Fixed lead allocation. When leads_remaining hits 0, order completes. |
monthly |
Auto-resets on the 1st of each month. leads_delivered resets to 0, leads_remaining restores to total_leads, completed orders reactivated to active. Each reset is logged in the activity log. |
When a lead is assigned to a broker who has a crm_webhook_url configured:
- A
webhook_deliveryrecord is created with statuspending - The payload contains the full lead object as JSON
- Delivery status is tracked:
pending->sentorfailed - Retry count and error messages are logged
- Deliveries respect broker contact hours — out-of-window deliveries are queued and released when the window opens
- Delivery history is visible on the lead's detail page in the dashboard