Replaced static Mock Enrichment with Apollo Simulator code node that: - Generates Apollo-shaped responses with varying data quality - Routes through the actual Enrichment Mapper & Grader (not bypass) - Email prefix controls response type (full@, partial@, limited@, nomatch@, error@) All 5 grading paths verified. Throughput: 50 concurrent requests, all 200s. Exported canonical workflow JSON from live n8n instance. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
448 lines
18 KiB
JSON
448 lines
18 KiB
JSON
{
|
|
"name": "Lead Enrich Core",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"httpMethod": "POST",
|
|
"path": "lead-enrich",
|
|
"responseMode": "responseNode",
|
|
"options": {}
|
|
},
|
|
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
|
"name": "Webhook",
|
|
"type": "n8n-nodes-base.webhook",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
0,
|
|
0
|
|
],
|
|
"webhookId": "lead-enrich"
|
|
},
|
|
{
|
|
"parameters": {
|
|
"assignments": {
|
|
"assignments": [
|
|
{
|
|
"id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
|
|
"name": "lead.email",
|
|
"value": "={{ $json.body?.email || $json.email || '' }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "d4e5f6a7-b8c9-0123-defa-234567890123",
|
|
"name": "lead.first_name",
|
|
"value": "={{ $json.body?.first_name || $json.first_name || '' }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "e5f6a7b8-c9d0-1234-efab-345678901234",
|
|
"name": "lead.last_name",
|
|
"value": "={{ $json.body?.last_name || $json.last_name || '' }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "f6a7b8c9-d0e1-2345-fabc-456789012345",
|
|
"name": "lead.company_name",
|
|
"value": "={{ $json.body?.company_name || $json.company_name || '' }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "a7b8c9d0-e1f2-3456-abcd-567890123456",
|
|
"name": "lead.source",
|
|
"value": "={{ $json.body?.source || $json.source || 'webhook' }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "b8c9d0e1-f2a3-4567-bcde-678901234567",
|
|
"name": "lead.submitted_at",
|
|
"value": "={{ $now.toISO() }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "c9d0e1f2-a3b4-5678-cdef-789012345678",
|
|
"name": "lead.raw_payload",
|
|
"value": "={{ $json }}",
|
|
"type": "object"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
|
"name": "Input Mapper",
|
|
"type": "n8n-nodes-base.set",
|
|
"typeVersion": 3.3,
|
|
"position": [
|
|
304,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const lead = item.json.lead;\n\n // Trim all string fields\n for (const key of Object.keys(lead)) {\n if (typeof lead[key] === 'string') {\n lead[key] = lead[key].trim();\n }\n }\n\n // Lowercase email\n lead.email = (lead.email || '').toLowerCase();\n\n // Validate email format\n const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n if (!lead.email || !emailRegex.test(lead.email)) {\n item.json._validation_error = true;\n item.json._error_reason = !lead.email\n ? 'Email is missing or empty'\n : `Invalid email format: ${lead.email}`;\n } else {\n item.json._validation_error = false;\n }\n\n // Ensure submitted_at exists\n if (!lead.submitted_at) {\n lead.submitted_at = new Date().toISOString();\n }\n\n results.push(item);\n}\n\nreturn results;"
|
|
},
|
|
"id": "d0e1f2a3-b4c5-6789-defa-890123456789",
|
|
"name": "Clean & Validate",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
608,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": "",
|
|
"typeValidation": "strict",
|
|
"version": 1
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "f2a3b4c5-d6e7-8901-fabc-012345678901",
|
|
"leftValue": "={{ $json._validation_error }}",
|
|
"rightValue": true,
|
|
"operator": {
|
|
"type": "boolean",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "e1f2a3b4-c5d6-7890-efab-901234567890",
|
|
"name": "Validation Router",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
912,
|
|
0
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"assignments": {
|
|
"assignments": [
|
|
{
|
|
"id": "b4c5d6e7-f8a9-0123-bcde-234567890123",
|
|
"name": "error",
|
|
"value": true,
|
|
"type": "boolean"
|
|
},
|
|
{
|
|
"id": "c5d6e7f8-a9b0-1234-cdef-345678901234",
|
|
"name": "error_reason",
|
|
"value": "={{ $json._error_reason }}",
|
|
"type": "string"
|
|
},
|
|
{
|
|
"id": "d6e7f8a9-b0c1-2345-defa-456789012345",
|
|
"name": "lead",
|
|
"value": "={{ $json.lead }}",
|
|
"type": "object"
|
|
},
|
|
{
|
|
"id": "e7f8a9b0-c1d2-3456-efab-567890123456",
|
|
"name": "timestamp",
|
|
"value": "={{ $now.toISO() }}",
|
|
"type": "string"
|
|
}
|
|
]
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "a3b4c5d6-e7f8-9012-abcd-123456789012",
|
|
"name": "Dead Letter",
|
|
"type": "n8n-nodes-base.set",
|
|
"typeVersion": 3.3,
|
|
"position": [
|
|
1200,
|
|
-208
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{ { error: true, error_reason: $json.error_reason, lead: $json.lead, timestamp: $json.timestamp } }}",
|
|
"options": {
|
|
"responseCode": 422
|
|
}
|
|
},
|
|
"id": "f8a9b0c1-d2e3-4567-fabc-678901234567",
|
|
"name": "Dead Letter Response",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
1504,
|
|
-208
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"leftValue": ""
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "b0c1d2e3-f4a5-6789-bcde-890123456789",
|
|
"leftValue": "={{ $env.TEST_MODE }}",
|
|
"rightValue": "true",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"options": {}
|
|
},
|
|
"id": "a9b0c1d2-e3f4-5678-abcd-789012345678",
|
|
"name": "Test Mode Check",
|
|
"type": "n8n-nodes-base.if",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1200,
|
|
208
|
|
]
|
|
},
|
|
{
|
|
"id": "849c61c7-ea72-4816-b0d7-aca0ace3ea9a",
|
|
"name": "Apollo Simulator",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1504,
|
|
112
|
|
],
|
|
"parameters": {
|
|
"mode": "runOnceForAllItems",
|
|
"jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const lead = item.json.lead;\n const email = (lead.email || '').toLowerCase();\n\n // Determine response quality based on email pattern\n // full@ -> Full Profile (all fields populated)\n // partial@ -> Partial Profile (company but missing title/employees)\n // empty@ -> No match (person: null)\n // error@ -> API error (429 rate limit)\n // limited@ -> Limited (person found but no org data)\n // anything else -> random quality based on email hash\n\n let statusCode = 200;\n let headers = { 'x-minute-requests-left': '87', 'x-hourly-requests-left': '950' };\n let body = {};\n\n if (email.startsWith('error@')) {\n // Simulate 429 rate limit\n statusCode = 429;\n body = { error: 'Rate limit exceeded' };\n headers['x-minute-requests-left'] = '0';\n\n } else if (email.startsWith('empty@') || email.startsWith('nomatch@')) {\n // No match found\n body = { person: null, credits_consumed: 0 };\n\n } else if (email.startsWith('partial@')) {\n // Partial: company name but no title or employee count\n body = {\n person: {\n first_name: lead.first_name || 'Jane',\n last_name: lead.last_name || 'Doe',\n title: null,\n linkedin_url: 'https://linkedin.com/in/janedoe',\n city: 'Manchester',\n state: 'England',\n country: 'United Kingdom',\n seniority: null,\n organization: {\n name: lead.company_name || 'PartialCo Ltd',\n primary_domain: 'partialco.com',\n industry: 'Manufacturing',\n estimated_num_employees: null,\n annual_revenue: null,\n linkedin_url: null,\n website_url: 'https://partialco.com'\n }\n },\n credits_consumed: 1\n };\n\n } else if (email.startsWith('limited@')) {\n // Limited: person found but no org at all\n body = {\n person: {\n first_name: lead.first_name || 'Sam',\n last_name: lead.last_name || 'Solo',\n title: 'Freelance Consultant',\n linkedin_url: 'https://linkedin.com/in/samsolo',\n city: 'Bristol',\n state: 'England',\n country: 'United Kingdom',\n seniority: 'Entry',\n organization: {}\n },\n credits_consumed: 1\n };\n\n } else {\n // Full Profile (default for full@ and anything else in test mode)\n body = {\n person: {\n first_name: lead.first_name || 'Sarah',\n last_name: lead.last_name || 'Chen',\n title: 'Head of Operations',\n linkedin_url: 'https://linkedin.com/in/sarahchen',\n city: 'London',\n state: 'England',\n country: 'United Kingdom',\n seniority: 'Director',\n organization: {\n name: lead.company_name || 'Acme Industries',\n primary_domain: 'acmeindustries.co.uk',\n industry: 'Technology',\n estimated_num_employees: 340,\n annual_revenue: 12000000,\n linkedin_url: 'https://linkedin.com/company/acme-industries',\n website_url: 'https://acmeindustries.co.uk'\n }\n },\n credits_consumed: 1\n };\n }\n\n // Output in the same shape as HTTP Request node with fullResponse: true\n results.push({\n json: {\n statusCode,\n headers,\n body,\n // Preserve lead data for downstream reference\n lead\n }\n });\n}\n\nreturn results;"
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "https://api.apollo.io/api/v1/people/match",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
},
|
|
{
|
|
"name": "x-api-key",
|
|
"value": "={{ $env.APOLLO_API_KEY }}"
|
|
}
|
|
]
|
|
},
|
|
"sendBody": true,
|
|
"specifyBody": "json",
|
|
"jsonBody": "={{ JSON.stringify({ email: $json.lead.email, first_name: $json.lead.first_name, last_name: $json.lead.last_name, organization_name: $json.lead.company_name }) }}",
|
|
"options": {
|
|
"response": {
|
|
"response": {
|
|
"fullResponse": true
|
|
}
|
|
},
|
|
"timeout": 30000
|
|
}
|
|
},
|
|
"id": "b4c5d6e7-f8a9-0123-bcde-234567890124",
|
|
"name": "Apollo HTTP Request",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
1504,
|
|
352
|
|
],
|
|
"retryOnFail": true,
|
|
"maxTries": 3,
|
|
"waitBetweenTries": 5000,
|
|
"continueOnFail": true
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "const items = $input.all();\nconst results = [];\n\nfor (const item of items) {\n const response = item.json;\n // Get lead: from response (simulator includes it), or upstream node, or empty\n let lead = response.lead;\n if (!lead) {\n try {\n lead = $('Test Mode Check').first().json.lead;\n } catch(e) {\n lead = {};\n }\n }\n\n // Initialise enrichment structure\n const enrichment = {\n provider: 'apollo',\n person: {\n title: null,\n linkedin_url: null,\n city: null,\n state: null,\n country: null,\n seniority: null\n },\n organisation: {\n name: null,\n domain: null,\n industry: null,\n estimated_employees: null,\n annual_revenue: null,\n linkedin_url: null,\n website_url: null\n },\n enrichment_error: false,\n credits_consumed: 0,\n rate_limit_remaining: null,\n credits_low: false\n };\n\n // Check for HTTP errors via the response metadata\n const statusCode = response.statusCode || response.$response?.statusCode || 200;\n\n // Extract rate limit headers if available\n const headers = response.headers || response.$response?.headers || {};\n const rateRemaining = headers['x-minute-requests-left'];\n if (rateRemaining !== undefined) {\n enrichment.rate_limit_remaining = parseInt(rateRemaining, 10);\n enrichment.credits_low = enrichment.rate_limit_remaining < 50;\n }\n\n // Handle response based on status\n if (statusCode === 429) {\n enrichment.enrichment_error = true;\n } else if (statusCode >= 400) {\n enrichment.enrichment_error = true;\n } else {\n // Look for person data in the response body\n const body = response.body || response;\n const person = body.person || null;\n\n if (person && person !== null) {\n const org = person.organization || {};\n\n enrichment.person.title = person.title || null;\n enrichment.person.linkedin_url = person.linkedin_url || null;\n enrichment.person.city = person.city || null;\n enrichment.person.state = person.state || null;\n enrichment.person.country = person.country || null;\n enrichment.person.seniority = person.seniority || null;\n\n enrichment.organisation.name = org.name || null;\n enrichment.organisation.domain = org.primary_domain || null;\n enrichment.organisation.industry = org.industry || null;\n enrichment.organisation.estimated_employees = org.estimated_num_employees || null;\n enrichment.organisation.annual_revenue = org.annual_revenue || null;\n enrichment.organisation.linkedin_url = org.linkedin_url || null;\n enrichment.organisation.website_url = org.website_url || null;\n\n enrichment.credits_consumed = body.credits_consumed || 1;\n }\n // else: person is null \u00e2\u20ac\u201d no match, all fields stay null, no error, no credits\n }\n\n // Grade the profile\n const hasCompany = enrichment.organisation.name !== null;\n const hasTitle = enrichment.person.title !== null;\n const hasEmployees = enrichment.organisation.estimated_employees !== null;\n\n let profileGrade;\n if (enrichment.enrichment_error || !hasCompany) {\n profileGrade = 'Limited';\n } else if (hasCompany && hasTitle && hasEmployees) {\n profileGrade = 'Full Profile';\n } else {\n profileGrade = 'Partial Profile';\n }\n\n // Count populated fields\n const personFields = Object.values(enrichment.person).filter(v => v !== null).length;\n const orgFields = Object.values(enrichment.organisation).filter(v => v !== null).length;\n\n const grading = {\n profile_grade: profileGrade,\n fields_populated: personFields + orgFields,\n graded_at: new Date().toISOString()\n };\n\n results.push({\n json: {\n lead,\n enrichment,\n grading\n }\n });\n}\n\nreturn results;"
|
|
},
|
|
"id": "c5d6e7f8-a9b0-1234-cdef-345678901235",
|
|
"name": "Enrichment Mapper & Grader",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1808,
|
|
352
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"respondWith": "json",
|
|
"responseBody": "={{ $json }}",
|
|
"options": {
|
|
"responseCode": 200
|
|
}
|
|
},
|
|
"id": "d6e7f8a9-b0c1-2345-defa-456789012346",
|
|
"name": "Success Response",
|
|
"type": "n8n-nodes-base.respondToWebhook",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
2112,
|
|
208
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"workflowId": "xrvKeRGJ297kWSno",
|
|
"options": {}
|
|
},
|
|
"id": "e12f94e6-c02d-4a40-b969-05d4a1251dc9",
|
|
"name": "Call Actions Workflow",
|
|
"type": "n8n-nodes-base.executeWorkflow",
|
|
"typeVersion": 1.2,
|
|
"position": [
|
|
2400,
|
|
200
|
|
],
|
|
"continueOnFail": true
|
|
}
|
|
],
|
|
"connections": {
|
|
"Webhook": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Input Mapper",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Input Mapper": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Clean & Validate",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Clean & Validate": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Validation Router",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Validation Router": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Dead Letter",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Test Mode Check",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Dead Letter": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Dead Letter Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Test Mode Check": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Apollo Simulator",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Apollo HTTP Request",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Apollo HTTP Request": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Enrichment Mapper & Grader",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Enrichment Mapper & Grader": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Success Response",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Success Response": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Call Actions Workflow",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Apollo Simulator": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Enrichment Mapper & Grader",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1",
|
|
"callerPolicy": "workflowsFromSameOwner",
|
|
"availableInMCP": false
|
|
}
|
|
} |