Chapter 2.10 Lab: CRM System Implementation (n8n)

Building each individual component of a CRM automation system in isolation is a necessary but insufficient achievement.

A system that scores leads, tracks lifecycle transitions, enforces governance, and synchronizes external data is not a collection of independently capable workflows. It is a coordinated platform in which those workflows operate against shared state, respect each other’s property ownership boundaries, and produce coherent outcomes across the full lead-to-customer lifecycle.

The engineering challenge Chapter 2.10 addresses is not “can you configure a webhook trigger?” or “can you call the OpenAI API?” it is “can you assemble eleven architectural layers into a production-grade automation platform that behaves correctly at every point in a real lead’s lifecycle?”

This distinction between component competence and systems integration is the gap most junior automation engineers encounter when their first production deployment begins producing unexpected behavior.

A workflow that passed every isolated test suddenly interacts with an edge case: a Contact whose governance status was set by a different workflow, a Typeform response that arrives during a broker’s active correspondence, a scoring model that produces a maximum output below the first tier’s threshold.

These interactions are invisible during component-level testing and only become visible when all four workflows are running simultaneously against live data. The Chapter 2.10 lab is designed to surface and resolve these interactions in a controlled environment before the student encounters them in client deployments.

Chapter 2.10 is the integration lab for Part II. It does not introduce new architectural concepts. It assembles all concepts introduced in Sections 5.1 through 5.9 into a complete, deployable CRM automation platform.

Each of the eleven implementation subsections corresponds to one layer of the nine-layer CRM architecture introduced in Chapter 2.3, with Enrichment split into rule-based and AI-assisted components and Score Combination treated as a distinct step. The lab is organized to mirror the execution order of Workflow A: students configure each layer in the sequence in which it operates during a live intake event.

By the end of Chapter 2.10.7 (Routing), students have a complete lead intake pipeline. Sections 2.10.8 through 2.10.11 add the timing, delivery, protection, and logging layers that transform a basic intake pipeline into a governed, durable, client-grade system.

Business Scenario

A junior engineer has built all four workflows independently each passes its own test suite, all produce the expected outputs in isolation. On day one of production, a lead resubmits the intake form while the broker has manual_override_active = true from a prior correspondence. Workflow A creates a new Task, resets the follow-up cadence, and sends a Slack notification directly overriding the broker’s active outreach. No individual workflow malfunctioned. The integration produced the failure.

The Problem

Component correctness does not guarantee system correctness. The ten integration points between four workflows shared HubSpot state, property ownership boundaries, governance gate conditions, timing dependencies each represent an assumption that must be verified end-to-end, not only in isolation.

The Architectural Solution

The eleven-layer implementation structure in this chapter mirrors the execution order of Workflow A. Each layer is built, verified against a checkpoint, and confirmed as a known-good baseline before the next layer is added. Integration checkpoints are the mechanism that catches cross-layer assumptions before they reach production.

Updated Workflow

The complete CRM architecture has eleven layers, built sequentially during the lab:

Layer Name Key Nodes / What It Validates
1 Intake Layer normalize, validate, header auth
2 Structuring Layer Contact batch upsert, Company search/create/associate
3 Rule Enrichment Layer rule_score, rule_score_breakdown
4 AI Enrichment Layer ai_score, ai_confidence, ai_score_valid
5 Score Combination Layer combined_score, priority_label, scoring_model_version
6 Validation Layer input validation WF-A, transition validation WF-B
7 Routing Layer lifecyclestage, notification_channel, journey_state
8 Timing Layer followup_tp*_due_at, BH-adjusted
9 Delivery Layer Task, Deal, Slack notification
10 Protection Layer stop conditions, governance gate, idempotency
11 Logging Layer intake audit Note, error Notes, action Notes

Integration Principles

Three integration principles govern the assembly.

The first is the distinction between constructing a feature and integrating a system. Construction means building a new capability that did not previously exist. Integration means assembling previously constructed features into a coherent whole in which each component’s inputs and outputs are correctly connected to every other component that depends on them. Integration failures are often invisible until a specific combination of conditions is met conditions that may not occur during development but will occur in production. The primary risk in CRM automation integration is not that any individual component is incorrect; it is that two correct components make incompatible assumptions about shared state.

The second principle is the property ownership map as integration blueprint. The rule that each HubSpot property is owned by exactly one workflow, and only that workflow writes to it in normal operation, determines which workflow node is responsible for each write, which node is authorized to read and act on each value, and which conflicts require arbitration by the governance layer. Students assembling the lab should consult the property ownership map before configuring any HubSpot property write node.

The third principle is integration checkpoints: specific, verifiable state assertions made at defined points in the workflow execution. Before advancing from one subsection to the next, each subsection specifies a checkpoint a set of HubSpot property values and notifications that should be present after executing the completed layers against a test submission. These checkpoints verify the current implementation before adding the next layer and establish a known-good baseline for isolating failures introduced by subsequent layers.

Learning Objectives

After completing this lab, you will be able to:

  • Configure all four Part II workflows (A, B, C, D) in a single n8n instance with correct trigger conditions, property ownership boundaries, and inter-workflow coordination.
  • Execute the complete integration test sequence single submission, re-submission, SLA breach, survey synchronization, governance conflict and verify correct platform behavior for each scenario.
  • Identify and resolve at least one cross-workflow interaction bug surfaced during integration testing that was not visible during component-level testing of individual workflows.
  • Explain the difference between component-level testing and systems integration testing, using a specific example from the lab where a component passed its test but the integration scenario failed.
  • Produce a complete implementation record documenting the configuration of each workflow, the HubSpot property schema, and the results of each integration test scenario.

2.10.1 Intake Implementation

Intake Layer

The Intake layer is the system’s entry point: it receives the raw event payload from the external form submission, validates that the webhook request is authentic and structurally complete, and normalizes field values into the canonical formats required by every downstream layer. The Intake layer does not score, qualify, or route; it processes the raw payload into a normalized data object that subsequent layers can trust. Any field that arrives in an unexpected format a phone number with parentheses and dashes, a company name with trailing whitespace, a timeline value from a legacy form version is either normalized here or rejected with a clear error.

Intake normalization is a prerequisite for every other layer’s correctness. If a phone number arrives as (212) 555-0182 and the Scoring layer’s completeness signal checks phone !== null && phone.length > 0, the check passes correctly. But if the Scoring layer later writes phone: "(212) 555-0182" to a HubSpot property configured for E.164 format, the write fails. Normalization at intake prevents this class of failure from propagating through ten downstream layers. Intake normalization also ensures that the Structuring layer’s duplicate detection which identifies returning contacts by email address operates against a consistent canonical form. Two submissions from Marcus.Chen@VertexLogistics.com and marcus.chen@vertexlogistics.com should resolve to the same Contact; they will, but only if email normalization (lowercase) is applied at intake before the deduplication check.

Implementation: Workflow A Intake Layer

Step 1 Webhook Receive

Purpose

Receive the raw inbound form submission as an HTTP POST, respond immediately with 200 OK to prevent form platform retries, and surface the payload for downstream processing.


Operation Summary

Property Value
Method POST
Endpoint /intake/lead
Primary Function Receive form payload and acknowledge receipt
Output Raw request body available to downstream nodes

Request Payload

Node type:     Webhook
Method:        POST
Path:          /intake/lead
Response mode: Immediately
Response body: {"status": "received"}
Response code: 200

The webhook is configured in “Respond Immediately” mode it returns 200 before executing any downstream node. This decouples the form platform’s retry logic from n8n’s processing time.


Response Processing

// No processing required n8n exposes the body as $input.item.json.body
// Headers are accessible at $input.item.json.headers

The raw submission body and headers pass directly to the Header Validation Code node.


Output Table

Output Description
$input.item.json.body Form field values submitted by the lead
$input.item.json.headers HTTP request headers including x-form-secret

Engineering Rationale

NoteEngineering Rationale

Form platforms (Gravity Forms, Webflow, Typeform embed) treat any response latency beyond their timeout threshold as a submission failure and retry. An n8n workflow that scores a lead, calls OpenAI, and writes to HubSpot takes 2–5 seconds above most platform timeouts. The immediate 200 response eliminates this retry loop and prevents duplicate processing events without any loss of data.


Step 2 Header Validation

Purpose

Verify that the incoming webhook request carries a valid shared secret before allowing any processing to proceed, preventing unauthorized access to the intake endpoint.


Operation Summary

Property Value
Method Code node (no HTTP call)
Endpoint N/A reads request header
Primary Function Authenticate incoming webhook using shared secret
Output valid_request: true/false, error reason

Implementation Logic

const secret = $env.FORM_WEBHOOK_SECRET;
const received = $input.item.json.headers['x-form-secret'];
if (!received || received !== secret) {
  return [{ json: { valid_request: false, error: 'invalid_secret' } }];
}
return [{ json: { valid_request: true, body: $input.item.json.body } }];

The Code node reads FORM_WEBHOOK_SECRET from n8n’s environment variables and compares it against the x-form-secret header on the incoming request.


Response Processing

An IF node immediately follows Header Validation and checks valid_request. When false, the IF node routes to the Error branch, which logs a 403 entry and terminates execution without writing to HubSpot.


Output Table

Output Description
valid_request Boolean true if header matches, false otherwise
error Present when false; value: "invalid_secret"
body Form submission body, passed forward when valid

Engineering Rationale

NoteEngineering Rationale

Header validation provides a first defense layer against unauthenticated webhook submissions. It is not a substitute for HTTPS (which must be enforced at the deployment layer) but prevents casual unauthorized calls and accidental misconfigured sources from submitting data. The secret is stored as an environment variable rather than a hardcoded literal so it can be rotated without a workflow code change.


Step 3 Field Normalization and Validation

Purpose

Transform all inbound field values into canonical formats before any write occurs, and verify that the required contact fields are present. Normalization at this step prevents format inconsistencies from propagating through all downstream layers.


Operation Summary

Property Value
Method Code node (no HTTP call)
Endpoint N/A transforms in-memory payload
Primary Function Normalize field formats; validate required fields
Output Normalized field object; validation_passed; validation_errors[]

Implementation Logic

The normalization Code node applies these transformations:

const body = $input.item.json.body;

const normalized = {
  email:                body.email?.trim().toLowerCase(),
  phone:                normalizePhone(body.phone),      // strip non-digits, prefix +1
  company:              body.company?.trim(),
  firstname:            capitalize(body.firstname?.trim()),
  lastname:             capitalize(body.lastname?.trim()),
  lead_source_channel:  body.lead_source_channel?.trim().toLowerCase().replace(/ /g, '_'),
  property_type:        body.property_type?.trim(),
  property_size:        body.property_size?.trim(),
  timeline:             body.timeline?.trim(),
  inquiry_description:  body.inquiry_description?.trim().substring(0, 2000),
};

const required = ['email', 'firstname', 'lastname'];
const missing  = required.filter(f => !normalized[f]);

return [{ json: {
  ...normalized,
  validation_passed: missing.length === 0,
  validation_errors: missing,
}}];

Response Processing

const result = $input.item.json;
// result.validation_passed determines IF-node routing
// result.validation_errors lists missing required fields for error logging
// All normalized fields are available by name for downstream nodes

When validation_passed = false, the next IF node routes to the Error branch. No HubSpot write is attempted for invalid submissions.


Output Table

Output Description
email Lowercased, trimmed
phone E.164 format (+1NNNNNNNNNN) or empty
company Trimmed, case preserved
firstname / lastname Trimmed, first-letter capitalized
lead_source_channel Lowercased, underscored
inquiry_description Trimmed, max 2000 characters
validation_passed true if all required fields present
validation_errors Array of missing required field names

Engineering Rationale

NoteEngineering Rationale

Normalization must precede all writes. A Contact record created before normalization may contain inconsistent data (e.g., mixed-case email) that breaks downstream deduplication two submissions from Marcus.Chen@ and marcus.chen@ would produce two HubSpot Contact records if email normalization had not run first. All field transforms are applied comprehensively, not selectively: the cost of normalizing a field that arrives correctly formatted is zero; the cost of missing a malformed field is a downstream silent failure.

Integration Checkpoint 2.10.1: Submit a test POST with a mixed-case email, a phone in (NNN) NNN-NNNN format, and a missing lastname field. Expected outcomes: webhook responds 200 immediately; normalization converts email to lowercase and phone to E.164; required fields check sets validation_passed = false; Error Exit branch fires; no HubSpot write is attempted.

The “respond immediately, process asynchronously” webhook pattern is a professional reliability practice. n8n workflow execution takes 2–5 seconds under normal conditions. A form platform that waits for a synchronous response will treat any execution delay as a timeout and retry the submission, creating duplicate processing events. The immediate 200 response breaks this retry loop while allowing full asynchronous processing to complete. The header validation pattern is a minimal but sufficient security measure for a webhook receiver it is not a substitute for HTTPS (which must always be enforced) or for HMAC-SHA256 signature validation, but it prevents casual unauthorized access to the intake endpoint.

CautionProduction Risk

Placing validation logic after the first data-writing node is the most consequential intake implementation mistake. Students who configure the HubSpot Contact upsert before the required fields check create Contact records with null values before the validation error is detected. Validation must precede all writes: normalize, then validate, then write. This is a structural ordering requirement, not a best practice.

CautionProduction Risk

Normalizing only the fields expected to arrive malformed leaves downstream errors in place for fields that arrive malformed unexpectedly. Every string field should be trimmed. Every email should be lowercased. The normalization pass should be comprehensive the cost of normalizing a field that is already correct is zero, and the cost of missing a field that arrives malformed is a downstream failure that may produce a silent wrong result rather than an error.

ImportantCritical Requirement

Header validation without HTTPS enforcement provides only weak security. An attacker who intercepts traffic in transit can read the x-form-secret header value from unencrypted requests and replay it in forged requests. HTTPS is a deployment prerequisite, not optional. Header validation adds a layer of protection on top of HTTPS; it does not substitute for it.


2.10.2 Structuring Implementation

Structuring Layer

The Structuring layer takes the validated and normalized payload from the Intake layer and constructs the CRM data objects that represent it: the Contact record, the Company record, and their association. Structuring is not scoring or qualifying; it is the process of translating a form submission’s flat field structure into the CRM’s relational data model. A lead is not just a person with an email address it is a Contact associated with a Company, potentially enriched by a prior submission history, and ready to participate in HubSpot’s pipeline workflows.

