In this tutorial we'll build a real, production-grade lead routing workflow in n8n. When a form is submitted, the workflow enriches the lead, uses an LLM to score and summarize it, routes hot leads to a rep in Slack, and writes everything back to your CRM — with proper error handling. No fluff, just the build.
The workflow at a glance
- Webhook receives the form submission.
- Enrich the email domain with company data.
- AI score & summarize the lead with an LLM.
- Route hot leads to Slack, nurture the rest.
- Persist the full record to the CRM.
- Catch any failure and alert the team.
Step 1 — Capture the lead with a Webhook node
Add a Webhook node, set the method to POST, and point your form at the production URL. n8n gives you a test URL while building — use it to send a sample payload like the one below.
{
"name": "Dana Lee",
"email": "[email protected]",
"company": "Acme Robotics",
"message": "We need to automate our support inbox. ~5k tickets/mo.",
"budget": "$5k-10k/mo"
}Step 2 — Enrich the lead
Add an HTTP Request node to look up the company by email domain. Always set a timeout and let the workflow continue on failure so a flaky enrichment API never blocks a lead.
Step 3 — Score and summarize with an LLM
Add an OpenAI (or HTTP Request) node. We ask the model to return strict JSON so the next nodes can branch on it deterministically. Notice the explicit schema and the instruction to only return JSON.
// System prompt for the scoring node
const system = `You are a B2B SDR. Score this lead 0-100 on fit and intent.
Return ONLY valid JSON, no prose, matching:
{ "score": number, "tier": "hot" | "warm" | "cold", "summary": string }`;
// User content built from previous nodes
const user = JSON.stringify({
name: $json.name,
email: $json.email,
company: $json.company,
message: $json.message,
budget: $json.budget,
enrichment: $node["Enrich"].json ?? null
});
return { system, user };// Function node: safely parse the model output
let parsed;
try {
parsed = JSON.parse($json.choices[0].message.content);
} catch (e) {
// Fallback keeps the workflow alive if the model returns junk
parsed = { score: 50, tier: "warm", summary: "Unparsed model output" };
}
return [{ json: { ...$json, ...parsed } }];try/catch fallback above is the difference between a workflow that degrades gracefully and one that crashes at 2am.Step 4 — Route based on tier
Add a Switch node keyed on {{ $json.tier }}. Send hot leads to a Slack node that @-mentions the on-call rep with the AI summary, and route warm/cold leads into your nurture sequence.
:fire: *New HOT lead* — score {{ $json.score }}/100
*{{ $json.name }}* @ {{ $json.company }}
{{ $json.summary }}
<mailto:{{ $json.email }}|Reply now>Step 5 — Persist to your CRM
Finally, write the enriched, scored record to your CRM with an upsert keyed on email so re-submissions update the existing contact instead of creating duplicates.
| CRM field | Maps from | Notes |
|---|---|---|
| {{ $json.email }} | Upsert key | |
| company | {{ $json.company }} | From form or enrichment |
| lead_score | {{ $json.score }} | 0–100 |
| lead_tier | {{ $json.tier }} | hot / warm / cold |
| ai_summary | {{ $json.summary }} | Shown to rep |
Step 6 — Add a global error workflow
Don't ship without these
- An Error Trigger workflow that posts failures to Slack
- Timeouts on every HTTP/LLM node
- 'Continue On Fail' on non-critical nodes
- A test execution saved with sample data
- Credentials stored in n8n, never hard-coded