Lead scoring and CRM sync
One n8n workflow that scores a web form lead, upserts it in HubSpot and only announces the hot ones
Eleven cases you can trigger yourself, plus your own lead
The demo posts to the same webhook as the source repository, with the same workflow export, the same criteria file and the same provider. Every scenario states what it expects before it runs, then shows the actual result and the PostgreSQL rows behind it. The console re-derives the class from the stored per-criterion answers and checks it against what the workflow wrote, so you can see that the class came from the rule table. Scoring is probabilistic and is labelled as such; the policy checks are not. HubSpot and Slack are local stand-ins that speak the real request and response contracts, and that is stated on the page.
The model judges, code decides the class
The prompt never asks for Hot, Warm or Cold. Four criteria with weights and thresholds live in a config file; the class is arithmetic over the answers the model gave.
VERIFY: Run any scenario and open the scoring breakdown, where the console re-derives the class from the stored answersA bad answer never becomes a guess
A provider that stays down after four attempts, or an answer that does not fit the criteria file, falls back to Warm with needs_review. The lead still reaches the CRM, flagged for a human.
VERIFY: Run Provider failure and Answer the schema rejectsDuplicates cost nothing
The submission key is claimed under a unique constraint before the provider is called. A redelivery returns the stored result with one lead row, one scoring event and one contact.
VERIFY: Run Duplicate deliveryRetries are bounded and recorded
Four attempts with 2, 4 and 8 second waits on the scoring call and on a CRM 5xx. A CRM 4xx is never repeated, because a rejected payload does not get better on the second try.
VERIFY: Run Provider failure and CRM outageNothing is dropped silently
A malformed submission answers HTTP 200 so the form provider stops redelivering, and lands in dead_letter with the full payload. The ops alert names the stage and the reason and is throttled to one per key per fifteen minutes.
VERIFY: Run Malformed submission, then run it again inside the throttle windowOnly the class that earns it gets a notification
The chat message goes out for Hot leads only, and only while the operator killswitch in the database is on. A skipped notification is logged with its reason.
VERIFY: Run Routine lead and Notifications switched offFree text is data, never instructions
Everything the lead typed sits between two markers in the prompt. An injection attempt is flagged on the lead, named in the CRM reason field and kept verbatim in the raw payload table.
VERIFY: Run Prompt injection in the messageEvery decision is auditable
Raw payload, normalized lead, one scoring event per attempt with tokens and latency, one sync row per outbound system, dead letters and alerts are read back from PostgreSQL after each run, next to the n8n execution id.
VERIFY: Open the PostgreSQL evidence panel under any result- The public console validates a synthetic submission and posts it to the internal n8n webhook
- The raw payload is written to PostgreSQL before anything is judged
- The payload is normalized into one lead shape; a rejection carries a reason code and goes straight to dead_letter
- The submission key is claimed under a unique constraint, so a redelivery stops before the provider call
- Enrichment adds the company domain, whether the mailbox is free, and what this address and domain sent before
- One provider call scores four criteria under a strict JSON schema, with four attempts and 2, 4 and 8 second waits
- The answer is validated against the schema and the criteria options, then code turns it into points and a class
- The contact is upserted in HubSpot on email: search, then create or update, with five scored properties
- A Hot lead is announced in the chat channel; any other class is logged as a skipped notification with its reason
- Scoring event and sync rows are written, and a separate error workflow catches what the main path did not
Synthetic leads only, and no real CRM or workspace behind the demo.
- Synthetic leads only. The custom form accepts addresses on the reserved example.com domain and nothing else.
- Only the normalized lead fields and the criteria text reach the model provider. Nothing else leaves the host.
- The provider key lives in a gateway container. The browser, n8n and both stand-ins never receive it.
- There is no HubSpot account and no chat workspace behind the demo. Both stand-ins speak the real contracts and hold what the workflow sends, so no contact is created and no message reaches a person.
- n8n, PostgreSQL and both stand-ins stay on the internal Docker network; the console is the single public surface.
- Provider processing region and retention are not configured or measured for this reference implementation.
- The demo database and both stand-ins reset to their seeded state every night at 04:00 CET.
Two things go wrong with lead scoring built on a language model. The model invents a class, and a bad answer quietly becomes a decision. This build takes both off the table. The prompt never asks for Hot, Warm or Cold. The model answers four separate questions about budget, company size, timeline and decision authority, each one under a strict JSON schema with a fixed set of allowed values and a reason. The points and the class come from a weight and threshold table in the repository, so the same answers always produce the same class and a rule change is a config edit rather than a prompt rewrite.
The submission is written to PostgreSQL before anything is judged, and the provider submission id is claimed under a unique constraint before the model is called. A form provider that redelivers after a timeout hits that constraint instead of paying for a second scoring call, and gets the stored result back. Everything the lead typed goes into the prompt as data between two markers, with the markers stripped from the text, so an instruction in the free text is recorded as an injection attempt rather than obeyed.
The failure paths got as much attention as the happy path. The scoring call is tried four times with 2, 4 and 8 second waits; after that the lead falls back to Warm with needs_review set and still reaches the CRM, flagged for a human, because a lead you cannot score is worse lost than unscored. An answer that does not fit the criteria file takes the same fallback without a retry, since the provider did answer. A CRM 5xx is repeated with the same backoff, a 4xx is not, because repeating a rejected payload cannot fix it, and a permanent failure writes the full request to a dead letter table and raises an ops alert that is throttled to one per alert key per fifteen minutes.
This is a reference project built on a public brief, with synthetic submissions and my own test accounts. There is no client. The live acceptance run went against the real HubSpot and Slack APIs on a developer test portal and a test workspace, and the numbers below come from that run and from a scoring measurement over ten fixtures, not from an estimate.
What it does
- Accepts Typeform and Google Forms webhook payloads and normalizes both into one lead shape
- Scores four criteria per lead under a strict JSON schema, with a reason per criterion and a confidence the model reports about itself
- Turns those answers into points and a class with a weight and threshold table in the repository, never with the model
- Upserts the contact in HubSpot on email with five scored properties, and notifies chat for Hot leads only, behind an operator killswitch
- Claims the submission key in PostgreSQL before the provider call, so a redelivery costs nothing
- Retries with bounded backoff, falls back instead of guessing, and writes every permanent failure to a dead letter table with a throttled ops alert




Want something like this for your business?