The Structuring layer determines the system’s data quality baseline. A Contact record with correctly normalized fields, an associated Company record, and a complete set of intake metadata is the foundation on which all downstream layers operate. A Contact record with a blank company name or a non-normalized email reduces the Scoring layer’s completeness signal and complicates the Delivery layer’s Task creation all because the Structuring layer did not write the data correctly. The distinction between a new Contact (contact_action = "created") and a returning Contact (contact_action = "updated") is established by the Structuring layer’s batch upsert response and carried forward as a signal to the Scoring layer’s Constraint C3 (returning contact scoring rule).

Implementation: Workflow A Contact Batch Upsert

Step 4 Contact Batch Upsert

Purpose

Create or update the HubSpot Contact record for the submitting lead using a deduplication-safe batch upsert call, and determine whether this is a new Contact or a returning one a signal that flows into Constraint C3 of the scoring model.


Operation Summary

Property Value
Method POST
Endpoint /crm/v3/objects/contacts/batch/upsert
Primary Function Create or update Contact; detect new vs. returning
Output HubSpot contactId, contact_action (created/updated)

Request Payload

{
  "inputs": [
    {
      "idProperty": "email",
      "id": "marcus.chen@vertexlogistics.com",
      "properties": {
        "email":                "marcus.chen@vertexlogistics.com",
        "firstname":            "Marcus",
        "lastname":             "Chen",
        "phone":                "+12125550182",
        "company":              "Vertex Logistics Group",
        "lead_source_channel":  "referral",
        "property_type":        "Office (Class A)",
        "property_size":        "Over 20,000 sqft",
        "timeline":             "Immediate (within 3 months)",
        "inquiry_description":  "We're relocating our NYC headquarters..."
      }
    }
  ]
}

The idProperty: "email" field instructs HubSpot to perform deduplication on the email address. If a Contact with this email already exists, HubSpot updates it; otherwise it creates a new record.


Response Processing

const result = $input.item.json.results[0];
const contactId = result.id;

// Determine new vs. returning Contact
const createdAt = new Date(result.createdAt).getTime();
const updatedAt = new Date(result.updatedAt).getTime();
const contact_action = (updatedAt - createdAt < 1000) ? 'created' : 'updated';

return [{ json: { contactId, contact_action } }];

The contact_action value is a functional input to the Scoring layer’s Constraint C3: if contact_action = "updated" and the Contact’s existing combined_score > 18, the prior score is preserved rather than replaced.


Output Table

Output Description
contactId HubSpot internal Contact ID, used for all subsequent API calls
contact_action "created" or "updated" signals Constraint C3 in scoring

Engineering Rationale

NoteEngineering Rationale

Batch upsert with idProperty: "email" is the correct deduplication primitive. A naive POST /contacts call without deduplication creates a new Contact record on every form submission, producing duplicate records for any returning lead. The batch endpoint handles both creation and update in a single call, and the createdAt/updatedAt comparison is the canonical method for distinguishing the two outcomes without an additional API call.


Step 5 Company Search and Association

Purpose

Locate the existing HubSpot Company record for the Contact’s firm, or create a new one, and associate the Contact to the Company. This association is required for Deal creation in Workflow B.


Operation Summary

Property Value
Method POST
Endpoint /crm/v3/objects/companies/search (search); /crm/v3/objects/companies (create); /crm/v4/objects/contacts/{id}/associations/companies/{companyId}/contact_to_company (associate)
Primary Function Search for Company by name; create if not found; associate to Contact
Output companyId, association confirmed

Request Payload

{
  "filterGroups": [
    {
      "filters": [
        {
          "propertyName": "name",
          "operator": "EQ",
          "value": "Vertex Logistics Group"
        }
      ]
    }
  ],
  "properties": ["name", "domain"],
  "limit": 1
}

If the search returns no results, create the Company:

{
  "properties": {
    "name": "Vertex Logistics Group"
  }
}

Response Processing

const searchResult = $input.item.json;
let companyId;

if (searchResult.total > 0) {
  companyId = searchResult.results[0].id;
} else {
  // Company was just created use the create response
  companyId = $input.item.json.id;
}

return [{ json: { companyId } }];

After resolving the companyId, the Associations API call links the Contact to the Company. The association response is checked for a non-empty results array before proceeding.


Output Table

Output Description
companyId HubSpot Company ID, passed to Workflow B for Deal creation

Engineering Rationale

NoteEngineering Rationale

HubSpot Deals are associated with both Contacts and Companies. Workflow B’s Deal creation step requires a companyId to name the Deal and establish the three-way Contact–Deal–Company association. Without Company association at this step, Deal names will be incomplete and company-level pipeline reporting will be impossible. The search-before-create pattern prevents a proliferating Company record table where every submission from the same organization creates a separate Company.

Implementation: Workflow D Survey Structuring

Workflow D implements a parallel Structuring component: it receives a Typeform survey payload, maps survey fields to the survey_* property namespace, resolves the Contact by normalized email, and writes only survey_* properties via a single HubSpot PATCH call. Workflow D never writes to intake, scoring, or governance properties. This property ownership boundary at the Structuring layer is the architectural guarantee that survey data enrichment cannot corrupt the Contact’s primary identity or scoring fields.

Integration Checkpoint 2.10.2: Submit a test payload for a new Contact. Verify: Contact record created with normalized field values, Company record created and associated, contact_action = "created". Submit same email a second time. Verify: Contact updated (not duplicated), contact_action = "updated", Company association preserved.

Company association is a data model requirement, not just a UX nicety. HubSpot Deals are associated with both Contacts and Companies; the Deal creation step in Workflow B requires a Company ID to populate the deal name and establish the three-way association. Without Company association at the Structuring layer, the Delivery layer cannot create properly named Deals. The Company search-before-create pattern is also a deduplication requirement without it, every Contact from the same company generates a new Company record, making company-level pipeline reporting impossible.

CautionProduction Risk

Not extracting contact_action from the batch upsert response loses the Scoring layer’s returning-contact detection. Students who skip this step deprive Constraint C3 (preserve existing score if > 18) of the signal it needs to activate. The contact_action flag is not only informational it is a functional input to the scoring model.

CautionProduction Risk

Creating a new Company record on every submission without first searching for an existing one produces a proliferating Company record table where each submission from the same organization creates a separate Company. A brokerage that receives 10 submissions from “Vertex Logistics Group” will have 10 Company records in HubSpot, none linked to each other, making company-level deal tracking impossible. The search-before-create pattern is a correctness requirement.

CautionProduction Risk

Not verifying that Company association was successfully created before proceeding with the workflow means that Deal creation in Workflow B will produce a Deal with no Company association. HubSpot’s Associations API call can fail silently (200 but with a partial result) if the association type code is incorrect. The association creation step should check the response for a non-empty results array before continuing.


2.10.3 Enrichment Implementation (Rule-Based)

Rule-Based Enrichment Layer

The Rule-Based Enrichment layer applies the five-signal scoring model to the normalized Contact data, computing a rule_score between 0 and 20. Rule-based scoring is deterministic: given the same field values, the same score is always produced. This determinism makes rule-based scoring the foundation of the hybrid model it provides a reproducible, auditable baseline that the AI scoring component supplements without replacing. The five signal groups (Source Channel 0–5, Property Size 0–4, Property Type 0–4, Timeline 0–4, and Contact Completeness 0–3) are computed by a single Code node that reads signal weights from SCORING_CONFIG (a JSON-encoded environment variable) and returns a structured score object containing the total, the per-signal breakdown, and the input values used for each signal.

Rule-based scoring’s primary operational value is transparency. When a broker asks “why was this lead classified as Cool?”, the rule score breakdown answers precisely: which signals were present, which were absent, and how much each contributed. This level of specificity is not achievable with AI-only scoring, where the model’s reasoning is probabilistic rather than explicitly enumerated. The rule score is also the fallback scoring mechanism when AI scoring is unavailable, combined_score = rule_score × 1.0. Rule scoring correctness is prerequisite to the entire hybrid model’s reliability.

Implementation: Workflow A Rule Score Code Node

Step 6 Rule Score Computation

Purpose

Apply the five-signal scoring model to the normalized Contact payload to produce a deterministic, auditable rule_score between 0 and 20. This score forms the stable foundation of the hybrid model it is reproducible, explainable to brokers, and the fallback when AI scoring is unavailable.


Operation Summary

Property Value
Node Type Code
Method In-memory computation (no HTTP call)
Primary Function Compute rule_score from five signal groups
Input Normalized payload from Step 3
Output rule_score (0–20), rule_score_breakdown object

Implementation Logic

const config   = JSON.parse($env.SCORING_CONFIG);
const body     = $input.item.json;

function lookup(map, value) {
  const key = (value || '').trim().toLowerCase();
  for (const [k, v] of Object.entries(map)) {
    if (k.toLowerCase() === key) return v;
  }
  return 0;
}

const source   = lookup(config.source_channel,  body.lead_source_channel);
const size     = lookup(config.property_size,   body.property_size);
const type     = lookup(config.property_type,   body.property_type);
const timeline = lookup(config.timeline,        body.timeline);
const phone    = body.phone ? 1 : 0;
const company  = body.company ? 1 : 0;
const complete = phone + company + (phone && company ? 1 : 0);

const rule_score = source + size + type + timeline + complete;

const rule_score_breakdown = {
  source_channel: source,
  property_size:  size,
  property_type:  type,
  timeline:       timeline,
  completeness:   complete,
};

return [{ json: { ...body, rule_score, rule_score_breakdown } }];

The SCORING_CONFIG environment variable stores all signal weight maps as a JSON object. Changing a signal weight requires only an environment variable update no code change or workflow redeployment.


Signal Weight Reference Table

Signal Group Values and Points
Source Channel (0–5) referral: 5, direct: 4, paid_search: 3, organic: 2, social: 1, unknown: 0
Property Size (0–4) Over 20,000 sqft: 4, 5,000–20,000 sqft: 3, 2,000–4,999 sqft: 1, Under 2,000 sqft: 0
Property Type (0–4) Office Class A: 4, Retail: 3, Industrial: 3, Office Class B/C: 2, Flexible/Other: 0
Timeline (0–4) Immediate (within 3 months): 4, Near-term (3–6 months): 3, Planning (6–12 months): 1, Exploratory (12+): 0
Completeness (0–3) phone +1, company +1, both present bonus +1

Output Table

Output Description
rule_score Total rule score, 0–20
rule_score_breakdown Object with per-signal contribution values

Engineering Rationale

NoteEngineering Rationale

The rule_score_breakdown object is the primary audit artifact for the scoring layer. A broker reviewing a Contact record weeks after intake can see exactly which signals were present, which were absent, and the precise contribution of each. Without the breakdown, only the final score is visible and the scoring rationale cannot be reconstructed. The breakdown must propagate through all downstream nodes to be written into the intake audit Note at the Logging layer.

Integration Checkpoint 2.10.3: Submit four test payloads representing the four expected priority tiers. Key test cases: referral + over 20k sqft + Class A + immediate + phone + company → rule_score = 20; organic + 2k–5k sqft + flexible + planning + company only → rule_score = 4; direct + 5k–20k sqft + retail + near-term + no contact data → rule_score = 7; no channel + no size + no type + no timeline + no contact data → rule_score = 0.

The rule_score_breakdown object is the audit layer’s most valuable input. A broker reviewing a Contact record six weeks after intake can see exactly which signals were present at submission time not just the final score. This is operationally important when a lead’s circumstances change: if the broker learns the Contact’s timeline has accelerated from “Planning” to “Immediate”, comparing the new timeline signal value against the original rule_score_breakdown.timeline value makes the rescoring rationale concrete and defensible.

CautionProduction Risk

Using case-sensitive string comparisons in signal lookups without prior normalization will silently score signals as zero. If the form sends property_type: "Office (class a)" and the scoring map uses "Office (Class A)" as the key, the match fails and the signal contributes 0 instead of 4. The Intake normalization node should standardize values, but rule scoring lookups should use case-insensitive comparison as a defensive measure given the cost of a missed signal.

CautionProduction Risk

Hard-coding signal weight values directly in the Code node rather than reading from SCORING_CONFIG creates a maintenance problem where scoring recalibration requires code changes and workflow redeployment. All numeric weight values signal scores and tier thresholds must be stored as environment variables, not as literals in the Code node body.

CautionProduction Risk

Not preserving the rule_score_breakdown object through all downstream nodes to the Logging layer produces audit Notes with only the final score and no per-signal trace. Operators investigating an unexpected priority assignment need the breakdown to determine whether the issue is a signal weight configuration problem or an input data problem. The breakdown must propagate from the Rule Score node to the final intake audit Note write.


2.10.4 Enrichment Implementation (AI Scoring)

AI Scoring Layer

The AI Scoring layer sends the Contact’s inquiry_description to the OpenAI Chat Completions API and receives a structured JSON response containing an intent_level (0–8), a confidence value (0.0–1.0), a list of detected signals, and a brief reasoning statement. The AI layer evaluates unstructured text signals that rule-based scoring cannot access: the specificity of the space requirement, urgency expressed in the inquiry’s language, decision-maker language, and hard deadline statements. The implementation uses a three-node pattern: Build Prompt Code node, OpenAI HTTP Request node, Parse Response Code node. The layer is designed for graceful degradation if the inquiry description is absent, if the API call fails, or if the response fails JSON parsing, the layer sets ai_score_valid = false and the Score Combination layer proceeds with rule-score-only combination.

The confidence threshold system prevents low-confidence AI assessments from distorting the combined score. High confidence (≥0.80, full weight), medium (0.55–0.79, 0.5× weight), low (<0.55, not applied). A high-quality inquiry description with specific numbers, deadlines, and decision-maker language produces a high-confidence score. A vague or ambiguous description produces a low-confidence score that is excluded from the combination entirely. This design operationalizes the principle that AI should augment structured data signals, not override them with low-confidence probabilistic assessments.

Implementation: Workflow A AI Scoring Nodes

Step 7 Build AI Prompt

Purpose

Validate that the inquiry_description field contains sufficient content for meaningful AI evaluation, then construct the structured prompt that will be submitted to the OpenAI API. This node acts as the AI layer’s entry gate it prevents unnecessary API calls for leads with no inquiry text and ensures the prompt format produces a consistent, parseable JSON response.


Operation Summary

