← All ArticlesLifecycle Engineering13 min read

Using HubSpot Custom Code Actions to Build Intelligent Referral Matching

Patient referral form data moving through a clinic matching engine and routing paths.

Introduction

Referral workflows are deceptively hard because human-entered names are messy. A patient may submit “Main Street Vet,” while the CRM stores “Main Street Veterinary Hospital LLC.” Another patient may spell the clinic correctly but use an old phone number. A third may submit a clinic that does not exist in the CRM at all.

This case study explains how I used HubSpot custom code actions to build an intelligent clinic referral matching system. Patients submitted a form asking that their veterinary clinic offer Scratch Pay. The workflow needed to decide whether the clinic already existed, whether the submitted data matched a CRM record, whether an associated contact was eligible to receive communication, or whether the clinic should become a sales lead.

The key engineering idea was confidence-based matching. Instead of pretending clinic names were reliable, the workflow collected additional fields, searched CRM records, scored possible matches, and routed each submission based on confidence.

Business Problem

The business goal was to turn patient demand into clinic acquisition and clinic activation. If a patient wanted Scratch Pay at a clinic, the system needed to do something useful with that signal.

The workflow had to answer several questions:

  • Does this clinic already exist in HubSpot?
  • Does the submitted clinic match one known CRM record?
  • Is the matched clinic associated with a contact who can receive the message?
  • If the clinic does not exist, should the submission create a new company or lead?
  • If the match is ambiguous, should a human review it?

The patient form originally could not rely on clinic name alone. Clinic names are inconsistent in the real world:

  • “ABC Vet”
  • “ABC Veterinary”
  • “ABC Veterinary Clinic”
  • “ABC Animal Hospital”
  • “A.B.C. Vet Clinic”

Names also collide. Two clinics can share similar names across different cities or states. A fuzzy name match without location context can create false positives, and false positives are worse than no match because they send communication to the wrong clinic.

The system needed more evidence.

Why Native Platform Features Weren’t Enough

HubSpot workflows are strong for standard branching, property updates, and notifications. But this use case needed more than “if clinic name contains X.”

Native workflow branches are not ideal for:

  • Searching across many company records with multiple fields
  • Normalizing submitted text
  • Scoring match confidence
  • Handling ambiguous matches
  • Inspecting associated contacts
  • Creating CRM records only when confidence is low enough
  • Returning structured outputs for downstream workflow branches

HubSpot custom code actions were the right tool because HubSpot supports extending workflow behavior with code. The official custom code actions documentation describes how workflow steps can execute JavaScript or Python, depending on account capabilities. The CRM search layer came from HubSpot’s CRM search endpoint documentation, which supports filtering and searching CRM records.

The workflow did not need a separate application. It needed a programmable decision step inside HubSpot.

Solution Architecture

The architecture kept HubSpot as the orchestration system and used custom code as the matching engine.

Patient referral form
  - clinic name
  - clinic phone
  - clinic website
  - city
  - state
  - zip
  - patient email
        |
        v
HubSpot workflow
        |
        v
Custom code action
  - normalize submitted fields
  - search company records
  - calculate confidence
  - inspect associated contacts
  - return match status
        |
        v
Workflow branches
  - exact match: send eligible clinic message
  - probable match: send or review depending rules
  - ambiguous match: create review task
  - no match: create sales lead / route SDR

The workflow output was not just “matched” or “not matched.” It returned a match class:

exact_match
probable_match
ambiguous_match
no_match

That gave downstream automation enough structure to behave differently for each case.

Technical Implementation

The first implementation decision was adding form fields. Clinic name alone was not enough. The form needed just enough friction to collect useful matching evidence without making the patient abandon it.

The useful fields were:

  • Clinic name
  • City
  • State
  • ZIP code
  • Phone number
  • Website

The custom code action normalized those values before searching.

function normalizeName(value) {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9 ]/g, "")
    .replace(/\b(veterinary|vet|clinic|hospital|animal|llc|inc)\b/g, "")
    .replace(/\s+/g, " ")
    .trim();
}

function normalizePhone(value) {
  return value.replace(/\D/g, "").slice(-10);
}

The action then searched HubSpot company records using combinations of name, phone, domain, city, state, and ZIP. The simplified flow looked like this:

1. Search by normalized website domain.
2. Search by normalized phone number.
3. Search by clinic name plus state.
4. Search by clinic name plus city.
5. Score all candidates.
6. Classify the best candidate.

The scoring model was intentionally explainable. It did not need machine learning. It needed predictable tiers.

