This workflow follows the Execute Workflow Trigger → Postgres recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "oCCiiwvp7DYqoFb3",
"name": "Enrich company L1",
"nodes": [
{
"id": "new-webhook-id",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"position": [
200,
100
],
"parameters": {
"httpMethod": "POST",
"path": "l1-enrich",
"responseMode": "lastNode"
},
"typeVersion": 2
},
{
"parameters": {
"workflowInputs": {
"values": [
{
"name": "record_id"
}
]
}
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
-304,
-112
],
"id": "1b015495-c387-433e-b764-9c1563365e22",
"name": "When Executed by Another Workflow"
},
{
"id": "bacee63a-d5e9-40ac-a336-18f1f5584a36",
"name": "Read Company",
"type": "n8n-nodes-base.postgres",
"position": [
-48,
-112
],
"parameters": {
"operation": "executeQuery",
"query": "SELECT id, name, domain, status, industry, company_size, revenue_range, tier, enrichment_cost_usd, owner_id, tenant_id FROM companies WHERE id = '{{ $json.body?.company_id || $json.company_id }}'",
"options": {}
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"typeVersion": 2.6
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict"
},
"conditions": [
{
"id": "condition1",
"leftValue": "={{ $json.status }}",
"rightValue": "new",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "condition2",
"leftValue": "={{ $json.status }}",
"rightValue": "enrichment_failed",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "or"
}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
144,
-112
],
"id": "f6a147d1-771c-4320-84c6-59096c5bb7af",
"name": "If"
},
{
"parameters": {
"messages": {
"message": [
{
"content": "=You verify company basics for B2B sales qualification. Return ONLY the JSON structure requested. No commentary.\n\nRules:\n- Official filings > company website > third-party databases > LinkedIn\n- If data is for parent company, not the target entity, mark 'unverified - possible parent'\n- Revenue/employee ratio above \u20ac500K/employee = flag as 'unverified - check parent vs subsidiary'\n- Revenue must be for THIS legal entity, not group/parent\n- 'unverified' is always better than a guess",
"role": "system"
},
{
"content": "=Verify company basics: {{ $json.name }} ({{ $json.domain }})\n\nLinkedIn claims to verify or correct:\n- Industry: {{ $json.industry }}\n- Employees: {{ $json.company_size }}\n- Revenue: {{ $json.revenue_range }}\n\nReturn JSON only:\n{\n \"company_name\": \"Official registered name\",\n \"summary\": \"What they do, 1-2 sentences max\",\n \"b2b\": true/false,\n \"hq\": \"City, Country\",\n \"markets\": \"Countries they operate in, comma-separated, or 'domestic only'\",\n \"founded\": \"Year or 'Unknown'\",\n \"ownership\": \"Family / PE-backed (investor name) / Public (exchange:ticker) / Subsidiary of [parent] / Founder-led / Unknown\",\n \"industry\": \"Corrected industry or 'confirmed'\",\n \"business_model\": \"Manufacturer / Distributor / Service provider / SaaS / Platform / Other\",\n \"revenue_eur_m\": \"Number in EUR millions for THIS entity, or 'unverified'\",\n \"revenue_year\": \"Year of revenue figure, or null\",\n \"revenue_source\": \"Source name, or null\",\n \"employees\": \"Number or range for THIS entity, or 'unverified'\",\n \"employees_source\": \"Source name, or null\",\n \"confidence\": \"high / medium / low\",\n \"flags\": [\"list of any issues, e.g. 'possible parent data', 'domain mismatch', 'conflicting sources'\"]\n}"
}
]
},
"options": {
"maxTokens": 600,
"temperature": 0.1,
"searchRecency": "month"
},
"requestOptions": {}
},
"type": "n8n-nodes-base.perplexity",
"typeVersion": 1,
"position": [
400,
-128
],
"id": "023732bc-65d4-4dd1-ab2f-7ad3b76fdb22",
"name": "Basic Company Reseach",
"credentials": {
"perplexityApi": {
"name": "<your credential>"
}
},
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// ============================================================================\n// STAGE A \u2014 TRIAGE: Research QC + Tier Requalification (v5 \u2014 Postgres)\n// ============================================================================\n// Mode: Run Once for Each Item\n// After: Perplexity basic research\n// Input: Perplexity response ($('Basic Company Reseach'))\n// + Postgres record ($('Read Company'))\n// Output: verdict + corrected tier + Postgres-ready fields\n// + research_directory scores\n// Next: Update Company \u2192 Save Research Asset\n// ============================================================================\n\nconst original = $('Read Company').item.json;\nconst perplexity = $('Basic Company Reseach').item.json;\nconst rawContent = perplexity.choices?.[0]?.message?.content || '';\nconst researchCost = perplexity.usage?.cost?.total_cost || 0;\nconst model = perplexity.model || 'unknown';\n\n// -----------------------------------------------------------------------\n// 1. PARSE RESEARCH JSON\n// -----------------------------------------------------------------------\nlet research = null;\nlet parseError = false;\n\ntry {\n let cleaned = rawContent.trim();\n if (cleaned.startsWith('```')) {\n cleaned = cleaned.replace(/^```(?:json)?\\s*/, '').replace(/\\s*```$/, '');\n }\n research = JSON.parse(cleaned);\n} catch (e) {\n parseError = true;\n}\n\nif (parseError || !research) {\n return {\n record_id: original.id,\n company_name: original.name || 'Unknown',\n triage_verdict: 'manual_review',\n pg_status: 'triage_review',\n pg_triage_notes: `PARSE_ERROR: Failed to parse Perplexity response (${rawContent.length} chars). Manual research needed.`,\n pg_triage_score: 0,\n research_quality_score: 0,\n research_confidence_score: 0,\n pg_enrichment_cost: (parseFloat(original.enrichment_cost_usd) || 0) + researchCost,\n raw_research: rawContent\n };\n}\n\n// -----------------------------------------------------------------------\n// 2. HELPERS\n// -----------------------------------------------------------------------\nfunction parseRevenue(raw) {\n if (!raw || raw === 'unverified' || raw === 'Unknown' || raw === 'N/A' || raw === 'null') return null;\n const str = String(raw).toLowerCase().replace(/[\u20ac$\u00a3,\\s]/g, '');\n let match = str.match(/([\\d.]+)\\s*(b|billion)/i);\n if (match) return parseFloat(match[1]) * 1000;\n match = str.match(/([\\d.]+)\\s*(m|million)/i);\n if (match) return parseFloat(match[1]);\n match = str.match(/^([\\d.]+)$/);\n if (match) {\n const val = parseFloat(match[1]);\n if (val > 1000000) return val / 1000000;\n return val;\n }\n return null;\n}\n\nfunction parseEmployees(raw) {\n if (!raw || raw === 'unverified' || raw === 'Unknown' || raw === 'N/A' || raw === 'null') return null;\n const str = String(raw).replace(/[,\\s+]/g, '');\n let match = str.match(/(\\d+)-(\\d+)/);\n if (match) return Math.round((parseInt(match[1]) + parseInt(match[2])) / 2);\n match = str.match(/(\\d+)\\+?/);\n if (match) return parseInt(match[1]);\n return null;\n}\n\n// Parse PG revenue_range enum to numeric range\nfunction parseLinkedInRevenue(raw) {\n if (!raw) return null;\n const str = String(raw);\n const ranges = {\n 'micro': { min: 0, max: 2 },\n 'small': { min: 2, max: 10 },\n 'medium': { min: 10, max: 50 },\n 'mid_market': { min: 50, max: 500 },\n 'enterprise': { min: 500, max: 50000 },\n };\n return ranges[str] || null;\n}\n\n// Parse PG company_size enum to numeric range\nfunction parseLinkedInEmployees(raw) {\n if (!raw) return null;\n const str = String(raw);\n const ranges = {\n 'micro': { min: 1, max: 20 },\n 'startup': { min: 20, max: 49 },\n 'smb': { min: 50, max: 199 },\n 'mid_market': { min: 200, max: 1999 },\n 'enterprise': { min: 2000, max: 50000 },\n };\n return ranges[str] || null;\n}\n\n// Geo cluster from HQ only\nfunction deriveGeoCluster(hq) {\n const text = (hq || '').toLowerCase();\n if (/czech|slovakia|poland|hungary|romania|bulgaria|croatia|slovenia|serbia|bosnia|albania|north macedonia|moldova|montenegro|kosovo|estonia|latvia|lithuania/i.test(text)) return 'cee';\n if (/germany|austria|switzerland|liechtenstein/i.test(text)) return 'dach';\n if (/sweden|norway|denmark|finland|iceland/i.test(text)) return 'nordics';\n if (/netherlands|belgium|luxembourg/i.test(text)) return 'benelux';\n if (/uk|united kingdom|england|scotland|wales|ireland/i.test(text)) return 'uk_ireland';\n if (/spain|italy|portugal|greece|malta|cyprus/i.test(text)) return 'southern_europe';\n if (/united states|usa|us\\b|america/i.test(text)) return 'us';\n return 'other';\n}\n\n// Returns PG tier enum value\nfunction formatTier(tier) {\n const map = {\n 'Platinum': 'tier_1_platinum',\n 'Gold': 'tier_2_gold',\n 'Silver': 'tier_3_silver',\n 'Bronze': 'tier_4_bronze',\n 'Copper': 'tier_5_copper',\n 'Deprioritized': 'deprioritize',\n };\n return map[tier] || null;\n}\n\n// Returns PG revenue_range enum value\nfunction revenueToBucket(revM) {\n if (revM === null) return null;\n if (revM < 2) return 'micro';\n if (revM < 10) return 'small';\n if (revM < 50) return 'medium';\n if (revM < 500) return 'mid_market';\n return 'enterprise';\n}\n\n// Returns PG company_size enum value\nfunction employeesToBucket(emp) {\n if (emp === null) return null;\n if (emp < 20) return 'micro';\n if (emp < 50) return 'startup';\n if (emp < 200) return 'smb';\n if (emp < 2000) return 'mid_market';\n return 'enterprise';\n}\n\n// Returns PG ownership_type enum value\nfunction mapOwnership(raw) {\n if (!raw || raw.toLowerCase() === 'unknown') return null;\n const l = raw.toLowerCase();\n if (l.includes('family')) return 'family_owned';\n if (l.includes('pe') || l.includes('private equity') || l.includes('backed')) return 'pe_backed';\n if (l.includes('vc') || l.includes('venture')) return 'vc_backed';\n if (l.includes('public') || l.includes('listed')) return 'public';\n if (l.includes('state') || l.includes('government')) return 'state_owned';\n if (l.includes('founder') || l.includes('bootstrap')) return 'bootstrapped';\n if (l.includes('subsidiary')) return 'other';\n return 'other';\n}\n\n// Display-format tier for triage notes (human-readable)\nfunction displayTier(pgTier) {\n const map = {\n 'tier_1_platinum': 'Tier 1 - Platinum',\n 'tier_2_gold': 'Tier 2 - Gold',\n 'tier_3_silver': 'Tier 3 - Silver',\n 'tier_4_bronze': 'Tier 4 - Bronze',\n 'tier_5_copper': 'Tier 5 - Copper',\n 'deprioritize': 'Deprioritize',\n };\n return map[pgTier] || pgTier;\n}\n\n// -----------------------------------------------------------------------\n// 3. EXTRACT & NORMALIZE\n// -----------------------------------------------------------------------\nconst flags = [];\nconst contradictions = [];\n\nconst researchRevenue = parseRevenue(research.revenue_eur_m);\nconst researchEmployees = parseEmployees(research.employees);\nconst researchConfidence = (research.confidence || 'low').toLowerCase();\n\nconst linkedinRevRange = parseLinkedInRevenue(original.revenue_range);\nconst linkedinEmpRange = parseLinkedInEmployees(original.company_size);\n\nconst isB2B = research.b2b === true;\n\n// Import model-reported flags\nif (Array.isArray(research.flags)) {\n for (const f of research.flags) {\n if (f && typeof f === 'string' && f.trim().length > 0) {\n flags.push('MODEL: ' + f.trim());\n }\n }\n}\n\n// -----------------------------------------------------------------------\n// 4. CONTRADICTION DETECTION\n// -----------------------------------------------------------------------\n\nif (researchRevenue !== null && linkedinRevRange) {\n if (researchRevenue > linkedinRevRange.max * 3) {\n contradictions.push({\n field: 'revenue', severity: 'critical',\n detail: `Research \u20ac${researchRevenue}M vs LinkedIn \u20ac${linkedinRevRange.min}-${linkedinRevRange.max}M (${Math.round(researchRevenue / linkedinRevRange.max)}x higher)`\n });\n flags.push('REVENUE_MISMATCH_HIGH');\n } else if (linkedinRevRange.min > 0 && researchRevenue < linkedinRevRange.min * 0.3) {\n contradictions.push({\n field: 'revenue', severity: 'critical',\n detail: `Research \u20ac${researchRevenue}M vs LinkedIn \u20ac${linkedinRevRange.min}-${linkedinRevRange.max}M (significantly lower)`\n });\n flags.push('REVENUE_MISMATCH_LOW');\n }\n}\n\nif (researchEmployees !== null && linkedinEmpRange) {\n if (researchEmployees > linkedinEmpRange.max * 3) {\n contradictions.push({\n field: 'employees', severity: 'warning',\n detail: `Research ${researchEmployees} vs LinkedIn ${linkedinEmpRange.min}-${linkedinEmpRange.max}`\n });\n flags.push('EMPLOYEE_MISMATCH_HIGH');\n } else if (researchEmployees < linkedinEmpRange.min * 0.3) {\n contradictions.push({\n field: 'employees', severity: 'warning',\n detail: `Research ${researchEmployees} vs LinkedIn ${linkedinEmpRange.min}-${linkedinEmpRange.max}`\n });\n flags.push('EMPLOYEE_MISMATCH_LOW');\n }\n}\n\nif (researchRevenue !== null && researchEmployees && researchEmployees > 0) {\n const ratioPerEmp = (researchRevenue * 1000000) / researchEmployees;\n if (ratioPerEmp > 500000) {\n flags.push('REV_EMP_RATIO_SUSPICIOUS');\n contradictions.push({\n field: 'ratio', severity: 'warning',\n detail: `\u20ac${Math.round(ratioPerEmp / 1000)}K/employee \u2014 possible parent data`\n });\n }\n}\n\nif (!isB2B) flags.push('NOT_B2B');\nif (researchConfidence === 'low') flags.push('LOW_CONFIDENCE');\n\n// -----------------------------------------------------------------------\n// 5. RESEARCH QUALITY SCORE (0-10)\n// -----------------------------------------------------------------------\nlet qualityScore = 0;\n\nif (research.summary && research.summary.length > 30) qualityScore += 2;\nelse if (research.summary) qualityScore += 1;\n\nif (isB2B) qualityScore += 1;\nif (researchRevenue !== null) qualityScore += 2;\nif (researchEmployees !== null) qualityScore += 1;\n\nconst ownership = (research.ownership || '').toLowerCase();\nif (ownership && ownership !== 'unknown') qualityScore += 1;\nif (research.business_model && research.business_model !== 'Other') qualityScore += 0.5;\nif (research.markets && research.markets !== 'Unknown' && research.markets !== 'domestic only') qualityScore += 0.5;\nif (research.revenue_source) qualityScore += 0.5;\nif (research.employees_source) qualityScore += 0.5;\n\nif (researchConfidence === 'high') qualityScore += 1;\nelse if (researchConfidence === 'medium') qualityScore += 0.5;\n\nif (contradictions.some(c => c.severity === 'critical')) qualityScore -= 2;\nif (flags.includes('REV_EMP_RATIO_SUSPICIOUS')) qualityScore -= 1;\nif (flags.some(f => f.startsWith('MODEL:'))) qualityScore -= 0.5;\n\nqualityScore = Math.max(0, Math.min(10, Math.round(qualityScore * 10) / 10));\n\n// Confidence as numeric score for research_directory\nconst confidenceScore = researchConfidence === 'high' ? 0.9\n : researchConfidence === 'medium' ? 0.6\n : 0.3;\n\n// -----------------------------------------------------------------------\n// 6. TIER REQUALIFICATION\n// -----------------------------------------------------------------------\n\nconst bestRevenue = researchRevenue;\n\nlet bestEmployees = researchEmployees;\nlet employeeSource = researchEmployees ? 'research' : 'none';\nif (!researchEmployees && linkedinEmpRange) {\n bestEmployees = Math.round((linkedinEmpRange.min + linkedinEmpRange.max) / 2);\n employeeSource = 'linkedin';\n flags.push('EMPLOYEES_FROM_LINKEDIN');\n}\n\nconst isPE = ownership.includes('pe') || ownership.includes('private equity')\n || ownership.includes('backed') || ownership.includes('venture');\nconst isSubsidiary = ownership.includes('subsidiary');\n\nconst summary = (research.summary || '').toLowerCase();\nconst industry = (research.industry || '').toLowerCase();\n\nconst isPEFund = industry.includes('private equity') || industry.includes('venture capital')\n || industry.includes('fund management') || industry.includes('investment management')\n || summary.includes('private equity firm') || summary.includes('venture capital firm')\n || summary.includes('investment fund');\n\nconst originalTier = original.tier || null;\n\nlet newTier = null;\nlet tierReason = '';\nconst tierSignals = [];\n\nif (isPEFund) {\n newTier = 'Bronze';\n tierReason = 'PE/VC fund \u2014 portfolio multiplier opportunity';\n tierSignals.push('pe_fund_detected');\n\n} else if (bestRevenue !== null) {\n if (bestRevenue >= 200) {\n newTier = 'Platinum';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M \u2192 Platinum (\u20ac200M+)`;\n tierSignals.push('revenue_platinum');\n } else if (bestRevenue >= 50) {\n newTier = 'Gold';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M \u2192 Gold (\u20ac50-200M)`;\n tierSignals.push('revenue_gold');\n } else if (bestRevenue >= 20) {\n newTier = 'Silver';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M \u2192 Silver (\u20ac20-100M)`;\n tierSignals.push('revenue_silver');\n if (bestEmployees !== null && bestEmployees < 50) {\n newTier = 'Copper';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M but ~${Math.round(bestEmployees)} emp \u2192 Copper`;\n tierSignals.push('low_headcount_downgrade');\n }\n } else if (bestRevenue >= 10) {\n if (bestEmployees !== null && bestEmployees <= 50) {\n newTier = 'Copper';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M, ~${Math.round(bestEmployees)} emp \u2192 Copper`;\n tierSignals.push('revenue_copper', 'lean_headcount');\n } else {\n newTier = 'Bronze';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M \u2192 Bronze`;\n tierSignals.push('revenue_bronze');\n }\n } else if (bestRevenue >= 8) {\n if (bestEmployees !== null && bestEmployees <= 50) {\n newTier = 'Copper';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M, ~${Math.round(bestEmployees)} emp \u2192 Copper (borderline)`;\n tierSignals.push('revenue_borderline_copper');\n } else {\n newTier = 'Deprioritized';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M \u2014 below Silver, above Copper emp max`;\n tierSignals.push('revenue_below_threshold');\n }\n } else {\n newTier = 'Deprioritized';\n tierReason = `Verified \u20ac${Math.round(bestRevenue)}M \u2014 below \u20ac8M minimum`;\n tierSignals.push('revenue_too_low');\n }\n\n} else if (bestEmployees !== null) {\n flags.push('TIER_FROM_EMPLOYEE_PROXY');\n if (bestEmployees >= 500) {\n newTier = 'Gold';\n tierReason = `No verified revenue, ~${Math.round(bestEmployees)} emp \u2192 Gold (conservative proxy)`;\n tierSignals.push('employee_proxy_gold');\n } else if (bestEmployees >= 100) {\n newTier = 'Silver';\n tierReason = `No verified revenue, ~${Math.round(bestEmployees)} emp \u2192 Silver (proxy)`;\n tierSignals.push('employee_proxy_silver');\n } else if (bestEmployees >= 50) {\n newTier = 'Bronze';\n tierReason = `No verified revenue, ~${Math.round(bestEmployees)} emp \u2192 Bronze (proxy)`;\n tierSignals.push('employee_proxy_bronze');\n } else if (bestEmployees >= 2) {\n newTier = 'Copper';\n tierReason = `No verified revenue, ~${Math.round(bestEmployees)} emp \u2192 Copper (proxy)`;\n tierSignals.push('employee_proxy_copper');\n } else {\n newTier = 'Deprioritized';\n tierReason = 'No verified revenue, employee count too low';\n tierSignals.push('insufficient_data');\n }\n\n} else {\n newTier = 'Unclassified';\n tierReason = 'No verified revenue or employee count';\n tierSignals.push('no_sizing_data');\n flags.push('NO_SIZING_DATA');\n}\n\n// --- Tier adjustments ---\nif (isPE && !isPEFund && newTier === 'Silver') {\n flags.push('PE_BACKED_SILVER_COULD_BE_GOLD');\n}\n\nif (isSubsidiary) {\n flags.push('SUBSIDIARY_CHECK_REVENUE');\n if (researchRevenue !== null && researchRevenue >= 200) {\n contradictions.push({\n field: 'revenue', severity: 'critical',\n detail: 'Subsidiary \u2014 revenue figure may belong to parent company'\n });\n }\n}\n\nconst pgNewTier = formatTier(newTier);\n\nlet tierChanged = false;\nlet tierDirection = 'none';\nif (originalTier && pgNewTier) {\n if (pgNewTier !== originalTier) {\n tierChanged = true;\n const tierOrder = ['deprioritize', 'tier_5_copper', 'tier_4_bronze', 'tier_3_silver', 'tier_2_gold', 'tier_1_platinum'];\n const oldIdx = tierOrder.indexOf(originalTier);\n const newIdx = tierOrder.indexOf(pgNewTier);\n if (oldIdx >= 0 && newIdx >= 0) {\n tierDirection = newIdx > oldIdx ? 'upgraded' : 'downgraded';\n } else {\n tierDirection = 'reclassified';\n }\n flags.push(`TIER_${tierDirection.toUpperCase()}`);\n }\n}\n\n// -----------------------------------------------------------------------\n// 7. GEOGRAPHY & INDUSTRY\n// -----------------------------------------------------------------------\nconst hq = research.hq || '';\nconst hqLower = hq.toLowerCase();\n\nconst europeanCountries = [\n 'czech', 'slovakia', 'germany', 'austria', 'switzerland', 'netherlands',\n 'belgium', 'luxembourg', 'denmark', 'sweden', 'norway', 'finland', 'iceland',\n 'poland', 'hungary', 'romania', 'bulgaria', 'croatia', 'slovenia', 'serbia',\n 'france', 'spain', 'italy', 'portugal', 'ireland', 'uk', 'united kingdom',\n 'great britain', 'england', 'scotland', 'estonia', 'latvia', 'lithuania',\n 'greece', 'cyprus', 'malta'\n];\nconst isEuropean = europeanCountries.some(c => hqLower.includes(c));\nif (!isEuropean && hq && hqLower !== 'unknown') flags.push('NON_EUROPEAN_HQ');\n\nconst targetIndustries = [\n 'manufacturing', 'logistics', 'distribution', 'retail', 'pharma',\n 'pharmaceutical', 'financial', 'fintech', 'banking', 'insurance',\n 'energy', 'healthcare', 'professional services', 'construction',\n 'industrial', 'automotive', 'food', 'chemical', 'engineering',\n 'solar', 'renewable'\n];\nconst isTargetIndustry = targetIndustries.some(ind =>\n industry.includes(ind) || summary.includes(ind)\n);\nif (!isTargetIndustry && industry && industry !== 'confirmed') {\n flags.push('NON_TARGET_INDUSTRY');\n}\n\nconst geoCluster = deriveGeoCluster(hq);\n\n// -----------------------------------------------------------------------\n// 8. TRIAGE VERDICT\n// -----------------------------------------------------------------------\nlet verdict = 'pass';\nconst verdictReason = [];\n\n// Hard disqualifiers\nif (!isB2B) {\n verdict = 'disqualify';\n verdictReason.push('Not B2B');\n}\nif (newTier === 'Deprioritized') {\n verdict = 'disqualify';\n verdictReason.push('Below minimum revenue/size threshold');\n}\n\n// Manual review triggers\nif (verdict !== 'disqualify') {\n\n if (contradictions.some(c => c.severity === 'critical')) {\n verdict = 'manual_review';\n verdictReason.push('Critical data contradictions');\n }\n\n if (newTier === 'Unclassified') {\n verdict = 'manual_review';\n verdictReason.push('No sizing data \u2014 cannot assign tier');\n }\n\n if (flags.includes('SUBSIDIARY_CHECK_REVENUE') && researchRevenue !== null && researchRevenue >= 200) {\n verdict = 'manual_review';\n verdictReason.push('Subsidiary with high revenue \u2014 verify entity');\n }\n\n if (tierChanged) {\n const tierOrder = ['deprioritize', 'tier_5_copper', 'tier_4_bronze', 'tier_3_silver', 'tier_2_gold', 'tier_1_platinum'];\n const oldIdx = tierOrder.indexOf(originalTier);\n const newIdx = tierOrder.indexOf(pgNewTier);\n if (oldIdx >= 0 && newIdx >= 0 && Math.abs(newIdx - oldIdx) >= 2) {\n verdict = 'manual_review';\n verdictReason.push(`Large tier shift: ${displayTier(originalTier)} \u2192 ${displayTier(pgNewTier)}`);\n }\n }\n\n if (qualityScore <= 3) {\n verdict = 'manual_review';\n verdictReason.push('Low research quality score');\n }\n}\n\n// PG enum status values\nconst statusMap = {\n 'pass': 'triage_passed',\n 'manual_review': 'triage_review',\n 'disqualify': 'triage_disqualified'\n};\n\n// -----------------------------------------------------------------------\n// 9. COMPOSE TRIAGE NOTES\n// -----------------------------------------------------------------------\nconst triageNoteLines = [];\ntriageNoteLines.push(`VERDICT: ${verdict.toUpperCase()}${verdictReason.length ? ' \u2014 ' + verdictReason.join('; ') : ''}`);\ntriageNoteLines.push(`TIER: ${displayTier(pgNewTier) || newTier}${tierChanged ? ` (was: ${displayTier(originalTier)}, ${tierDirection})` : ''}`);\ntriageNoteLines.push(`REASON: ${tierReason}`);\ntriageNoteLines.push(`SCORE: ${qualityScore}/10 | CONFIDENCE: ${researchConfidence}`);\ntriageNoteLines.push(`SIZING: Revenue ${researchRevenue !== null ? '\u20ac' + researchRevenue + 'M' : 'unverified'} (${researchRevenue !== null ? research.revenue_source || 'research' : '-'}) | Employees ${researchEmployees || 'unverified'} (${employeeSource})`);\nif (flags.length) triageNoteLines.push(`FLAGS: ${flags.join(', ')}`);\nif (contradictions.length) triageNoteLines.push(`CONTRADICTIONS: ${contradictions.map(c => `[${c.severity}] ${c.detail}`).join(' | ')}`);\ntriageNoteLines.push(`COST: $${researchCost.toFixed(4)} (${model})`);\n\nconst triageNotes = triageNoteLines.join('\\n');\n\n// -----------------------------------------------------------------------\n// 10. RETURN (Postgres-ready values)\n// -----------------------------------------------------------------------\nreturn {\n // --- Routing ---\n record_id: original.id,\n triage_verdict: verdict,\n\n // --- Postgres: companies table (pg_ prefix = ready to write) ---\n pg_status: statusMap[verdict],\n pg_tier: pgNewTier,\n pg_summary: research.summary || null,\n pg_hq_city: hq.split(',')[0]?.trim() || null,\n pg_hq_country: hq.split(',')[1]?.trim() || null,\n pg_geo_cluster: geoCluster,\n pg_ownership_type: mapOwnership(research.ownership),\n pg_business_model: isB2B ? 'b2b' : null,\n pg_industry: (research.industry && research.industry !== 'confirmed') ? research.industry : null,\n\n // --- Verified data ---\n pg_verified_revenue_m: researchRevenue,\n pg_verified_employees: researchEmployees,\n pg_triage_score: qualityScore,\n pg_triage_notes: triageNotes,\n pg_business_type: research.business_model || null,\n\n // --- Bucket updates ---\n pg_revenue_bucket: revenueToBucket(researchRevenue),\n pg_company_size_bucket: employeesToBucket(researchEmployees),\n\n // --- Accumulate cost ---\n pg_enrichment_cost: (parseFloat(original.enrichment_cost_usd) || 0) + researchCost,\n\n // --- research_assets scores ---\n research_quality_score: qualityScore,\n research_confidence_score: confidenceScore,\n\n // --- Metadata (not stored in companies) ---\n tier_changed: tierChanged,\n tier_direction: tierDirection,\n is_european: isEuropean,\n is_target_industry: isTargetIndustry,\n employee_source: employeeSource,\n raw_research: JSON.stringify(research),\n\n // --- Return enrichment cost for Python orchestrator ---\n enrichment_cost_usd: researchCost\n};\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
896,
-128
],
"id": "342a6d5a-0a49-44f6-821c-27969dce7a83",
"name": "Code in JavaScript"
},
{
"id": "e94e97d6-9225-4d1e-a12b-c3d2d303770d",
"name": "Update Company Success",
"type": "n8n-nodes-base.postgres",
"position": [
1104,
-128
],
"parameters": {
"operation": "executeQuery",
"query": "UPDATE companies SET\n status = '{{ $json.pg_status }}',\n tier = '{{ $json.pg_tier }}',\n summary = '{{ $json.pg_summary }}',\n hq_city = '{{ $json.pg_hq_city }}',\n hq_country = '{{ $json.pg_hq_country }}',\n geo_region = '{{ $json.pg_geo_cluster }}',\n ownership_type = '{{ $json.pg_ownership_type }}',\n triage_notes = {{ $json.pg_triage_notes ? \"'\" + $json.pg_triage_notes.replace(/'/g, \"''\") + \"'\" : \"NULL\" }},\n triage_score = {{ $json.pg_triage_score }},\n verified_employees = {{ $json.pg_verified_employees || 'NULL' }},\n verified_revenue_eur_m = {{ $json.pg_verified_revenue_m || 'NULL' }},\n business_model = {{ $json.pg_business_model ? \"'\" + $json.pg_business_model + \"'\" : \"NULL\" }},\n industry = {{ $json.pg_industry ? \"'\" + $json.pg_industry + \"'\" : \"NULL\" }},\n business_type = {{ $json.pg_business_type ? \"'\" + $json.pg_business_type + \"'\" : \"NULL\" }},\n revenue_range = {{ $json.pg_revenue_bucket ? \"'\" + $json.pg_revenue_bucket + \"'\" : \"NULL\" }},\n company_size = {{ $json.pg_company_size_bucket ? \"'\" + $json.pg_company_size_bucket + \"'\" : \"NULL\" }},\n enrichment_cost_usd = {{ $json.pg_enrichment_cost }},\n updated_at = now()\nWHERE id = '{{ $json.record_id }}'\nRETURNING id, status, tier, enrichment_cost_usd",
"options": {}
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"typeVersion": 2.6
},
{
"id": "011776e6-d5fb-471f-9161-a8978cd90cf6",
"name": "Update Company Error",
"type": "n8n-nodes-base.postgres",
"position": [
624,
96
],
"parameters": {
"operation": "executeQuery",
"query": "UPDATE companies SET\n status = 'enrichment_failed',\n error_message = {{ $json.error ? \"'\" + String($json.error).replace(/'/g, \"''\").substring(0, 500) + \"'\" : \"NULL\" }},\n updated_at = now()\nWHERE id = '{{ $('Read Company').item.json.id }}'\nRETURNING id, status",
"options": {}
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"typeVersion": 2.6
},
{
"id": "bb5fb03f-a786-44c6-8e6f-947992de8a2a",
"name": "Save Research Asset",
"type": "n8n-nodes-base.postgres",
"position": [
1328,
-128
],
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO research_assets (\n tenant_id, entity_type, entity_id, name, tool_name,\n cost_usd, research_data, confidence_score, quality_score\n) VALUES (\n '{{ $('Read Company').item.json.tenant_id }}',\n 'company',\n '{{ $('Read Company').item.json.id }}',\n 'company_basic_research',\n 'perplexity_{{ $('Basic Company Reseach').item.json.model }}',\n {{ $('Basic Company Reseach').item.json.usage.cost.total_cost || 0 }},\n '{{ $('Basic Company Reseach').item.json.choices[0].message.content.replace(/'/g, \"''\") }}',\n {{ $json.research_confidence_score || 0 }},\n {{ $json.research_quality_score || 0 }}\n)\nRETURNING id",
"options": {}
},
"credentials": {
"postgres": {
"name": "<your credential>"
}
},
"typeVersion": 2.6
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Read Company",
"type": "main",
"index": 0
}
]
]
},
"When Executed by Another Workflow": {
"main": [
[
{
"node": "Read Company",
"type": "main",
"index": 0
}
]
]
},
"Read Company": {
"main": [
[
{
"node": "If",
"type": "main",
"index": 0
}
]
]
},
"If": {
"main": [
[
{
"node": "Basic Company Reseach",
"type": "main",
"index": 0
}
],
[
{
"node": "Update Company Error",
"type": "main",
"index": 0
}
]
]
},
"Basic Company Reseach": {
"main": [
[
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
],
[
{
"node": "Update Company Error",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "Update Company Success",
"type": "main",
"index": 0
},
{
"node": "Save Research Asset",
"type": "main",
"index": 0
}
]
]
},
"Update Company Success": {
"main": [
[]
]
},
"Update Company Error": {
"main": [
[]
]
},
"Save Research Asset": {
"main": [
[]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"availableInMCP": false,
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "01a66f1d-843b-42fa-b482-5f8a1aad52c7",
"tags": []
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
perplexityApipostgres
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Enrich company L1. Uses executeWorkflowTrigger, postgres, perplexity. Webhook trigger; 9 nodes.
Source: https://github.com/Visionvolve/leadgen/blob/107f1f3cd3814304fd0e09c6836adb3595e509dd/n8n/l1-workflow-v5.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Enrich person L3. Uses executeWorkflowTrigger, postgres, perplexity, httpRequest. Webhook trigger; 12 nodes.
Kreativ: Lead Scoring. Uses executeWorkflowTrigger, postgres, httpRequest. Webhook trigger; 8 nodes.
This workflow automates bulk email campaigns with built-in validation, deliverability protection, and smart send-time optimization.
get-data-for-marketting-dashboard. Uses postgres, googleAnalytics, httpRequest. Webhook trigger; 20 nodes.
User Integration Setup. Uses httpRequest, postgres. Webhook trigger; 5 nodes.