Property Value
Node Type Code
Method In-memory (no HTTP call)
Primary Function Validate inquiry text; construct OpenAI prompt
Input inquiry_description from normalized payload
Output ai_prompt string OR ai_score_valid = false early exit

Implementation Logic

const desc = ($input.item.json.inquiry_description || '').trim();

if (desc.length < 20) {
  return [{ json: {
    ...$input.item.json,
    ai_score_valid: false,
    ai_skip_reason: 'inquiry_description_absent_or_too_short',
  }}];
}

const truncated = desc.substring(0, 500);

const ai_prompt = `You are evaluating a commercial real estate lead inquiry.
Analyze the following inquiry description and respond with a JSON object.

Inquiry: "${truncated}"

Respond with exactly this JSON structure:
{
  "intent_level": <integer 0-8>,
  "confidence": <float 0.0-1.0>,
  "signals_detected": [<array of signal strings>],
  "timeline_estimate": "<string>",
  "reasoning": "<one sentence>"
}`;

return [{ json: { ...$input.item.json, ai_prompt, ai_score_valid: null } }];

Output Table

Output Description
ai_prompt Structured prompt for the OpenAI API (when description is valid)
ai_score_valid null (proceed to API) or false (skip to Score Combination)
ai_skip_reason Present when ai_score_valid = false; describes why the skip occurred

Engineering Rationale

NoteEngineering Rationale

The early exit on absent or short descriptions eliminates unnecessary API calls and ensures the fallback path is clean. An ai_score_valid = false set here routes via the IF node directly to Score Combination with no API call the execution log shows no OpenAI node execution, confirming the skip was intentional. Truncating at 500 characters bounds API token cost and keeps the prompt focused on the most prominent intent signals in the description’s opening content.


Step 8 OpenAI API Call

Purpose

Submit the structured prompt to the OpenAI Chat Completions API and receive a JSON-format intent assessment containing the intent_level, confidence, detected signals, and reasoning. This node has no conditional logic all pre-processing was handled in Step 7 and all post-processing will be handled in Step 9.


Operation Summary

Property Value
Node Type HTTP Request
Method POST
Endpoint https://api.openai.com/v1/chat/completions
Primary Function Send prompt; receive structured intent assessment
Input ai_prompt from Step 7
Output Raw OpenAI response containing choices[0].message

Request Payload

{
  "model": "gpt-4o-mini",
  "temperature": 0.1,
  "response_format": { "type": "json_object" },
  "messages": [
    {
      "role": "user",
      "content": "{{$json.ai_prompt}}"
    }
  ]
}

Headers:

Authorization: Bearer {{$env.OPENAI_API_KEY}}
Content-Type:  application/json

Response Processing

The raw response is passed directly to Step 9 (Parse Response). No transformation occurs in this node.


Output Table

Output Description
choices[0].message.content JSON string containing intent assessment

Engineering Rationale

NoteEngineering Rationale