function scoreCandidate(input, company) {
  let score = 0;

  if (input.domain && input.domain === company.domain) score += 45;
  if (input.phone && input.phone === company.phone) score += 35;
  if (input.state && input.state === company.state) score += 10;
  if (input.zip && input.zip === company.zip) score += 10;
  if (nameSimilarity(input.name, company.name) > 0.9) score += 25;
  if (nameSimilarity(input.name, company.name) > 0.75) score += 15;

  return score;
}

The final classification used confidence thresholds:

exact_match:
  one candidate with strong phone/domain evidence
  or one candidate with very high name + location confidence

probable_match:
  one candidate above threshold
  but missing a stronger identifier

ambiguous_match:
  multiple candidates above threshold
  or close scores across records

no_match:
  no candidate above threshold

After identifying the company, the action checked communication eligibility. A clinic match did not automatically mean the workflow should send an email. The system still needed to inspect associated contacts and suppression fields.

function isEligibleContact(contact) {
  return (
    contact.email &&
    contact.lifecycle_stage !== "customer_champion_suppressed" &&
    contact.marketing_opt_out !== true &&
    contact.role !== "billing_only"
  );
}

The custom code action returned structured outputs to the workflow:

{
  "match_status": "probable_match",
  "company_id": "12345",
  "eligible_contact_id": "67890",
  "confidence_score": 82,
  "route_to_sdr": false,
  "needs_manual_review": false
}

Workflow branches then handled the next step. An exact or probable match with an eligible contact could trigger a personalized email. A no-match submission could create a company record, create a deal or task, and route it to an SDR. An ambiguous match could create a review task instead of sending anything automatically.

Engineering Decisions & Tradeoffs

The first tradeoff was form friction versus match quality. Adding fields makes the form longer. Not adding fields makes matching unreliable. I chose a small set of high-signal fields because the workflow needed enough evidence to avoid false positives.

The second tradeoff was deterministic scoring versus machine learning. A machine-learning approach would have been harder to explain, harder to debug, and unnecessary for the data volume and operational need. A weighted scoring model was transparent. When the workflow made a decision, the team could understand why.

The third tradeoff was automation versus review. It is tempting to automate every path. That is unsafe when communication can go to the wrong clinic. The ambiguous state was an important design feature. It kept the system from pretending uncertainty did not exist.

The fourth tradeoff was where to create CRM records. Creating a record for every referral can pollute the CRM. Creating none loses demand signals. The compromise was to create or route only when the match was genuinely absent or needed sales review.

Failure handling focused on safe defaults:

  • If the search API failed, create a review task instead of sending.
  • If multiple companies tied, mark ambiguous.
  • If no eligible contact existed, route internally.
  • If the confidence score was below threshold, do not send external communication.

That is the right bias for lifecycle systems connected to CRM data: when uncertain, avoid sending the wrong message.

Lessons Learned

The first lesson is that names are identifiers only in small demos. In production CRMs, names are hints. Phone, website, ZIP, city, state, associations, and historical activity all produce better evidence.

The second lesson is that confidence tiers are more useful than binary matches. Exact, probable, ambiguous, and no match are operationally different states. A workflow should preserve those distinctions.

The third lesson is that custom code actions are best used as decision engines, not entire applications. HubSpot still handled form intake, workflow branches, CRM updates, and routing. The custom code action only handled the part that needed code.

The fourth lesson is that communication eligibility has to be checked after matching. A correct company match can still be the wrong send if the associated contact is suppressed, unqualified, or not the right recipient.

For more on lifecycle systems that connect behavior, CRM state, and revenue movement, read How to Build a Customer Lifecycle Marketing Strategy From Scratch.

Closing Thoughts

The clinic referral matching system worked because it treated a patient referral as structured evidence, not just a form submission.

The workflow collected better fields, searched the CRM, scored candidates, classified confidence, checked communication eligibility, and routed the result. HubSpot handled orchestration. Custom code handled ambiguity.

That is the broader lifecycle engineering pattern: when native workflow branches are too blunt, insert a small deterministic decision layer. The goal is not to make the system clever. The goal is to make it accurate enough to automate safely.

Get one retention idea, every other week

The Lifecycle Letter. One actionable retention idea in your inbox, no fluff, unsubscribe anytime.

Ronald Davenport
Ronald Davenport

Founder of Lifecycle Architect. Built the lifecycle and retention systems behind products with 4M+ users, including 367% YOY lifecycle revenue growth at Zendrop.