Setting response_format: { "type": "json_object" } enforces structured JSON output from the API, preventing markdown-fenced responses (\``json … ```) that would failJSON.parse()in the downstream node. Temperature0.1` minimizes response variability the AI evaluation should be near-deterministic for a given input, not creative. Keeping this node free of conditional logic makes it independently maintainable: changing the model requires updating only this node.


Step 9 Parse AI Response

Purpose

Extract and validate the intent_level and confidence values from the OpenAI response, assign the confidence band, compute the confidence_multiplier, and set ai_score_valid. A try/catch block handles malformed responses gracefully by routing to the rule-only fallback rather than terminating the workflow.


Operation Summary

Property Value
Node Type Code
Method In-memory
Primary Function Parse, validate, and classify AI response; set validity flag
Input Raw OpenAI response from Step 8
Output ai_score, ai_confidence, confidence_band, ai_score_valid

Implementation Logic

try {
  const raw    = $input.item.json.choices[0].message.content;
  const parsed = JSON.parse(raw);

  const intent_level = Math.min(8, Math.max(0, Number(parsed.intent_level) || 0));
  const confidence   = Math.min(1.0, Math.max(0.0, Number(parsed.confidence) || 0));

  let confidence_band, confidence_multiplier;
  if (confidence >= 0.80) {
    confidence_band = 'high';   confidence_multiplier = 1.0;
  } else if (confidence >= 0.55) {
    confidence_band = 'medium'; confidence_multiplier = 0.5;
  } else {
    confidence_band = 'low';    confidence_multiplier = 0.0;
  }

  const ai_score_valid = confidence_band !== 'low';

  return [{ json: {
    ...$input.item.json,
    ai_score:             intent_level,
    ai_confidence:        confidence,
    confidence_band,
    confidence_multiplier,
    ai_score_valid,
    ai_signals_detected:  parsed.signals_detected || [],
    ai_timeline_estimate: parsed.timeline_estimate || '',
    ai_reasoning:         parsed.reasoning || '',
  }}];
} catch (e) {
  return [{ json: {
    ...$input.item.json,
    ai_score_valid:   false,
    ai_skip_reason:   'parse_error',
    ai_error_message: e.message,
  }}];
}

Confidence Band Assignment Table

Confidence Value Band Multiplier Effect
≥ 0.80 high 1.0 Full AI contribution applied
0.55–0.79 medium 0.5 Half AI contribution applied
< 0.55 low 0.0 AI contribution excluded
Parse failure fallback ai_score_valid = false

Output Table

Output Description
ai_score Intent level 0–8 (clamped)
ai_confidence Confidence value 0.0–1.0 (clamped)
confidence_band "high", "medium", "low", or "fallback"
confidence_multiplier Weight applied to AI score in combination formula
ai_score_valid true if band is high or medium; false if low or error

Engineering Rationale

NoteEngineering Rationale

The try/catch is a structural requirement, not defensive programming. A malformed API response format, a network timeout partial response, or an unexpected schema change from OpenAI would otherwise throw an uncaught exception that terminates the entire workflow leaving no Contact record, no scoring output, and no notification. The catch block converts any failure to ai_score_valid = false, allowing the workflow to continue on the rule-only path. This guarantees that an AI API issue never blocks lead processing.

The routing between Build Prompt and the HTTP Request uses an IF node: ai_score_valid === null proceeds to the HTTP Request; ai_score_valid === false skips directly to Score Combination via a Merge node.

Integration Checkpoint 2.10.4: Test three cases: (1) a rich description with specific numbers, deadline, and decision-maker language verify confidence >= 0.80 and ai_score_valid = true; (2) a vague description verify confidence < 0.55 and ai_score_valid = false; (3) empty description verify the Build Prompt node sets ai_score_valid = false and the OpenAI HTTP Request node is skipped (no API call in the n8n execution log for the OpenAI node).

The three-node AI scoring pattern separates concerns cleanly. The Build Prompt node handles all pre-processing and early exits; the HTTP Request node has no conditional logic; the Parse Response node handles all post-processing and error recovery. Changing the AI model requires updating only the HTTP Request node’s body. Changing the prompt structure requires updating only the Build Prompt node. This separation makes each component independently maintainable.

CautionProduction Risk

Not routing around the HTTP Request node when ai_score_valid = false is a structural error. Students who connect Build Prompt directly to the HTTP Request without an IF node will call the OpenAI API with an undefined prompt when the inquiry description is absent, generating an API error rather than a clean fallback. The IF node between Build Prompt and HTTP Request is a structural requirement of the three-node pattern, not an optimization.

CautionProduction Risk

Not using response_format: { "type": "json_object" } in the OpenAI API request will cause GPT-4o-mini to return markdown-fenced JSON (e.g., ```json\n{...}\n```) on some prompts. JSON.parse() on a markdown-fenced string throws a SyntaxError, which the Parse Response node’s try/catch converts to ai_score_valid = false. The result is that every such request silently falls back to rule-only scoring with no error logged, because the fallback path is the intended graceful degradation response.

CautionProduction Risk

Not implementing a try/catch in the Parse Response Code node means that a malformed or unexpected API response format causes the node to throw an uncaught exception, which terminates the entire workflow execution at the AI scoring node and produces no HubSpot Contact record, no scoring, and no notification. The Parse Response node must catch all exceptions and convert them to ai_score_valid = false, allowing the workflow to continue on the rule-only path.


2.10.5 Score Combination Logic

The Score Combination layer takes the rule_score, ai_score, confidence_multiplier, and ai_score_valid values produced by the two Enrichment layers and computes the final combined_score. It then applies the four scoring constraints ceiling (28), AI contribution cap (4), returning-contact preservation (Constraint C3), and internal submission filter and maps the resulting score to a priority_label (Hot, Warm, Cool, or Cold). The Score Combination layer is the most analytically significant node in Workflow A: it is the point at which all enrichment signals converge into a single routing decision.

The Score Combination layer applies the calibrated additive formula combined_score = rule_score + min(4, ai_score × confidence_multiplier) which resolves the Hot-tier calibration gap identified in Chapter 2.9.3. This formula produces a practical maximum of 24 (rule_score 20 + AI cap 4), making the Hot threshold of 20 achievable for leads with near-maximum rule scores and any AI contribution.

If the combination formula is incorrect for example, if ai_score_valid = false is not handled separately, causing the formula to apply a zero AI score every lead will be scored differently than intended. The operational effect may not be immediately visible, but over time it will skew the priority distribution and degrade the brokerage’s ability to rank and respond to leads appropriately.

The priority label produced by Score Combination controls six downstream behavioral decisions: the HubSpot lifecyclestage assigned by the Routing layer, the followup_schedule_tier used by the Timing layer, the Workflow C cadence configuration, the Slack notification channel, the Task urgency, and the audit Note content. A change to one priority boundary cascades through all six decisions for every affected lead.

Implementation: Workflow A Score Combination Code Node

Step 10 Score Combination and Priority Mapping

Purpose

Merge the rule score and AI score into a single combined_score, apply the four scoring constraints, and map the result to a priority_label. This is the single node that converts all enrichment signals into the routing decision that governs every downstream operational action.


Operation Summary

Property Value
Node Type Code
Method In-memory
Primary Function Combine rule and AI scores; apply constraints; assign priority
Input rule_score, ai_score, ai_score_valid, contact_action
Output combined_score, priority_label, scoring_model_version

Implementation Logic

const input        = $input.item.json;
const rule_score   = input.rule_score || 0;
const AI_CAP       = 4;
const CEILING      = 28;

// Internal domain filter (Constraint C4)
const email_domain = (input.email || '').split('@')[1] || '';
const internal     = ($env.INTERNAL_DOMAINS || '').split(',')
                       .map(d => d.trim().toLowerCase());
if (internal.includes(email_domain.toLowerCase())) {
  return [{ json: {
    ...input,
    priority_label:      'internal',
    requires_manual_review: true,
    scoring_model_version: '2.5.1',
    combined_score:      0,
  }}];
}

// Compute raw combination
let combined;
if (input.ai_score_valid) {
  const ai_contribution = Math.min(AI_CAP,
    (input.ai_score || 0) * (input.confidence_multiplier || 0));
  combined = rule_score + ai_contribution;
} else {
  combined = rule_score;                     // rule-only fallback
}

// Constraint C1 ceiling
combined = Math.min(CEILING, combined);

// Constraint C3 returning contact score preservation
if (input.contact_action === 'updated' &&
    Number(input.existing_combined_score) > 18) {
  combined = Number(input.existing_combined_score);
}

// Priority mapping
let priority_label;
if      (combined >= 20) priority_label = 'Hot';
else if (combined >= 13) priority_label = 'Warm';
else if (combined >=  7) priority_label = 'Cool';
else                     priority_label = 'Cold';

return [{ json: {
  ...input,
  combined_score:        Math.round(combined * 100) / 100,
  priority_label,
  requires_manual_review: !input.ai_score_valid,
  scoring_model_version: '2.5.1',
}}];

Constraint Application Table

Constraint Rule Effect
C1 Ceiling combined_score = min(28, combined) Prevents combined score from exceeding stated max
C2 AI Cap ai_contribution = min(4, ai_score × multiplier) Limits AI influence to 4 points maximum
C3 Returning If contact_action = "updated" AND existing_combined_score > 18, preserve prior Protects high-scoring returning contacts
C4 Internal If email domain in INTERNAL_DOMAINS, set priority_label = "internal" Excludes internal submissions from broker routing

Priority Threshold Table

Label combined_score Range Downstream Cadence
Hot ≥ 20 TP1 +2hr, TP2 +6hr, ESC +24hr
Warm 13–19 TP1 +24hr, TP2 +72hr, ESC +7d
Cool 7–12 TP1 +7d, TP2 +14d, ESC +21d
Cold < 7 ESC only at +30d

Output Table

Output Description
combined_score Final score (rounded to 2 decimal places)
priority_label Hot / Warm / Cool / Cold / internal
requires_manual_review true when AI was unavailable or lead is internal
scoring_model_version Version string written to Contact for future model comparison

Engineering Rationale

NoteEngineering Rationale

scoring_model_version must be written on every execution. When the scoring formula is recalibrated in the future, Contacts scored under the prior version retain their version tag making it possible to distinguish genuine lead quality changes from formula changes in historical analysis. Without this property, recalibration makes all prior scores uninterpretable. The property is a first-class operational requirement, not metadata.

Integration Checkpoint 2.10.5: Verify four priority tiers with combined AI and rule inputs: perfect rule (20) + AI (8, conf 0.92) → combined = 20 + min(4, 8×1.0) = 24 → Hot; mid rule (13) + AI (5, conf 0.71, medium) → combined = 13 + min(4, 5×0.5) = 15.5 → Warm; low rule (7) + AI unavailable → combined = 7 → Cool boundary; low rule (4) + no AI → combined = 4 → Cold.

CautionProduction Risk

Applying AI weight even when ai_score_valid = false incorporates a zero AI score at partial weight, effectively penalizing leads with empty inquiry descriptions relative to the rule-only baseline intended for that case. Writing combined = rule + min(4, ai * multiplier) without first checking the validity flag produces systematically incorrect scores for all leads without inquiry descriptions. The validity check is a functional requirement, not an optimization.

CautionProduction Risk

Changing either the formula parameters or the priority threshold values without re-verifying the other will reproduce the calibration gap identified in Chapter 2.9.3. The formula’s maximum output and the priority tier thresholds are mathematically coupled: if either changes, the tier reachability must be recalculated. Every formula or threshold change must be accompanied by a theoretical maximum output computation and a verification that all four priority tiers are reachable.

CautionProduction Risk

Not writing scoring_model_version to the Contact record makes it impossible to determine which formula version produced a given score for a Contact who was scored before and after a formula update. When the scoring model is recalibrated and existing Contact scores are compared to post-recalibration scores, the scoring_model_version property is the only way to determine whether a score difference reflects a genuine change in lead quality or a formula change. This property must be written on every scoring execution.


Diagram 2.10.2 End-to-End Lead Lifecycle Execution Path

%%{init:{"theme":"base","themeVariables":{"primaryColor":"#eef2ff","primaryTextColor":"#1e1b4b","primaryBorderColor":"#6366f1","lineColor":"#6366f1","clusterBkg":"#f8f9ff","clusterBorder":"#6366f1","titleColor":"#1e1b4b","edgeLabelBackground":"#f6f4ef","fontFamily":"system-ui,sans-serif","fontSize":"13px"}}}%%

flowchart TD
    FORM["Form POST"]:::process --> A1

    subgraph WFA["Workflow A Intake, Enrichment, Scoring"]
        A1["Webhook -> Normalize -> Validate -> Company Upsert (intake + structuring complete)"]:::trigger --> A2["Rule Score 0-20 → Build AI Prompt → OpenAI Call (enrichment complete)"]:::process
        A2 --> A3["Score Combination -> Priority Map -> Qualification Switch combined_score, priority_label written"]:::process
        A3 --> SW{Qualification branch}
        SW -->|"Hot/Warm + SQL criteria"| B1["lifecyclestage = salesqualifiedlead Notify #sales-alerts"]:::success
        SW -->|"Warm/Cool + MQL criteria"| B2["lifecyclestage = marketingqualifiedlead Notify #mql-review"]:::success
        SW -->|"Cold / insufficient"| B3["lifecyclestage = lead Notify #crm-ops"]:::success
        B1 --> A4["Follow-up timing writes Governance init writes Task + Note + Slack"]:::success
        B2 --> A4
        B3 --> A4
    end

    A4 --> WEBHOOK["HubSpot lifecycle change webhook (fires when WF-A writes lifecyclestage)"]:::trigger

    subgraph WFB1["Workflow B Transition Validation & Routing"]
        WEBHOOK --> V1{"PERMITTED_TRANSITIONS check"}:::decision
        V1 -->|Invalid| V2["#crm-ops-escalations alert, halt"]:::fallback
        V1 -->|Valid| V3["journey_state update; notification routing"]:::process
        V3 -->|If SQL| V4["Deal created (Appt Scheduled stage) associated to Contact + Company"]:::success
    end

    SCHED["Schedule Trigger (0,30 8-20 * * *)"]:::trigger --> C1

    subgraph WFC["Workflow C Follow-up Monitor"]
        C1["Business hours check -> Query overdue Contacts Per-Contact: stop conditions -> governance gate -> action"]:::process --> C2["TP1 overdue: Task + #broker-tasks followup_tp1_sent_at written; cooldown_until updated"]:::process
        C1 --> C3["TP2 overdue: Escalation Task + #sales-alerts followup_tp2_sent_at written"]:::process
        C1 --> C4["Escalation overdue: escalation_triggered = true #crm-ops-escalations + broker Slack"]:::success
    end

    TYPEFORM["Typeform survey webhook"]:::trigger --> D1

    subgraph WFD["Workflow D External Integration"]
        D1["Log -> Validate -> Resolve identity -> Governance gate Idempotency check (last_typeform_response_id) Scoped write: survey_* properties only"]:::process --> D2["Timeline upgrade -> requires_rescore_review = true Audit Note + #crm-ops-intake alert"]:::process
    end

    OPP["Opportunity / Deal management (manual broker action)"]:::success --> E1

    subgraph WFB2["Workflow B Opportunity & Close"]
        E1["Opportunity entry -> manual_override_active = true journey_state = opportunity"]:::success --> E2["Deal Closed Won -> Contact lifecycle -> Customer manual_override_active = false journey_state = customer"]:::success
        E2 --> E3["WF-C stop conditions triggered for this Contact"]:::process
    end
    classDef trigger  fill:#dcfce7,stroke:#16a34a,color:#14532d,font-weight:600
    classDef process  fill:#eef2ff,stroke:#6366f1,color:#1e1b4b
    classDef decision fill:#fef9c3,stroke:#ca8a04,color:#78350f,font-weight:600
    classDef success  fill:#d1fae5,stroke:#059669,color:#064e3b,font-weight:700
    classDef fallback fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
Figure 27.1: End-to-End Lead Lifecycle Execution Path. End-to-End Lead Lifecycle Execution Path

2.10.6 Validation Implementation

Input Validation vs. Transition Validation

The Validation layer encompasses two distinct mechanisms operating at different workflow positions. Input validation in Workflow A verifies that the incoming payload meets structural requirements before any data is written. Transition validation in Workflow B verifies that a proposed lifecycle state change is a permitted transition within the PERMITTED_TRANSITIONS state machine before any side effects are executed. Both mechanisms protect the system’s data integrity from different failure modes: input validation prevents corrupt data from entering the CRM; transition validation prevents the lifecycle state machine from being corrupted by unauthorized or erroneous state changes.

Transition validation is the architectural guarantee of lifecycle state integrity. Without it, a misconfigured external integration, a direct API write from a non-workflow source, or a manual error could advance a Contact from Lead directly to Customer, bypassing SQL and Opportunity entirely.

If the broker’s deal management process assumes that Customer stage means a Deal was created and closed in HubSpot’s pipeline, a Contact who reached Customer through an invalid transition would appear as a customer with no deal history corrupting pipeline analytics and triggering incorrect post-sale workflow actions.

Input validation addresses external data quality; transition validation addresses internal state integrity. Both are necessary; neither substitutes for the other.

Implementation: Workflow B Transition Validation

Step 11 Lifecycle Transition Validation

Purpose

Validate that the incoming HubSpot lifecycle state change is a permitted transition within the PERMITTED_TRANSITIONS state machine before executing any side effects. This node is the architectural guarantee of lifecycle state integrity it prevents unauthorized state changes from corrupting the CRM pipeline regardless of their source.


Operation Summary

Property Value
Node Type Code
Method In-memory (reads webhook payload)
Primary Function Check transition against PERMITTED_TRANSITIONS; set validity flag
Input HubSpot lifecycle change webhook payload
Output transition_valid boolean, from_stage, to_stage

Implementation Logic

const PERMITTED_TRANSITIONS = {
  lead:                    ['marketingqualifiedlead', 'salesqualifiedlead'],
  marketingqualifiedlead:  ['salesqualifiedlead'],
  salesqualifiedlead:      ['opportunity'],
  opportunity:             ['customer'],
  customer:                [],        // terminal
  other:                   ['lead', 'marketingqualifiedlead', 'salesqualifiedlead'],
};

const payload    = $input.item.json;
const from_stage = payload.previousValue || 'other';
const to_stage   = payload.value;
const contactId  = payload.objectId;

const permitted  = PERMITTED_TRANSITIONS[from_stage] || [];
const transition_valid = permitted.includes(to_stage);

return [{ json: { from_stage, to_stage, contactId, transition_valid } }];

PERMITTED_TRANSITIONS State Machine Table

From Stage Permitted Next Stages
lead marketingqualifiedlead, salesqualifiedlead
marketingqualifiedlead salesqualifiedlead
salesqualifiedlead opportunity
opportunity customer
customer (none terminal state)
other (null/new) lead, marketingqualifiedlead, salesqualifiedlead

Response Processing

When transition_valid = false: Workflow B sends a Slack alert to #crm-ops-escalations with the Contact ID, from/to stage values, and timestamp; creates a HubSpot Note on the Contact recording the attempted invalid transition; and exits without executing any valid-transition downstream actions. Workflow B does NOT revert the lifecyclestage property reversion is a destructive action requiring human authorization.


Output Table

Output Description
transition_valid true if transition is in PERMITTED_TRANSITIONS, else false
from_stage Previous lifecycle stage (or "other" if null)
to_stage New lifecycle stage from the webhook payload
contactId HubSpot Contact ID for all subsequent API calls

Engineering Rationale

NoteEngineering Rationale

Workflow B does not revert invalid lifecycle writes. Automated state reversion is a destructive action: if the reversion itself is incorrect (because the system’s understanding of the prior state was stale), it produces a new incorrect state while destroying the evidence of the original problem. The correct behavior is detection and escalation alerting the operations team, creating an audit Note, and halting all side effects. Human authorization is required for reversion. The governance layer detects; humans decide.

Integration Checkpoint 2.10.6: Trigger lead -> salesqualifiedlead (valid) verify Workflow B routes to the valid branch and Deal creation logic executes. Trigger salesqualifiedlead -> lead (invalid regression) verify Workflow B routes to the invalid branch, the ops Slack alert fires, a Note is created, and no Deal or journey_state update occurs.

CautionProduction Risk

Implementing transition validation as a warning rather than a guard defeats its purpose. Students who configure the invalid-transition branch to send a Slack alert but still execute the downstream deal-creation and journey-state-update logic are flagging the violation while still processing it. The invalid transition must be an exit point: any Contact that fails transition validation exits Workflow B immediately after the alert and Note creation, with no downstream side effects.

CautionProduction Risk

Not accounting for a null previousValue which occurs when a Contact’s lifecyclestage is set for the first time will route all new Contact lifecycle writes to the invalid-transition branch if the validation check is PERMITTED_TRANSITIONS[previousValue] and previousValue is null or undefined. The validation Code node must handle the null previous value case explicitly, treating it as the "other" state that permits entry to lead, marketingqualifiedlead, or salesqualifiedlead.

CautionProduction Risk

Not logging invalid transitions to the Contact’s HubSpot Note history means that an engineer investigating a lifecycle state anomaly six weeks later has no Contact-visible record that the anomaly was detected. The transition audit Note “INVALID LIFECYCLE TRANSITION detected: [from] → [to], source: [webhook]” must be written to the Contact before exiting the invalid-transition branch. This Note is the primary artifact for post-incident investigation.


2.10.7 Routing Implementation

The Routing layer takes the priority_label and lifecycle qualification criteria from the Scoring and Validation layers and routes each Contact through the correct notification path, Task assignment, and pipeline entry. Routing is the bridge between the analytical layers (scoring) and the operational layers (delivery, timing, lifecycle management). A correctly configured Routing layer ensures that every SQL lead immediately generates a broker notification, a Task, and a Deal record, while every Cold lead generates a low-priority ops alert and a 30-day follow-up timer.

The Routing layer’s configuration directly determines the brokerage’s operational responsiveness to inbound leads. A misrouted SQL lead assigned a Cool cadence because a routing condition was misconfigured waits 7 days for its first TP1 notification instead of 24 hours. For a lead with a hard lease deadline 90 days out, that 6-day difference may cost the engagement. Routing in the four-workflow architecture is implemented in two places: the Qualification Switch in Workflow A (routing by priority label and SQL entry criteria at intake time) and the Notification Router in Workflow B (routing by lifecycle stage after transition validation).

Implementation: Workflow A Qualification Switch

Step 12 Qualification Switch and Lifecycle Assignment

Purpose

Evaluate the combined score and SQL entry criteria to assign the Contact’s initial lifecyclestage and determine which Slack notification channel receives the intake alert. This Switch node produces the lifecycle routing decision that triggers Workflow B and establishes all downstream delivery behavior.


Operation Summary

Property Value
Node Type Switch (sequential branch evaluation)
Method In-memory conditional routing
Primary Function Map priority label + SQL criteria to lifecyclestage
Input priority_label, phone, company, property_size, timeline
Output target_lifecyclestage, notification_channel

Branch Evaluation Table

Branch Condition lifecyclestage notification_channel
1 Hot SQL priority_label = "Hot" AND phone present AND (size ≥ 5k sqft OR timeline = “Immediate”) salesqualifiedlead #sales-alerts
2 Warm SQL priority_label = "Warm" AND phone present AND company present AND (size ≥ 5k sqft OR timeline = “Immediate”) salesqualifiedlead #sales-alerts
3 MQL priority_label in [“Warm”, “Cool”] AND (phone OR company present) marketingqualifiedlead #mql-review
4 Lead All other cases (fallback) lead #crm-ops-intake

Branches are evaluated in order. The first matching branch wins. After the Switch, Workflow A writes target_lifecyclestage to the Contact’s HubSpot record via PATCH, which triggers the HubSpot property-change webhook and activates Workflow B.


Engineering Rationale

NoteEngineering Rationale

Sequential Switch evaluation prevents a Contact that matches two branches from triggering both. If Hot SQL and Warm SQL were evaluated as parallel IF nodes, a Contact meeting both conditions would write lifecyclestage twice and potentially generate two Deal records. Sequential evaluation is the correct primitive here: one Contact, one routing decision, one lifecycle write. The notification_channel is set once at this Switch node and all downstream delivery nodes read { $json.notification_channel } not a repeated hard-coded channel name.

Implementation: Workflow B Notification Router

Step 13 Lifecycle-Based Notification Routing

Purpose

Route the validated lifecycle transition to the correct downstream action path. SQL transitions create Deals. Opportunity entries activate the manual override. Customer entries close out governance and automation. This router translates the validated state machine transition into the correct set of side effects.


Operation Summary

Property Value
Node Type Switch (post-transition-validation routing)
Primary Function Route by to_stage to the correct delivery and state path
Input to_stage from Transition Validation (Step 11)
Output Route to Deal creation, governance writes, or close handler

Routing Table

to_stage Actions
salesqualifiedlead Notify #sales-alerts; create Deal in Appointment Scheduled stage; associate to Contact and Company
marketingqualifiedlead Notify #mql-review; write journey_state = "qualified_prospect"
opportunity Notify #sales-alerts (opportunity created); write manual_override_active = true, journey_state = "opportunity"
customer Notify #sales-alerts (deal closed); write manual_override_active = false, journey_state = "customer"

Engineering Rationale

NoteEngineering Rationale

Setting manual_override_active = true at Opportunity entry is a state machine side effect, not an optional feature. When a broker advances a Contact to Opportunity, they are signaling active direct management. The system must respect this signal immediately Workflow C’s next execution will find the flag set and halt automated follow-up. If the flag is not set automatically at Opportunity entry, the broker must remember to set it manually, and the automated follow-up will continue reaching the Contact while the broker is actively engaged.

Integration Checkpoint 2.10.7: Submit one SQL-qualifying and one Lead-qualifying test submission. Verify: SQL submission produces lifecyclestage = "salesqualifiedlead", a #sales-alerts Slack notification, a Task, and a Deal record in HubSpot. Lead submission produces lifecyclestage = "lead" and a #crm-ops-intake notification. Verify Workflow B fires for both lifecycle changes and that the SQL submission triggers Deal creation.

The Qualification Switch uses sequential branch evaluation, not parallel evaluation a Switch node’s branches are evaluated in order and the first matching branch wins. If Hot SQL and Warm SQL conditions were evaluated as parallel IF nodes, a lead that matches both would trigger both branches, creating duplicate notifications and potentially duplicate Deal records. The Switch node’s sequential evaluation prevents this structural routing ambiguity.

CautionProduction Risk

Not testing Workflow B’s behavior for lifecycle changes written directly from HubSpot’s UI by brokers rather than only from Workflow A leaves an untested interaction path. Workflow B’s transition validation handles direct HubSpot writes gracefully: a broker manually advancing a Contact from Opportunity to Customer triggers a valid opportunity -> customer transition and the correct post-sale actions. Students who test only the Workflow A path may not verify this and may configure Workflow B in a way that treats only Workflow A writes as valid, missing broker-initiated transitions.

CautionProduction Risk

Hard-coding Slack channel names directly in routing node configurations rather than reading them from environment variables is the failure mode demonstrated in Chapter 2.9’s Northgate Capital Partners case study. All routing destinations Slack channel names, user IDs, webhook URLs must be stored as n8n environment variables. A channel rename must require only an environment variable update, not a workflow node edit and redeployment.

CautionProduction Risk

Not setting notification_channel as a data property in the Qualification Switch and instead repeating the Slack channel name in every downstream delivery node creates a maintenance problem: changing which channel receives SQL alerts requires finding and updating every node that has the channel name hard-coded. The correct pattern sets notification_channel = "#sales-alerts" once at the Switch node and all downstream delivery nodes read { $json.notification_channel }.


Diagram 2.10.3 Property Ownership and Data Flow Map

Property Ownership Table

Owner Workflow Property Group Properties Access
Workflow A Contact Identity firstname, lastname, email, phone, company, lead_source_channel, property_type, property_size, timeline, inquiry_description Write
Workflow A Scoring combined_score, priority_label, ai_confidence, requires_manual_review, scoring_model_version Write
Workflow A Follow-up Timing followup_task_created_at, followup_schedule_tier, followup_tp1_due_at, followup_tp2_due_at, followup_esc_due_at Write
Workflow A Governance (init) communication_governance_status, journey_state, last_outreach_at, last_outreach_channel, communication_cooldown_until (initial set) Write
Workflow B Lifecycle State lifecyclestage (validated transitions only) Write
Workflow B Journey State journey_state (post-transition updates) Write
Workflow B Opportunity Override manual_override_active, manual_override_by, manual_override_reason Write
Workflow C Follow-up State followup_tp1_sent_at, followup_tp2_sent_at, escalation_triggered, escalation_triggered_at Write
Workflow C Governance (updates) communication_cooldown_until (post-action), last_outreach_at, last_outreach_channel Write
Workflow D Survey Enrichment survey_property_type_preference, survey_target_sqft_min/max, survey_preferred_submarkets, survey_decision_maker_role, survey_timeline_confirmation, survey_completed_at, last_typeform_response_id, requires_rescore_review Write
All four workflows Governance Gate manual_override_active (B writes), communication_governance_status (A init, C updates), communication_cooldown_until (A init, C updates) Read

Data Flow Between Workflows

G A Workflow A Intake & Enrichment B Workflow B Lifecycle & Deals A->B lifecyclestage write triggers HubSpot webhook C Workflow C Follow-up Monitor A->C followup_*_due_at stored for overdue queries B->C manual_override_active governs delivery gate B->C lifecyclestage = customer triggers stop condition D Workflow D External Integration OPS Ops Review D->OPS requires_rescore_review prompts manual review
Figure 27.2: Property Ownership and Data Flow Map. Property Ownership and Data Flow Map

2.10.8 Timing Implementation

Pre-Computed Timing Timestamps

The Timing layer has two distinct components with different owners. The timing metadata write in Workflow A pre-computes and stores follow-up due timestamps at intake time, applying business hours adjustments immediately. The timing evaluation in Workflow C reads those timestamps on a schedule and determines whether follow-up actions are overdue. The separation of timing write from timing evaluation is the architectural key: Workflow C does not need to know when a Contact was created or what tier they were assigned it only compares stored timestamps against the current time. This makes Workflow C’s query logic simple, stateless, and scalable.

The business hours utility function which advances a computed timestamp to the next valid business window (8am–8pm EST, Monday through Friday) is the single most operationally important function in the Timing layer. A follow-up Task delivered at 11:45pm on a Friday provides no operational value.

The pre-computed timestamp pattern is also a requirement of the HubSpot Search API, not a stylistic preference. HubSpot’s Search API filters on stored Contact properties using operators like LTE (less than or equal to). It cannot filter on values computed at query time.

A “compute on demand” approach where Workflow C calculates each Contact’s follow-up due time during the evaluation would require fetching all active Contacts and filtering in n8n, which does not scale beyond a few hundred contacts. The pre-computed timestamp pattern enables server-side filtering with HubSpot’s indexed properties, allowing Workflow C to efficiently process thousands of active Contacts.

Implementation: Workflow A Timing Metadata Write

Step 14 Compute and Write Follow-up Timestamps

Purpose

Pre-compute business-hours-adjusted due timestamps for each follow-up touchpoint and write them to the Contact record. These stored timestamps are the only mechanism by which Workflow C can query overdue Contacts efficiently at scale they must be computed now and written before Workflow A exits.


Operation Summary

Property Value
Node Type Code (timestamp computation) + HTTP Request (HubSpot PATCH)
Method Code → PATCH /crm/v3/objects/contacts/{contactId}
Primary Function Compute BH-adjusted timestamps; write to Contact properties
Input priority_label, current timestamp, FOLLOWUP_CONFIG_* env vars
Output followup_tp1_due_at, followup_tp2_due_at, followup_esc_due_at written to Contact

Implementation Logic

const { DateTime } = require('luxon');
const tz      = 'America/New_York';
const now     = DateTime.now().setZone(tz);
const tier    = $input.item.json.priority_label;
const cfg     = JSON.parse($env[`FOLLOWUP_CONFIG_${tier.toUpperCase()}`] || '{}');

function addBizHours(dt, hours) {
  let remaining = hours;
  let cursor    = dt;
  while (remaining > 0) {
    cursor = cursor.plus({ hours: 1 });
    const h = cursor.hour;
    const d = cursor.weekday; // 1=Mon...7=Sun
    if (d <= 5 && h >= 8 && h < 20) remaining--;
  }
  return cursor;
}

function addBizDays(dt, days) {
  return addBizHours(dt, days * 12); // 12 biz hrs/day (8am-8pm)
}

function advanceToBizHours(dt) {
  let cursor = dt;
  while (cursor.weekday > 5 || cursor.hour < 8 || cursor.hour >= 20) {
    if (cursor.weekday > 5 || cursor.hour >= 20) {
      cursor = cursor.plus({ days: 1 }).set({ hour: 8, minute: 0 });
    } else if (cursor.hour < 8) {
      cursor = cursor.set({ hour: 8, minute: 0 });
    }
  }
  return cursor;
}

const timestamps = {};
if (cfg.tp1_biz_hours != null)
  timestamps.followup_tp1_due_at = addBizHours(now, cfg.tp1_biz_hours).toISO();
if (cfg.tp2_biz_hours != null)
  timestamps.followup_tp2_due_at = addBizHours(now, cfg.tp2_biz_hours).toISO();
if (cfg.esc_cal_days != null)
  timestamps.followup_esc_due_at =
    advanceToBizHours(now.plus({ days: cfg.esc_cal_days })).toISO();

return [{ json: { ...$input.item.json, ...timestamps,
  followup_schedule_tier:   tier,
  followup_task_created_at: now.toISO(),
}}];

Cadence Interval Reference Table

Tier TP1 TP2 Escalation
Hot +2 business hours +6 business hours +24 business hours
Warm +24 business hours +72 business hours +7 calendar days (BH)
Cool +7 calendar days +14 calendar days +21 calendar days
Cold (none) (none) +30 calendar days

Output Table

Output Description
followup_tp1_due_at ISO timestamp when TP1 broker reminder is due
followup_tp2_due_at ISO timestamp when TP2 escalation reminder is due
followup_esc_due_at ISO timestamp when escalation fires
followup_schedule_tier Priority tier string Hot/Warm/Cool/Cold
followup_task_created_at Intake processing timestamp

Engineering Rationale

NoteEngineering Rationale

Business hours adjustment must occur at write time in Workflow A, not at evaluation time in Workflow C. Workflow C queries HubSpot using followup_tp1_due_at <= now a server-side filter on stored property values. If the stored timestamp is the raw computed time before business hours adjustment, Workflow C will find the Contact as overdue the moment the raw time passes, even if the business-hours-adjusted time is days later. The adjustment must be baked into the stored value. Deferring it to evaluation time breaks the Search API filter pattern entirely.

Implementation: Workflow C Timing Evaluation

Step 15 Overdue Contact Query

Purpose

Query HubSpot’s Contact Search API to identify Contacts whose follow-up due timestamps have passed and who have not yet received the corresponding follow-up action. Three parallel queries run at each Workflow C execution, each targeting a different touchpoint (TP1, TP2, Escalation).


Operation Summary

Property Value
Node Type HTTP Request (×3, parallel)
Method POST
Endpoint /crm/v3/objects/contacts/search
Primary Function Retrieve Contacts with overdue follow-up timestamps
Input Current timestamp (ISO)
Output Lists of Contact records for TP1, TP2, and Escalation processing

Request Payload TP1 Query

{
  "filterGroups": [
    {
      "filters": [
        { "propertyName": "followup_tp1_due_at",   "operator": "LTE", "value": "{{now_iso}}" },
        { "propertyName": "followup_tp1_sent_at",  "operator": "NOT_HAS_PROPERTY" },
        { "propertyName": "lifecyclestage",         "operator": "NOT_IN",
          "values": ["customer", "disqualified"] }
      ]
    }
  ],
  "properties": [
    "email", "firstname", "lastname", "company",
    "priority_label", "followup_schedule_tier",
    "communication_governance_status", "manual_override_active",
    "communication_cooldown_until", "followup_tp1_due_at"
  ],
  "limit": 50
}

Parallel TP2 and Escalation queries use followup_tp2_due_at / followup_tp2_sent_at and followup_esc_due_at / escalation_triggered respectively, with the same lifecycle exclusion filter.

The Schedule Trigger is configured as 0,30 8-20 * * * (every 30 minutes between 8am and 8pm). An additional business hours check at the start of execution verifies the current day is not a weekend before proceeding.


Output Table

Output Description
results Array of Contact records with all required properties
total Count of matching Contacts in this query

Engineering Rationale

NoteEngineering Rationale

The limit: 50 per query is a deliberate throughput boundary. With a 30-minute polling cycle and a max of 50 Contacts per query, the system can process 100 Contacts per hour (TP1 + TP2) plus 50 escalations. This is sufficient for a brokerage receiving 200–400 leads per week. If lead volume exceeds this capacity, the polling interval should be reduced rather than the limit increased each Contact in the batch adds API calls and Slack messages, and exceeding Slack’s per-minute message rate causes silent delivery failures.

Integration Checkpoint 2.10.8: Set a test Contact’s followup_tp1_due_at to 10 minutes in the past. Wait for the next Workflow C cycle. Verify: the Contact appears in the TP1 overdue query, governance gate permits action, a Task is created, followup_tp1_sent_at is written, and communication_cooldown_until is set to 30 minutes in the future.

CautionProduction Risk

Not accounting for daylight saving time transitions in the business hours utility function produces incorrect business hours calculations during EDT. A function using a fixed UTC offset of -5 hours for Eastern Time produces timestamps that are one hour off during summer months (EDT uses -4). Production implementations must use a timezone-aware library (such as luxon or date-fns-tz) with BROKERAGE_TIMEZONE = "America/New_York" rather than hard-coded UTC offsets.

CautionProduction Risk

Not applying the business hours check to weekends only to out-of-hours times within business days will schedule TP1 notifications on Saturdays and Sundays for any lead submitted late Friday. The business hours utility function must check both the time of day (8am–8pm) and the day of the week (Monday–Friday). A Saturday 9am timestamp must advance to Monday 8am, not remain as-is because the time falls within the 8am–8pm window.

CautionProduction Risk

Deferring timestamp adjustment to Workflow C’s evaluation time rather than pre-computing at Workflow A’s write time breaks the HubSpot Search API filter pattern. Workflow C queries followup_tp1_due_at <= now against stored property values. If followup_tp1_due_at stores the raw computed time before business hours adjustment, Workflow C will query it as overdue as soon as the raw time passes even if the adjusted (business hours) time is days later. Business hours adjustment must be applied at write time in Workflow A.


2.10.9 Delivery Implementation

The Delivery layer executes the outbound actions that the system has been preparing since intake: creating HubSpot Tasks for broker follow-up, sending Slack notifications to the appropriate channels, creating Deal records in HubSpot’s pipeline, and writing audit Notes. Delivery actions are the visible output of the CRM automation platform they are what brokers see, act on, and evaluate. Every preceding layer exists to ensure that Delivery actions are accurate, timely, authorized, and appropriately targeted.

Delivery is distributed across all four workflows. Workflow A delivers the initial intake Task and notification. Workflow B delivers lifecycle-transition notifications and Deal records. Workflow C delivers follow-up and escalation notifications. Workflow D delivers the survey-completion Note and re-score alert. Delivery is the layer where governance enforcement is most operationally visible. When a Contact has manual_override_active = true, Workflow C’s governance gate prevents automated follow-up Tasks and notifications from firing protecting the broker relationship. When communication_cooldown_until has not expired, the gate prevents a second notification from firing in the same window even if TP1 and TP2 happen to fall due simultaneously.

Implementation: Workflow C Governed Follow-up Delivery

Step 16 Governance Gate Check

Purpose

Before executing any follow-up delivery action on a Contact, verify that all three governance conditions are satisfied. A Contact blocked by any condition is skipped for this cycle the due timestamp is preserved and the Contact is re-evaluated at the next Workflow C execution.


Operation Summary

Property Value
Node Type Code
Primary Function Evaluate three governance conditions; determine permit or halt
Input Contact properties from overdue query
Output governance_permit: true/false, governance_block_reason

Implementation Logic

const contact = $input.item.json;
const now     = new Date().toISOString();

const override_active = contact.manual_override_active === 'true';
const status_active   = contact.communication_governance_status === 'active';
const cooldown_past   = !contact.communication_cooldown_until ||
                        contact.communication_cooldown_until < now;

if (override_active) {
  return [{ json: { ...contact, governance_permit: false,
    governance_block_reason: 'manual_override_active' } }];
}
if (!status_active) {
  return [{ json: { ...contact, governance_permit: false,
    governance_block_reason: 'governance_status_not_active' } }];
}
if (!cooldown_past) {
  return [{ json: { ...contact, governance_permit: false,
    governance_block_reason: 'cooldown_window_active' } }];
}

return [{ json: { ...contact, governance_permit: true } }];

Governance Gate Condition Table

Condition Check Block Reason
Manual override active manual_override_active == "true" manual_override_active
Governance status not active communication_governance_status != "active" governance_status_not_active
Cooldown window has not expired communication_cooldown_until > now cooldown_window_active

When blocked, Workflow C writes a governance-hold Note to the Contact and skips all delivery actions. No sent_at properties are written the due timestamp remains eligible for the next execution cycle.


Engineering Rationale

NoteEngineering Rationale

A governance-hold Note must be written when a Contact is blocked. Without it, there is no Contact-visible record that the automated system reached the Contact but held back. A broker investigating why no TP1 Task exists despite the TP1 due date having passed needs to see the governance-hold Note to understand the block. Without the Note, the broker cannot distinguish a governance block from a system failure, and the investigation path becomes unnecessarily complex.


Step 17 Follow-up Task Creation and Slack Notification

Purpose

Create the HubSpot broker Task, associate it to the Contact, send the Slack notification, and write all post-action state properties in a single PATCH call. Task creation precedes Slack notification: if Task creation fails, the notification does not fire. If notification fails, the Task still exists and the broker can act on it.


Operation Summary

Property Value
Node Type HTTP Request (Task create) + HTTP Request (Associate) + HTTP Request (Slack) + HTTP Request (PATCH)
Method POST (Task), PUT (Associate), POST (Slack), PATCH (HubSpot)
Primary Function Deliver follow-up action; record state; enforce post-cooldown
Input Contact record (governance-permitted)
Output Task created, Slack sent, sent_at and cooldown_until written

Task Creation Payload

{
  "properties": {
    "hs_task_subject":  "Follow up {{firstname}} {{lastname}} @ {{company}}",
    "hs_task_type":     "CALL",
    "hs_timestamp":     "{{now_iso}}",
    "hs_task_priority": "HIGH",
    "hs_task_status":   "NOT_STARTED"
  }
}

Endpoint: POST /crm/v3/objects/tasks

After creation, associate the Task to the Contact:

Endpoint: PUT /crm/v4/objects/tasks/{taskId}/associations/contacts/{contactId}/task_to_contact


Slack Channel Routing Table

Action Slack Channel Message Content
TP1 #broker-tasks Contact name, company, score, TP1 due time
TP2 #sales-alerts Contact name, company, score, escalation notice
Escalation #crm-ops-escalations Contact name, score, all touchpoint timestamps

Post-Action State PATCH

{
  "properties": {
    "followup_tp1_sent_at":         "{{now_iso}}",
    "communication_cooldown_until": "{{now_plus_30min_iso}}",
    "last_outreach_at":             "{{now_iso}}",
    "last_outreach_channel":        "task"
  }
}

All post-action properties are written in a single PATCH call to maintain atomicity.


Engineering Rationale

NoteEngineering Rationale

The post-action state write must be a single PATCH call. If followup_tp1_sent_at is written in one call and communication_cooldown_until in a second call that subsequently fails, the Contact has a sent_at timestamp but no cooldown Workflow C will process the Contact again in 30 minutes and attempt TP1 a second time. A single PATCH makes the write atomic from the Contact record’s perspective: either all post-action properties are written or none are, and the retry path is unambiguous.

Implementation: Workflow B Deal Delivery

Step 18 Deal Creation and Association

Purpose

Create the HubSpot Deal record representing the Contact’s SQL-qualified pipeline entry, associate it to both the Contact and the Company, and return the Deal ID for the audit Note. This step is idempotent it checks for an existing open Deal before creating to prevent duplicate records under webhook retry conditions.


Operation Summary

Property Value
Node Type HTTP Request (search) + HTTP Request (create) + HTTP Request (associate ×2)
Method POST (search, create, associate)
Endpoint /crm/v3/objects/deals/search, /crm/v3/objects/deals, /crm/v4/objects/deals/{id}/associations/...
Primary Function Idempotent Deal creation; Contact + Company association
Input contactId, companyId, Contact properties
Output dealId, Deal created confirmation

Deal Creation Payload

{
  "properties": {
    "dealname":    "{{company}} {{firstname}} {{lastname}} {{year}}",
    "dealstage":   "{{$env.HUBSPOT_STAGE_APPT_SCHEDULED}}",
    "pipeline":    "{{$env.HUBSPOT_DEAL_PIPELINE_ID}}",
    "deal_source": "{{lead_source_channel}}",
    "closedate":   "{{now_plus_90_days_iso}}"
  }
}

Association API Calls

PUT /crm/v4/objects/deals/{dealId}/associations/contacts/{contactId}/deal_to_contact
PUT /crm/v4/objects/deals/{dealId}/associations/companies/{companyId}/deal_to_company

Output Table

Output Description
dealId HubSpot Deal ID, written to audit Note

Engineering Rationale

NoteEngineering Rationale

The idempotency check must use explicit null-checking in the IF node: { $json.total > 0 } rather than { $json.total }. The value 0 is falsy in JavaScript, but a null or undefined response body (from a transient API error) is also falsy making it indistinguishable from “no existing deal” when evaluated without null-checking. The Northgate Capital Partners case study (Chapter 2.9) identified exactly this bug: { $json.total } evaluated a null response as “no deal,” creating duplicate records. The correct check is total !== null && total > 0.

On deal close (Closed Won stage), Workflow B updates lifecyclestage = "customer", manual_override_active = false, and journey_state = "customer".

Integration Checkpoint 2.10.9: Trace a complete intake-to-delivery sequence for a Warm SQL lead. Verify: intake Task created, governance init written, follow-up timestamps written, lifecyclestage = "salesqualifiedlead", Deal created with correct name and stage, #sales-alerts notification delivered. Advance time past TP1 due timestamp and verify the next Workflow C cycle creates the TP1 Task and notification. Set manual_override_active = true and verify the next Workflow C cycle governance-blocks the TP2 action and writes a governance-hold Note.

Key Principle

Task creation must precede Slack notification. The Task is the actionable artifact; the notification is the alert. If both cannot succeed, the Task carries more operational value than the notification.

Task creation should precede Slack notification in the Delivery layer. If Task creation succeeds but Slack fails, the broker still receives the Task in HubSpot and can act on it. If Slack fires first and Task creation subsequently fails, the broker sees a notification about a lead but has no actionable Task. Task creation is the higher-consequence action; ordering it before notification is the correct reliability decision.

CautionProduction Risk

Configuring Slack API calls as “continue on error” silently absorbs channel name errors, bot permission failures, and network timeouts without surfacing them in n8n’s error log. The Northgate Capital Partners case study in Chapter 2.9 demonstrates exactly this failure: a renamed channel caused all escalation notifications to fail silently while the escalation_triggered flag was still being set correctly. All Slack API calls must treat non-2xx responses as errors that route to the error branch and generate a log entry.

CautionProduction Risk

Not writing all post-action state properties (followup_tp*_sent_at, communication_cooldown_until, last_outreach_at, last_outreach_channel) in a single PATCH call introduces a partial-write failure risk. If the PATCH after TP1 delivery writes followup_tp1_sent_at but then the workflow execution fails before writing communication_cooldown_until, Workflow C will process this Contact again in 30 minutes (the due timestamp was cleared but no cooldown was set) and attempt TP1 delivery a second time. A single PATCH for all post-action state properties makes the write atomic from the Contact record’s perspective.

ImportantCritical Requirement

Not checking for an existing open Deal before creating one in Workflow B will create duplicate Deal records if the lifecycle webhook fires more than once for the same SQL transition (which can occur under HubSpot webhook retry conditions). The idempotent Deal creation pattern search for existing open Deals associated to the Contact before creating is a correctness requirement, not an optimization.


Diagram 2.10.4 Workflow Coordination and Event Flow

Coordination mechanism: shared HubSpot state. No workflow calls another directly all coordination happens through Contact property reads and writes, as traced in the sequence below for a representative lead.

sequenceDiagram
    participant Form as Website Form
    participant A as Workflow A
    participant HS as HubSpot (Contact State)
    participant B as Workflow B
    participant Sched as Schedule Trigger
    participant C as Workflow C
    participant TF as Typeform
    participant D as Workflow D
    participant Ops as Ops / Broker

    Form->>A: Form submit
    A->>HS: Write lifecyclestage = SQL/MQL/Lead followup_*_due_at, governance_status = active, cooldown_until (+30 min)
    HS-->>B: Property change event (lifecyclestage)
    B->>HS: Read previousValue, value; check PERMITTED_TRANSITIONS
    B->>HS: Write journey_state; create Deal (if SQL); manual_override_active = true (if Opportunity)

    Note over Sched,C: 30 min later
    Sched->>C: Schedule trigger (every 30 min, 8am-8pm)
    C->>HS: Read followup_*_due_at (A), manual_override_active (B), governance_status, cooldown_until
    C->>HS: Write followup_*_sent_at, escalation_triggered, cooldown_until, last_outreach_at/channel
    C->>Ops: TP1 overdue -> Task + notification

    Note over TF,D: 2 days later
    TF->>D: Typeform survey submitted
    D->>HS: Read governance_status, manual_override_active, last_typeform_response_id (idempotency)
    D->>HS: Write survey_* properties (scoped write)
    D->>Ops: requires_rescore_review = true -> Slack #crm-ops-intake (manual re-score prompt)
    Note over Ops: No automated re-score; human action required
Figure 27.3: Workflow Coordination and Event Flow. Workflow Coordination and Event Flow

2.10.10 Protection Implementation

Protection Layer: Stop Conditions vs. Governance Gate

The Protection layer comprises the system’s defenses against three categories of hazard: duplicate execution (the same event processed multiple times), data corruption (invalid state changes propagating through the system), and governance violation (automated actions reaching Contacts who should be protected from them). Protection mechanisms are distributed across all four workflows what makes them a coherent layer is their shared purpose: maintaining system integrity in the face of expected production failure modes. The four Protection mechanisms are: idempotent batch upsert (Workflow A and D), transition validation with invalid-transition halt (Workflow B), governance gate (all four workflows), and follow-up stop conditions (Workflow C).

The Protection layer’s value is most apparent when it is missing. A system without idempotent writes creates duplicate Contact records under form platform retry conditions which occur every time the platform experiences a network timeout. A system without transition validation allows external writes to corrupt the lifecycle state machine. A system without a governance gate sends automated follow-up to Contacts actively being managed by a broker.

The governance gate is the Protection layer’s most consequential mechanism because it operates at the communication delivery boundary. All other protection mechanisms prevent data corruption. The governance gate prevents relationship corruption: a broker’s active correspondence with an Opportunity-stage Contact being interrupted by an automated follow-up from a system that does not know the broker is engaged.

The manual_override_active property is the explicit signal of human engagement. The governance gate is the automated system’s acknowledgment that human engagement takes precedence over the follow-up schedule.

Implementation: Workflow C Follow-up Stop Conditions

Step 19 Per-Contact Stop Condition Evaluation

Purpose

Before the governance gate is evaluated, check whether this Contact has already reached a permanent terminal state that removes it from the follow-up queue entirely. Stop conditions skip the Contact with no property writes not even a governance-hold Note. They are distinct from governance gate blocks, which are temporary and do produce Notes.


Operation Summary

Property Value
Node Type Code
Primary Function Evaluate four terminal stop conditions; route to skip or proceed
Input Contact properties from overdue query
Output stop_condition_hit: true/false, stop_reason

Implementation Logic

const contact = $input.item.json;
const action  = contact._current_action; // 'tp1' | 'tp2' | 'escalation'

// Stop Condition 1: Task already completed by broker
if (contact.hs_task_completion_count > 0 ||
    contact.associated_task_status === 'COMPLETED') {
  return [{ json: { ...contact, stop_condition_hit: true,
    stop_reason: 'task_completed_by_broker' } }];
}

// Stop Condition 2: Disqualified
if (contact.lifecyclestage === 'disqualified') {
  return [{ json: { ...contact, stop_condition_hit: true,
    stop_reason: 'contact_disqualified' } }];
}

// Stop Condition 3: Customer
if (contact.lifecyclestage === 'customer') {
  return [{ json: { ...contact, stop_condition_hit: true,
    stop_reason: 'contact_is_customer' } }];
}

// Stop Condition 4: Escalation already triggered (for escalation actions only)
if (action === 'escalation' && contact.escalation_triggered === 'true') {
  return [{ json: { ...contact, stop_condition_hit: true,
    stop_reason: 'escalation_already_triggered' } }];
}

return [{ json: { ...contact, stop_condition_hit: false } }];

Stop Condition Reference Table

Condition Trigger Effect
1 Task Completed Associated Task marked COMPLETED Skip permanently, no Note written
2 Contact Disqualified lifecyclestage = "disqualified" Skip permanently, no Note written
3 Contact is Customer lifecyclestage = "customer" Skip permanently, no Note written
4 Escalation Already Fired escalation_triggered = "true" (ESC only) Skip permanently, no Note written

Engineering Rationale

NoteEngineering Rationale

Stop conditions and governance gate conditions are functionally distinct and must not be confused. Stop conditions are permanent: a Customer Contact is never re-queued, regardless of governance state. Governance gate blocks are temporary: a cooldown-blocked Contact is skipped for this cycle but re-evaluated at the next cycle (the due timestamp is not cleared). Treating a cooldown block as a stop condition skipping the Contact permanently would prevent TP1 from ever firing after the cooldown expires.

Implementation: Workflow D Idempotency Check

Step 20 Survey Response Idempotency Check

Purpose

Compare the incoming Typeform response_id against the Contact’s stored last_typeform_response_id to detect duplicate deliveries before any survey properties are written. Typeform delivers webhooks with retry logic the same response may arrive two or more times. This check makes survey processing idempotent.


Operation Summary

Property Value
Node Type Code (compare) + HTTP Request (HubSpot Contact read)
Primary Function Detect duplicate Typeform response delivery
Input response_id from Typeform payload, contactId
Output is_duplicate: true/false

Implementation Logic

// After identity resolution has retrieved the Contact record:
const contact     = $input.item.json;
const incoming_id = $('Typeform Payload').item.json.response_id;
const stored_id   = contact.last_typeform_response_id;

if (stored_id && stored_id === incoming_id) {
  return [{ json: { ...contact, is_duplicate: true } }];
}

return [{ json: { ...contact, is_duplicate: false } }];

If is_duplicate = true, the workflow returns 200 to Typeform immediately and exits without writing any properties. No Note is created for duplicate skips they are routine and do not require an audit record.


Output Table

Output Description
is_duplicate true if response_id matches stored ID; false for new response

Engineering Rationale

NoteEngineering Rationale

Typeform’s webhook retry behavior is documented: if the receiving endpoint does not respond with a 2xx status within the timeout window, Typeform retries delivery up to three times at exponential backoff intervals. An n8n workflow that processes a survey response in 2–4 seconds may receive the same response again if a momentary network delay caused the first delivery’s response to arrive late. Without the idempotency check, the second delivery overwrites survey_completed_at and other survey properties including potentially clearing a requires_rescore_review flag that was set correctly on the first delivery. The idempotency check is a correctness requirement, not an optimization.

Integration Checkpoint 2.10.10: Submit the same Typeform webhook payload twice (simulating a retry). Verify: the first delivery writes survey properties and sets last_typeform_response_id. The second delivery returns 200 without writing any properties. Verify that survey_completed_at was not updated by the second delivery.

CautionProduction Risk

Confusing stop conditions with governance gate conditions produces a Contact who stops being evaluated permanently after a single cooldown block. Stop conditions permanently remove a Contact from the follow-up queue they short-circuit the loop with no property writes. Governance gate conditions skip a Contact for the current execution cycle only, leaving the due timestamp active for reconsideration in the next cycle. A cooldown-blocked Contact treated as a stop condition will never receive their follow-up after the cooldown expires.

CautionProduction Risk

Not checking escalation_triggered before executing escalation actions produces duplicate escalation alerts under any condition where Workflow C runs more than once after the escalation threshold has passed. The escalation stop condition check if escalation_triggered = "true", skip must precede the escalation action node in the loop. Without it, every Workflow C execution after the first escalation fires will send another escalation Slack alert and create another ops Task.

CautionProduction Risk

Not having a try/catch around the idempotency check query in Workflow D means that a transient HubSpot API error during the last_typeform_response_id read will cause the idempotency check to fail as an uncaught exception, terminating the workflow before the survey data is written. The correct behavior for a failed idempotency check read is to re-attempt the check (with retry backoff) or to proceed with a conservative “assume duplicate” exit not to crash the workflow entirely.


2.10.11 Logging Implementation

The Logging layer creates a durable, human-readable audit trail of every significant action the CRM automation platform performs. Logs are written to two destinations: HubSpot Contact Notes (for Contact-specific action history visible in the HubSpot UI to both brokers and engineers) and n8n’s execution log (for workflow-level debugging and incident investigation). Every workflow writes at least one HubSpot Note per Contact per significant action: Workflow A writes an intake summary Note with the complete scoring trace; Workflow B writes a lifecycle-transition Note; Workflow C writes a follow-up action Note (and a governance-hold Note when applicable); Workflow D writes a survey-completion Note.

The audit Note written by Workflow A at intake is the system’s most comprehensive logging output. It records normalized field values, the rule score breakdown by signal group, the AI score and confidence value, the combined score, the priority label, the lifecycle stage assigned, and the follow-up due timestamps. A broker opening a Contact record for the first time regardless of elapsed time can reconstruct the complete intake context from the audit Note without accessing n8n.

HubSpot Note-based logging has a deliberate advantage over external log aggregation systems: it is accessible to non-engineering stakeholders. A broker or operations manager investigating unexpected behavior a Contact receiving a follow-up after opting out, a deal appearing without a corresponding Task can investigate the Contact’s Note history in HubSpot without needing access to n8n, without engineering credentials, and without understanding the workflow execution model.

Implementation: Workflow A Intake Audit Note

Step 21 Intake Audit Note Creation

Purpose

Create the comprehensive intake audit Note as the final action in Workflow A’s main execution path. This Note is the system’s most complete logging artifact it records the complete context of every intake event in a format that is human-readable without any access to n8n or engineering credentials.


Operation Summary

Property Value
Node Type HTTP Request (create Note) + HTTP Request (associate Note)
Method POST (create), PUT (associate)
Endpoint /crm/v3/objects/notes, /crm/v4/objects/notes/{id}/associations/contacts/{contactId}/note_to_contact
Primary Function Write complete scoring and routing trace to Contact timeline
Input All output from all preceding Workflow A steps
Output Note created and associated to Contact

Note Body Structure

const d = $input.item.json;
const now = new Date().toISOString();

const note_body = `
INTAKE AUDIT LOG ${now}
════════════════════════════════════════

CONTACT
  Name:          ${d.firstname} ${d.lastname}
  Email:         ${d.email}
  Phone:         ${d.phone || '(not provided)'}
  Company:       ${d.company || '(not provided)'}
  Contact Action:${d.contact_action}

RULE SCORING
  Source Channel:  ${d.rule_score_breakdown?.source_channel ?? 0}
  Property Size:   ${d.rule_score_breakdown?.property_size ?? 0}
  Property Type:   ${d.rule_score_breakdown?.property_type ?? 0}
  Timeline:        ${d.rule_score_breakdown?.timeline ?? 0}
  Completeness:    ${d.rule_score_breakdown?.completeness ?? 0}
  RULE TOTAL:      ${d.rule_score}/20

AI SCORING
  Valid:           ${d.ai_score_valid}
  Intent Level:    ${d.ai_score ?? 'N/A'}
  Confidence:      ${d.ai_confidence ?? 'N/A'} (${d.confidence_band ?? 'fallback'})
  Signals:         ${(d.ai_signals_detected || []).join(', ') || 'N/A'}

COMBINED SCORE
  combined_score:  ${d.combined_score}
  priority_label:  ${d.priority_label}
  model_version:   ${d.scoring_model_version}

ROUTING
  lifecyclestage:  ${d.target_lifecyclestage}
  notification:    ${d.notification_channel}

FOLLOW-UP TIMING
  Tier:      ${d.followup_schedule_tier}
  TP1 due:   ${d.followup_tp1_due_at || '(none)'}
  TP2 due:   ${d.followup_tp2_due_at || '(none)'}
  ESC due:   ${d.followup_esc_due_at || '(none)'}

GOVERNANCE INIT
  status:    ${d.communication_governance_status}
  cooldown:  ${d.communication_cooldown_until}
`.trim();

Create Note:

{
  "properties": {
    "hs_note_body": "{{note_body}}",
    "hs_timestamp": "{{processing_timestamp_iso}}"
  }
}

Associate Note:

PUT /crm/v4/objects/notes/{noteId}/associations/contacts/{contactId}/note_to_contact

Engineering Rationale

NoteEngineering Rationale

The intake audit Note must be the last node in Workflow A’s main execution path. Writing it before the Routing, Timing, or Delivery nodes complete produces a Note missing the lifecycle assignment, notification channel, follow-up timestamps, or governance initialization values all of which are computed after the scoring nodes complete. A Note written too early is an incomplete audit record that cannot support incident investigation. Two API calls are always required: create (returns the Note ID) and associate (links it to the Contact so it appears in the timeline). A Note that is not associated is orphaned in HubSpot’s database and invisible to brokers and operators.

Implementation: All Workflows Error Logging

Step 22 Error Branch Note and Alert

Purpose

When any node in any workflow throws an exception or returns an error response, the error branch creates a HubSpot Note on the affected Contact (when a Contact ID is available) and sends a Slack alert to the ops channel. This ensures that every workflow failure leaves a Contact-visible audit record and an immediate operator notification.


Operation Summary

Property Value
Node Type HTTP Request (Note create + associate) + HTTP Request (Slack post)
Primary Function Log failure; alert ops team; write Contact-visible error record
Input Error object, workflow name, failing node name, Contact ID (if available)
Output Error Note on Contact, Slack alert to #crm-ops-intake

Error Note Body Structure

const error_note = `
ERROR LOG ${new Date().toISOString()}
════════════════════════════════════

Workflow:    ${workflow_name}
Node:        ${failing_node_name}
Error:       ${error_message}
Contact ID:  ${contactId || '(unavailable)'}
Email:       ${email || '(unavailable)'}

Input Data (relevant fields):
  ${JSON.stringify(relevant_input_fields, null, 2)}

Action taken: No HubSpot writes were attempted after this node.
`.trim();

Slack Error Alert Payload

{
  "channel": "{{$env.SLACK_CHANNEL_OPS_INTAKE}}",
  "text": "⚠️ Workflow error in {{workflow_name}} at node {{node_name}}. Contact: {{email || contactId}}. Check n8n execution log and HubSpot Contact Note for details."
}

Engineering Rationale

NoteEngineering Rationale

Error branch Slack calls and HubSpot Note writes must not be configured as “continue on error.” If the error handling code itself fails silently, the original error goes completely unrecorded. An error in the error handler should write to n8n’s built-in execution log at minimum, even if all external logging fails. The Slack channel name for error alerts must be stored as an environment variable SLACK_CHANNEL_OPS_INTAKE not hard-coded. A renamed channel that silently breaks error alert delivery (as demonstrated in the Northgate Capital Partners case study) leaves the operations team unaware of workflow failures for potentially days.

Integration Checkpoint 2.10.11: For the fully assembled system, submit one complete-path test intake and verify: (1) the HubSpot audit Note contains the complete scoring trace; (2) the Workflow C TP1 Note records the action timestamp and governance check result; (3) a deliberately invalid payload triggers the error branch, the error log appears in n8n’s execution history, the error Note is created on the Contact if a Contact ID exists, and no HubSpot writes were attempted for the invalid fields.

CautionProduction Risk

Writing the intake audit Note before all layers have completed produces a Note missing critical data. Students who place Note creation immediately after the Scoring layer produce Notes missing the lifecycle routing decision, the notification channel, and the follow-up timing metadata all computed after scoring. The intake audit Note must be the final node in Workflow A’s main execution path, after all Delivery and Timing layer nodes have completed.

CautionProduction Risk

Not associating the HubSpot Note to the Contact after creation leaves the Note orphaned in HubSpot’s database without any Contact reference. The Note will not appear in the Contact’s timeline, will not be findable from the Contact record, and will not be accessible to brokers or operators investigating a Contact’s history. Two API calls are always required: POST /crm/v3/objects/notes to create, and PUT /crm/v4/objects/notes/{noteId}/associations/contacts/{contactId}/note_to_contact to associate.

ImportantCritical Requirement

Configuring error-path Slack calls and HubSpot Note writes as “continue on error” means that a failure in the error handling code itself is silently swallowed. If the error Note creation fails (because the Contact ID was not available) and the Slack error alert fails (because the channel was renamed), the original error goes completely unrecorded. Error handling code must be held to the same reliability standards as main-path code: failures in the error branch should write to n8n’s built-in execution log at minimum, even if external logging fails.


Production Consideration

Pre-Deployment Property Schema Audit Is Not Optional

HubSpot’s batch upsert API silently ignores writes to properties that do not exist in the target account no error is returned, no data is written, and no log entry appears in n8n’s execution history. A workflow that correctly computes and writes combined_score will produce a Contact record with no combined_score value if the property was not created in HubSpot before activation. This failure mode is invisible until a broker reviews the Contact record and finds the scoring fields empty.

Before activating any workflow against a production HubSpot account, run a property audit query that reads all custom properties from the account and compares them against the expected schema. Any missing property is a blocking defect. The audit should also verify that each property’s field type matches its expected type a combined_score property created as a single-line text field instead of a number field will silently accept the write but make numeric range filtering in the HubSpot Search API fail without error.

The property schema audit is the first step of the pre-production verification checklist, not a finishing step. It must complete with zero discrepancies before any live form submission is processed.

Operational Considerations

Before deploying the assembled four-workflow platform to a production HubSpot account, four verification categories must be completed.

HubSpot property schema: all 40+ Contact properties must exist in the target HubSpot account. Properties that do not exist are silently ignored by the batch upsert API no error is returned but the data is lost. Run a property audit query against the production account before activating any workflow.

Environment variables: verify all n8n environment variables are set and tested HUBSPOT_API_KEY, OPENAI_API_KEY, TYPEFORM_API_KEY, FORM_WEBHOOK_SECRET, SCORING_CONFIG, FOLLOWUP_CONFIG_HOT/WARM/COOL/COLD, INTERNAL_DOMAINS, TYPEFORM_SURVEY_ID, HUBSPOT_DEAL_PIPELINE_ID, HUBSPOT_STAGE_APPT_SCHEDULED, HUBSPOT_STAGE_CLOSED_WON. Test each API key with a single authenticated request before activation.

Slack channel verification: verify the Slack bot has posting access to all five channels. Store all channel names as environment variables.

Test scenario execution: run all four scenarios from Chapter 2.9 against a HubSpot sandbox account before activating against the production account.

Two monitoring mechanisms should be in place beyond n8n’s built-in execution log: a weekly property consistency check querying HubSpot for Contacts where journey_state does not match the expected value for their lifecyclestage, and a daily escalation audit querying for Contacts where escalation_triggered = true and escalation_triggered_at is more than 14 days old without a resolved Task.


Case Study

Meridian Commercial Brokerage Full Platform Deployment

Meridian Commercial Brokerage deployed the four-workflow CRM automation platform to replace a manual lead management process using a shared Google Sheet and individual broker email inboxes. The prior system’s average lead response time was 31 hours; 22% of SQL-qualified leads had no follow-up record within 48 hours of submission.

Sarah Chen submitted the intake form at 9:47am on a Monday with referral source channel, Class A Office requirement, 8,000–12,000 sqft, immediate timeline, and a detailed inquiry description noting a hard lease expiry and confirmed CFO budget approval. Workflow A processed the submission in 3.1 seconds: rule_score = 16, AI score = 7 (confidence 0.87, high band), combined_score = 16 + min(4, 7×1.0) = 20, priority_label = “Hot”. The Qualification Switch routed to the Hot SQL branch: lifecyclestage = "salesqualifiedlead", notification to #sales-alerts. TP1 due at 11:47am, TP2 at 3:47pm, escalation at 9:47am Tuesday.

The HubSpot lifecycle webhook fired 4 seconds after Workflow A’s lifecycle write. Workflow B validated lead -> salesqualifiedlead as valid, created Deal “Meridian Partners Sarah Chen 2026” in Appointment Scheduled stage, associated it to Contact and Company, and wrote journey_state = "qualified_lead". At 12:00pm, the first Workflow C cycle after 11:47am found followup_tp1_due_at <= now. Governance gate passed. TP1 Task created and associated; #broker-tasks Slack notification sent; followup_tp1_sent_at = 12:00pm; communication_cooldown_until = 12:30pm.

At 2:15pm, Workflow D received Sarah’s completed Typeform survey. Identity resolved, governance permitted, new response_id confirmed, survey properties written: 8,000–12,000 sqft, Midtown/Grand Central submarket preference, Managing Partner decision-maker role, immediate timeline confirmation. Timeline matched intake no rescore flag set. Three weeks later, the broker advanced lifecyclestage = "opportunity" after LOI execution. Workflow B set manual_override_active = true. Workflow C’s subsequent executions governance-blocked all automated follow-up. Six weeks later, deal marked Closed Won: Workflow B updated lifecyclestage = "customer", manual_override_active = false, journey_state = "customer". Workflow C’s next execution found the Customer stop condition and permanently removed Sarah from the follow-up queue.

Sarah progressed from intake to customer in 47 days. Every automated action was correct, timely, and governed appropriately. The broker’s active management period was fully protected by manual_override_active. Total n8n execution errors across the 47-day lifecycle: zero.


Lab Deliverables

Chapter 2.10 Lab: Four-Workflow CRM Platform

Deliverable 1 Workflow A: Lead Intake and Enrichment Configure all eleven layers of Workflow A in n8n. The workflow must receive POST submissions via a webhook endpoint with header validation; normalize and validate all fields; perform Contact and Company batch upsert; compute rule_score via SCORING_CONFIG; call OpenAI for AI scoring with the three-node pattern; combine scores using the additive model with four constraints; route via Qualification Switch (4 branches); write follow-up timing metadata; initialize governance state; and create the intake Task, audit Note, and Slack notification. All integration checkpoints 2.10.1 through 2.10.11 must pass.

Deliverable 2 Workflow B: Lifecycle and Opportunity Management Configure Workflow B with HubSpot lifecycle change webhook trigger; PERMITTED_TRANSITIONS validation with invalid-transition halt and alert; notification routing by lifecycle stage; Deal creation on SQL transition with Contact and Company associations; Opportunity entry with manual_override_active = true; Customer entry with manual_override_active = false and follow-up queue termination.

Deliverable 3 Workflow C: Follow-up and Governance Configure Workflow C with schedule trigger; business hours check; three parallel HubSpot Search queries for overdue TP1, TP2, and Escalation Contacts; per-Contact loop with stop conditions and governance gate; TP1, TP2, and Escalation action branches; governance-hold Notes on blocked actions; and post-action state writes.

Deliverable 4 Workflow D: External Integration Handler Configure Workflow D with Typeform webhook trigger; payload logging; schema validation and email normalization; identity resolution via HubSpot Contact search; governance gate; idempotency check; scoped survey property write; timeline upgrade detection; and audit Note creation.

Deliverable 5 HubSpot Property Schema Verify and document all 40+ HubSpot Contact properties. Submit a property audit report showing: internal name, display name, type, property group, and owner workflow for each property.

Deliverable 6 End-to-End Scenario Trace Submit documentation of a live end-to-end scenario trace against the assembled system. Include: the submitted test payload, a screenshot of the HubSpot Contact record showing expected property values, a screenshot of the intake audit Note showing the complete scoring trace, a screenshot of the Slack notification, and the n8n execution log showing successful completion of all four workflows triggered by the scenario.


Portfolio Project

Professional CRM Automation Platform Package

Package the completed four-workflow platform as a professional freelance deliverable suitable for client handoff. The package has seven sections.

Section 1 Architecture Overview (1 page): Describe the complete four-workflow architecture in client-accessible language what the system does, how leads flow through it, how it protects broker relationships, and what operational outcomes the client can expect. Include the capstone architecture diagram.

Section 2 Workflow Descriptions (4 pages, 1 per workflow): For each workflow: trigger type and configuration, primary business function, all workflow nodes in execution order with their function and key configuration, inputs (HubSpot properties read and external payloads), outputs (properties written, notifications sent, records created), error handling, and integration with the other three workflows.

Section 3 API Inventory: Complete inventory of all external API integrations. For each: API name, endpoint(s) called, authentication method, payload format, response handling, error behavior, known rate limits, and the n8n workflow and node that calls it. APIs to document: HubSpot CRM API, HubSpot Webhooks API, OpenAI Chat Completions API, Typeform Webhooks API, Slack API.

Section 4 HubSpot Property Inventory: Complete inventory of all Contact properties created for the platform. For each: internal name, display name, type, property group, owner workflow, description of purpose, and the workflow node that writes it.

Section 5 Governance Rules: Document the governance model in client-accessible language: governance status values and operational meaning, manual override mechanism and when brokers should use it, follow-up cadence by priority tier, escalation thresholds and notification destinations, PERMITTED_TRANSITIONS state machine with a lifecycle diagram, and property ownership rules.

Section 6 Implementation Notes: Engineering notes for future maintenance: all environment variables with descriptions (redact sensitive key values), scoring model calibration rationale (additive formula vs. weighted-sum, Chapter 2.9.3 analysis), AI confidence threshold values and operational meaning, known architecture limitations with references to Part II sections that address them, and recommended monitoring checks.

Section 7 Deployment Considerations: Practical deployment guidance: n8n hosting requirements, HubSpot tier requirements (Operations Hub for custom properties and webhooks), OpenAI billing configuration, Typeform plan requirements for webhook delivery, backup and recovery for workflow configurations, and the process for activating the platform against a new HubSpot account.


Discussion Questions

  1. The Timing layer’s business hours utility function adjusts follow-up due timestamps at write time (Workflow A) rather than at evaluation time (Workflow C). Describe a scenario where this approach produces a suboptimal outcome for example, a TP1 notification that fires significantly later than its nominal interval due to the business hours adjustment and evaluate whether deferring the adjustment to Workflow C’s evaluation time would produce better results. What are the trade-offs of each approach?

  2. The Score Combination layer applies the calibrated additive formula that replaced the weighted-sum formula from Chapter 2.5’s original specification. In the deployed platform, how would you measure whether the calibrated formula is producing better operational outcomes than the original would have? What data would you collect, over what time period, and what would constitute evidence that the formula should be recalibrated again?

  3. The Property Ownership Model assigns each HubSpot property group to exactly one owner workflow, but communication_governance_status is initialized by Workflow A and updated by Workflow C. Does this constitute a dual-ownership violation of the ownership model? How would you resolve the dual-write situation, and what rule would you establish to determine which workflow’s write takes precedence in a timing conflict?

  4. The Lab Deliverables require all four workflows to be verified against integration checkpoints before the end-to-end scenario trace. In a client engagement where the HubSpot account contains active live data and workflows must be deployed incrementally, how would you sequence the integration checkpoints to minimize risk to the client’s existing data?

  5. The Portfolio Project’s governance documentation is intended for a non-technical client. What information from the governance model PERMITTED_TRANSITIONS, cooldown windows, confidence bands is necessary for the client to understand in order to manage the system effectively, and what can be safely omitted from client-facing documentation without reducing their ability to oversee the platform?

  6. The case study describes a 47-day lead-to-customer journey with zero n8n execution errors. In a real production deployment processing 200 leads per week, what is a realistic frequency of execution errors, and what monitoring infrastructure would you put in place to detect, triage, and recover from them without manual engineering intervention for routine failures?


Diagram 2.10.5 Final CRM Automation Platform Architecture

G cluster_d Workflow D External Integration Handler cluster_ext External Systems cluster_a Workflow A Lead Intake & Enrichment cluster_rules Cross-Cutting Rules cluster_b Workflow B Lifecycle Mgmt & Deals cluster_core HubSpot CRM System of Record cluster_c Workflow C Follow-up Monitor & Governance Form Website Form A Intake -> Structuring -> Rules Enrich -> AI Enrich -> Combination -> Validation -> Routing -> Timing Write -> Protection -> Logging Form->A TF Typeform Survey D Payload Log -> Schema Validate -> Identity Resolve -> Governance Gate -> Idempotency Check -> Scoped Write -> Audit Note -> Rescore Flag TF->D HS_ext HubSpot (SoR) B Webhook Trigger -> PERMITTED_TRANS check Valid -> Route | Invalid -> Alert -> Deal Create -> Opportunity Ovr -> Customer Close -> journey_state / manual_override HS_ext->B Slack Slack CORE Contact Properties (40+) Deal Records | Companies Audit Notes | Tasks Associations | Property Groups A->CORE D->CORE B->CORE C Schedule Trigger (0,30 8-20 * * *) BH Check -> Query -> Stop Conditions -> Governance Gate -> TP1 / TP2 / ESC -> sent_at / cooldown CORE->C Broker Broker Team C->Broker Slack notifications GATE Governance Gate (all 4 workflows before communication): 1. manual_override_active = false 2. governance_status = active 3. cooldown_until < now SCORE Scoring Model (additive, calibrated): combined = rule_score(0-20) + min(4, ai_score x conf_multiplier) Hot>=20 | Warm 13-19 | Cool 7-12 | Cold<7 STATE State Machine (PERMITTED_TRANSITIONS): lead->MQL/SQL | MQL->SQL SQL->Opportunity | Opportunity->Customer Invalid -> alert + halt
Figure 27.4: Final CRM Platform Architecture. Final CRM Automation Platform Architecture

Field-level property detail for every workflow shown above is provided in the Property Ownership Table (fig-2-10-3) elsewhere in this chapter; this capstone diagram is condensed to named components and key relationships for legibility.


Chapter Summary

Chapter 2.10 demonstrates that a professional CRM automation platform is assembled from well-understood architectural layers with bounded responsibilities and defined integration interfaces, not built as a single monolithic workflow. The eleven implementation subsections Intake, Structuring, Rule-Based Enrichment, AI Scoring, Score Combination, Validation, Routing, Timing, Delivery, Protection, and Logging correspond to the eleven layers of the CRM architecture. Each layer has one job. Each integration checkpoint verifies that the completed layers produce the correct outcomes before the next layer is added. This incremental build-and-verify discipline is the professional alternative to assembling all layers simultaneously and debugging from first principles when the system does not behave as expected.

The four workflows coordinate through shared HubSpot state rather than direct calls, which makes them independently testable, independently deployable, and independently maintainable. Workflow A writes a lifecyclestage property, which triggers Workflow B through HubSpot’s webhook infrastructure. Workflow A writes followup_*_due_at timestamps, which Workflow C reads through HubSpot’s Search API. Workflow D writes survey data to the survey_* namespace, which Workflow A reads when evaluating a rescore flag. The architecture does not require a workflow orchestrator or message bus shared HubSpot state is the coordination primitive, and the property ownership model is the governance layer that prevents write conflicts.

Three architectural decisions define the platform’s operational reliability throughout the system. The idempotent batch upsert pattern prevents duplicate records under form platform retry conditions. The governance gate applied by all four workflows before any communication or property write prevents automated actions on Contacts under human management. The pre-computed timing timestamp pattern enables HubSpot’s Search API to query overdue Contacts at scale, rather than requiring Workflow C to fetch and filter all active Contacts in n8n. The scoring model’s calibrated additive formula resolves the Hot-tier gap identified in Chapter 2.9.3 making all four priority tiers reachable and the Hot threshold of 20 achievable for near-maximum-signal leads.

Transition to Chapter 2.11

Chapter 2.11 builds directly on the system constructed in this chapter.

Key Takeaways

  • A CRM automation platform is assembled from architectural layers with bounded responsibilities, not built as a monolithic workflow. Each layer’s correct output is a prerequisite for the next layer’s correct operation.
  • Integration checkpoints specific, verifiable state assertions at defined build stages provide incremental quality assurance and a known-good baseline for isolating failures introduced by subsequent layers.
  • The property ownership model is the integration blueprint: each HubSpot property group is owned by exactly one workflow, and only that workflow writes to it in normal operation. Consulting the ownership map before configuring any write node is mandatory.
  • The four workflows coordinate through shared HubSpot state property reads and writes rather than direct calls. No orchestrator is needed; HubSpot’s webhook infrastructure and scheduled triggers are the event delivery mechanism.
  • Idempotency at three layers prevents duplicate execution: batch upsert (Workflow A and D), transition validation halt (Workflow B), and idempotency response ID check (Workflow D).
  • The calibrated additive formula combined = rule_score + min(4, ai_score × confidence_multiplier) resolves the Hot-tier calibration gap and produces a practical maximum of 24, making all four priority tiers achievable.
  • Validation must precede all writes. The intake audit Note must be written last. Task creation must precede Slack notification. These are ordered structural requirements, not style preferences.
  • All external system identifiers Slack channel names, user IDs, survey IDs, API endpoints must be stored as n8n environment variables. Hard-coded identifiers silently break when external systems are renamed or reconfigured.
  • Stop conditions (permanent skip) and governance gate conditions (temporary skip, retry next cycle) are functionally distinct. Confusing them produces either permanently missed follow-ups or duplicate escalations.
  • The Portfolio Project deliverable packaging the assembled platform as a client-handoff document represents the program’s mastery standard: not just the ability to build the system, but the ability to explain, defend, and hand it off to a client or successor engineer.

End of Chapter 2.10 Lab: CRM System Implementation (n8n)